Merge latest PR #1518 into 2.0.0-beta.2

This commit is contained in:
Levi committed 2026-09-17 21:47:43 +08:00
commit c6dd58848d
8 files changed
+257 -186

No files matched your search

@@ -7,6 +7,7 @@ import '../../../../core/utils/breakpoints.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_path_picker_field.dart';
import '../../../../shared/widgets/app_path_placeholder_button.dart';
import '../../../../shared/widgets/app_primary_button.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../l10n/l10n.dart';
@@ -180,6 +181,14 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
desktopWidth: AppDesignTokens.settingsFormControlWidth,
allowAndroidEditing: true,
),
const SizedBox(height: AppDesignTokens.space4),
Align(
alignment: AlignmentDirectional.centerStart,
child: AppPathPlaceholderButton(
key: const ValueKey('download-category-placeholders'),
controller: pathController,
),
),
if (validationMessage != null) ...[
const SizedBox(height: 8),
Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
@@ -1,43 +0,0 @@
import 'package:flutter/widgets.dart';
import '../../l10n/l10n.dart';
import '../theme/app_design_tokens.dart';
import '../theme/app_palette.dart';
/// A single editable name/value pair in a narrow headers form.
class AppHttpHeaderGroup extends StatelessWidget {
const AppHttpHeaderGroup({super.key, required this.name, required this.value, required this.remove});
final Widget name;
final Widget value;
final Widget remove;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Container(
padding: const EdgeInsets.all(AppDesignTokens.space12),
decoration: BoxDecoration(
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text(context.l10n.httpHeaderName)),
remove,
],
),
const SizedBox(height: AppDesignTokens.space4),
name,
const SizedBox(height: AppDesignTokens.space12),
Text(context.l10n.httpHeaderValue),
const SizedBox(height: AppDesignTokens.space8),
value,
],
),
);
}
}
@@ -6,7 +6,7 @@ import '../theme/app_design_tokens.dart';
import '../theme/app_palette.dart';
import '../../l10n/l10n.dart';
import 'app_text_field.dart';
import 'app_http_header_group.dart';
import 'app_form_row.dart';
class AppHttpHeadersController extends ChangeNotifier {
AppHttpHeadersController({Map<String, String>? headers, Iterable<String> defaultNames = const []}) {
@@ -84,132 +84,60 @@ class AppHttpHeadersEditor extends StatelessWidget {
final bool stacked;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
if (stacked) {
return AnimatedBuilder(
key: ValueKey('$keyPrefix-editor'),
animation: controller,
builder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Widget build(BuildContext context) => AppFormRow(
key: ValueKey('$keyPrefix-editor'),
label: label,
direction: stacked ? Axis.vertical : Axis.horizontal,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) => Column(
children: [
for (var index = 0; index < controller._rows.length; index++) ...[
Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500),
flex: 382,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-name-$index'),
controller: controller._rows[index].name,
hintText: context.l10n.httpHeaderName,
),
),
shad.OutlineButton(
key: ValueKey('$keyPrefix-add'),
onPressed: controller.add,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 18),
const SizedBox(width: AppDesignTokens.space4),
Text(context.l10n.add),
],
const SizedBox(width: 8),
Expanded(
flex: 618,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-value-$index'),
controller: controller._rows[index].value,
hintText: context.l10n.httpHeaderValue,
),
),
],
),
const SizedBox(height: AppDesignTokens.space8),
for (var index = 0; index < controller._rows.length; index++) ...[
AppHttpHeaderGroup(
key: ObjectKey(controller._rows[index]),
name: _HeaderTextField(
key: ValueKey('$keyPrefix-name-$index'),
controller: controller._rows[index].name,
hintText: context.l10n.httpHeaderName,
),
value: _HeaderTextField(
key: ValueKey('$keyPrefix-value-$index'),
controller: controller._rows[index].value,
hintText: context.l10n.httpHeaderValue,
),
remove: _HeaderAction(
const SizedBox(width: 4),
_HeaderAction(
key: ValueKey('$keyPrefix-remove-$index'),
icon: Icons.delete_outline,
icon: Icons.remove,
onPressed: controller._rows.length == 1 ? null : () => controller.removeAt(index),
),
),
if (index != controller._rows.length - 1) const SizedBox(height: AppDesignTokens.space12),
],
],
),
);
}
return Row(
key: ValueKey('$keyPrefix-editor'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 104,
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
label,
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500),
),
),
),
const SizedBox(width: 12),
Expanded(
child: AnimatedBuilder(
animation: controller,
builder: (context, _) => Column(
children: [
for (var index = 0; index < controller._rows.length; index++) ...[
Row(
children: [
Expanded(
flex: 382,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-name-$index'),
controller: controller._rows[index].name,
hintText: context.l10n.httpHeaderName,
),
),
const SizedBox(width: 8),
Expanded(
flex: 618,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-value-$index'),
controller: controller._rows[index].value,
hintText: context.l10n.httpHeaderValue,
),
),
const SizedBox(width: 4),
_HeaderAction(
key: ValueKey('$keyPrefix-remove-$index'),
icon: Icons.remove,
onPressed: controller._rows.length == 1 ? null : () => controller.removeAt(index),
),
const SizedBox(width: 4),
Visibility(
visible: index == controller._rows.length - 1,
maintainAnimation: true,
maintainSize: true,
maintainState: true,
child: _HeaderAction(
key: index == controller._rows.length - 1 ? ValueKey('$keyPrefix-add') : null,
icon: Icons.add,
onPressed: controller.add,
),
),
],
const SizedBox(width: 4),
Visibility(
visible: index == controller._rows.length - 1,
maintainAnimation: true,
maintainSize: true,
maintainState: true,
child: _HeaderAction(
key: index == controller._rows.length - 1 ? ValueKey('$keyPrefix-add') : null,
icon: Icons.add,
onPressed: controller.add,
),
if (index != controller._rows.length - 1) const SizedBox(height: 8),
],
),
],
),
),
),
],
);
}
if (index != controller._rows.length - 1) const SizedBox(height: 8),
],
],
),
),
);
}
class _HttpHeaderRowControllers {
@@ -0,0 +1,84 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../../l10n/l10n.dart';
import '../theme/app_design_tokens.dart';
import '../theme/app_palette.dart';
/// Explains supported date variables and inserts one at the path's selection.
class AppPathPlaceholderButton extends StatelessWidget {
const AppPathPlaceholderButton({super.key, required this.controller});
final TextEditingController controller;
void _showPlaceholders(BuildContext context) {
final selection = controller.selection;
final now = DateTime.now();
final year = now.year.toString();
final month = now.month.toString().padLeft(2, '0');
final day = now.day.toString().padLeft(2, '0');
final l10n = context.l10n;
final placeholders = [
('%year%', l10n.placeholderYear, year),
('%month%', l10n.placeholderMonth, month),
('%day%', l10n.placeholderDay, day),
('%date%', l10n.placeholderDate, '$year-$month-$day'),
];
const PopoverOverlayHandler().show<void>(
context: context,
alignment: Alignment.topLeft,
anchorAlignment: Alignment.bottomLeft,
offset: const Offset(0, AppDesignTokens.space4),
builder: (popoverContext) => Card(
padding: const EdgeInsets.all(AppDesignTokens.space8),
child: SizedBox(
width: (MediaQuery.sizeOf(context).width - 80).clamp(180.0, 320.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (variable, description, example) in placeholders)
GhostButton(
key: ValueKey('path-placeholder-$variable'),
onPressed: () {
final text = controller.text;
final valid = selection.isValid && selection.end <= text.length;
final start = valid ? selection.start : text.length;
final end = valid ? selection.end : text.length;
controller.value = TextEditingValue(
text: text.replaceRange(start, end, variable),
selection: TextSelection.collapsed(offset: start + variable.length),
);
closeOverlay(popoverContext);
},
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$variable — $description', style: const TextStyle(fontSize: 12)),
Text(
l10n.example.replaceAll('@value', example),
style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 12),
),
],
),
),
),
],
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) => Builder(
builder: (anchorContext) =>
GhostButton(onPressed: () => _showPlaceholders(anchorContext), child: Text(context.l10n.insertPlaceholder)),
);
}
@@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/gestures.dart' show Drag;
import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
@@ -99,6 +100,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
late final Animation<double> _reveal;
final _scrollController = ScrollController();
final _horizontalScrollController = ScrollController();
Drag? _horizontalDrag;
final Map<String, double> _labelWidthCache = {};
TextStyle? _labelMeasurementStyle;
TextDirection? _labelMeasurementDirection;
@@ -138,6 +140,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
@override
void dispose() {
_horizontalDrag?.cancel();
_transitionController.removeStatusListener(_onTransitionStatus);
_transitionController.dispose();
_scrollController.dispose();
@@ -290,26 +293,36 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
),
const SizedBox(width: 4),
Expanded(
child: ClipRect(
child: AnimatedBuilder(
animation: _horizontalScrollController,
builder: (context, child) {
final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients
? _horizontalScrollController.offset
: 0.0;
return Transform.translate(offset: Offset(-offset, 0), child: child);
},
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: 0,
maxWidth: double.infinity,
child: SizedBox(
width: nameContentWidth,
child: Text(
data.label,
maxLines: 1,
softWrap: false,
style: contentTextStyle.copyWith(color: palette.textPrimary),
child: GestureDetector(
key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'),
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: horizontalScrollEnabled ? _startNameDrag : null,
onHorizontalDragUpdate: horizontalScrollEnabled
? (details) => _horizontalDrag?.update(details)
: null,
onHorizontalDragEnd: horizontalScrollEnabled ? (details) => _horizontalDrag?.end(details) : null,
onHorizontalDragCancel: horizontalScrollEnabled ? () => _horizontalDrag?.cancel() : null,
child: ClipRect(
child: AnimatedBuilder(
animation: _horizontalScrollController,
builder: (context, child) {
final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients
? _horizontalScrollController.offset
: 0.0;
return Transform.translate(offset: Offset(-offset, 0), child: child);
},
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: 0,
maxWidth: double.infinity,
child: SizedBox(
width: nameContentWidth,
child: Text(
data.label,
maxLines: 1,
softWrap: false,
style: contentTextStyle.copyWith(color: palette.textPrimary),
),
),
),
),
@@ -331,6 +344,12 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
);
}
void _startNameDrag(DragStartDetails details) {
_horizontalDrag?.cancel();
if (!_horizontalScrollController.hasClients) return;
_horizontalDrag = _horizontalScrollController.position.drag(details, () => _horizontalDrag = null);
}
Widget _buildTreeTransition(BuildContext context, VirtualTreeEntry<FileTreeNode<T>> entry, Widget row) {
if (_transitioningNodeKeys.contains(entry.node.data.key)) {
return SizeTransition(
@@ -302,6 +302,11 @@
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Close" keyEquivalent="w" id="clo-seW-inD">
<connections>
<action selector="performClose:" target="-1" id="clo-seA-ctN"/>
</connections>
</menuItem>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
@@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_path_placeholder_button.dart';
void main() {
for (final width in [390.0, 1024.0]) {
testWidgets('path placeholders explain and insert variables at width $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final controller = TextEditingController(text: '/downloads/replace');
addTearDown(controller.dispose);
controller.selection = const TextSelection(baseOffset: 11, extentOffset: 18);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Center(child: AppPathPlaceholderButton(controller: controller)),
),
);
await tester.tap(find.text('Insert Placeholder'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('%year% — Current year'), findsOneWidget);
expect(find.text('%month% — Current month (01-12)'), findsOneWidget);
expect(find.text('%day% — Current day (01-31)'), findsOneWidget);
expect(find.text('%date% — Full date (YYYY-MM-DD)'), findsOneWidget);
expect(find.text('e.g. ${DateTime.now().year}'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('path-placeholder-%year%')));
await tester.pumpAndSettle();
expect(controller.text, '/downloads/%year%');
expect(controller.selection.baseOffset, controller.text.length);
expect(find.text('%year% — Current year'), findsNothing);
for (final variable in ['%month%', '%day%', '%date%']) {
controller.text = '/downloads/';
await tester.tap(find.text('Insert Placeholder'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(ValueKey('path-placeholder-$variable')));
await tester.pumpAndSettle();
expect(controller.text, '/downloads/$variable');
}
expect(tester.takeException(), isNull);
});
}
}
+29 -9
View File
@@ -2474,11 +2474,17 @@ void main() {
expect(find.byType(DownloadCategoriesControl), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('download-category-name')), '归档');
await tester.enterText(find.byKey(const ValueKey('download-category-path')), '/tmp/archive');
await tester.tap(find.text('Insert Placeholder'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('%date% — Full date (YYYY-MM-DD)'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('path-placeholder-%date%')));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.text('归档'), findsOneWidget);
expect(find.text('/tmp/archive'), findsOneWidget);
expect(find.text('/tmp/archive%date%'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('edit-download-category-资料')));
await tester.pumpAndSettle();
@@ -2963,8 +2969,9 @@ void main() {
expect(tester.getRect(proxy).top, greaterThan(tester.getRect(find.text('Proxy')).bottom));
final name = find.byKey(const ValueKey('create-task-http-header-name-0'));
final value = find.byKey(const ValueKey('create-task-http-header-value-0'));
expect(tester.getRect(value).top, greaterThan(tester.getRect(name).bottom));
expect(tester.getSize(value).width, tester.getSize(name).width);
expect(tester.getRect(value).top, closeTo(tester.getRect(name).top, 0.01));
expect(tester.getRect(value).left, greaterThan(tester.getRect(name).right));
expect(tester.getSize(value).width / tester.getSize(name).width, closeTo(1.618, 0.01));
await tester.ensureVisible(value);
await tester.pumpAndSettle();
await tester.enterText(find.descendant(of: value, matching: find.byType(EditableText)), 'mobile-agent');
@@ -2974,6 +2981,9 @@ void main() {
final editor = tester.widget<AppHttpHeadersEditor>(find.byType(AppHttpHeadersEditor));
expect(editor.controller.toMap()['User-Agent'], 'mobile-agent');
final add = find.byKey(const ValueKey('create-task-http-header-add'));
final lastRemove = find.byKey(const ValueKey('create-task-http-header-remove-2'));
expect(tester.getCenter(add).dy, closeTo(tester.getCenter(lastRemove).dy, 0.01));
expect(tester.getRect(add).left, greaterThan(tester.getRect(lastRemove).right));
await tester.ensureVisible(add);
await tester.pumpAndSettle();
await tester.tap(add);
@@ -2990,12 +3000,8 @@ void main() {
for (final width in [720.0, 1024.0, 390.0]) {
tester.view.physicalSize = Size(width, 844);
await tester.pumpAndSettle();
if (width >= 720) {
expect(tester.getRect(name).top, closeTo(tester.getRect(value).top, 0.01));
expect(tester.getRect(value).left, greaterThan(tester.getRect(name).right));
} else {
expect(tester.getRect(value).top, greaterThan(tester.getRect(name).bottom));
}
expect(tester.getRect(name).top, closeTo(tester.getRect(value).top, 0.01));
expect(tester.getRect(value).left, greaterThan(tester.getRect(name).right));
expect(editor.controller.toMap()['User-Agent'], 'mobile-agent');
expect(tester.takeException(), isNull);
}
@@ -3449,6 +3455,20 @@ void main() {
expect(position.pixels, greaterThan(0));
expect(tester.getRect(find.text(longName)).left, lessThan(nameLeftBefore));
expect(tester.getRect(trailing).left, closeTo(trailingLeftBefore, 0.01));
position.jumpTo(0);
await tester.pumpAndSettle();
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:0'));
await tester.drag(nameArea, const Offset(-100, 0));
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(0));
expect(tester.getRect(find.text(longName)).left, lessThan(nameLeftBefore));
expect(tester.getRect(trailing).left, closeTo(trailingLeftBefore, 0.01));
final previousOffset = position.pixels;
await tester.drag(nameArea, const Offset(80, 0));
await tester.pumpAndSettle();
expect(position.pixels, lessThan(previousOffset));
expect(tester.takeException(), isNull);
});
testWidgets('file name scrollbar stays hidden when measured labels fit', (WidgetTester tester) async {