From 051d4267a593931f41e6c7f3210bc340bdc1ab4d Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 17 Sep 2026 21:08:30 +0800 Subject: [PATCH] fix(ui): restore path placeholders and window shortcut --- .../widgets/download_categories_setting.dart | 9 + .../shared/widgets/app_http_header_group.dart | 43 ----- .../widgets/app_http_headers_editor.dart | 156 +++++------------- .../widgets/app_path_placeholder_button.dart | 84 ++++++++++ .../lib/shared/widgets/file_tree_view.dart | 59 ++++--- .../macos/Runner/Base.lproj/MainMenu.xib | 5 + .../app_path_placeholder_button_test.dart | 49 ++++++ ui/flutter/test/widget_test.dart | 38 ++++- 8 files changed, 257 insertions(+), 186 deletions(-) delete mode 100644 ui/flutter/lib/shared/widgets/app_http_header_group.dart create mode 100644 ui/flutter/lib/shared/widgets/app_path_placeholder_button.dart create mode 100644 ui/flutter/test/shared/widgets/app_path_placeholder_button_test.dart diff --git a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart index ae8d78ee..8c3c6cf6 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart @@ -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 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)), diff --git a/ui/flutter/lib/shared/widgets/app_http_header_group.dart b/ui/flutter/lib/shared/widgets/app_http_header_group.dart deleted file mode 100644 index c3205e8e..00000000 --- a/ui/flutter/lib/shared/widgets/app_http_header_group.dart +++ /dev/null @@ -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, - ], - ), - ); - } -} diff --git a/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart index 9bb231ec..a98d8f2c 100644 --- a/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart +++ b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart @@ -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? headers, Iterable 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 { diff --git a/ui/flutter/lib/shared/widgets/app_path_placeholder_button.dart b/ui/flutter/lib/shared/widgets/app_path_placeholder_button.dart new file mode 100644 index 00000000..9908f5e2 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_path_placeholder_button.dart @@ -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( + 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)), + ); +} diff --git a/ui/flutter/lib/shared/widgets/file_tree_view.dart b/ui/flutter/lib/shared/widgets/file_tree_view.dart index c5254aa8..1d71a767 100644 --- a/ui/flutter/lib/shared/widgets/file_tree_view.dart +++ b/ui/flutter/lib/shared/widgets/file_tree_view.dart @@ -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 extends State> with SingleTickerProv late final Animation _reveal; final _scrollController = ScrollController(); final _horizontalScrollController = ScrollController(); + Drag? _horizontalDrag; final Map _labelWidthCache = {}; TextStyle? _labelMeasurementStyle; TextDirection? _labelMeasurementDirection; @@ -138,6 +140,7 @@ class _FileTreeViewState extends State> with SingleTickerProv @override void dispose() { + _horizontalDrag?.cancel(); _transitionController.removeStatusListener(_onTransitionStatus); _transitionController.dispose(); _scrollController.dispose(); @@ -290,26 +293,36 @@ class _FileTreeViewState extends State> 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 extends State> with SingleTickerProv ); } + void _startNameDrag(DragStartDetails details) { + _horizontalDrag?.cancel(); + if (!_horizontalScrollController.hasClients) return; + _horizontalDrag = _horizontalScrollController.position.drag(details, () => _horizontalDrag = null); + } + Widget _buildTreeTransition(BuildContext context, VirtualTreeEntry> entry, Widget row) { if (_transitioningNodeKeys.contains(entry.node.data.key)) { return SizeTransition( diff --git a/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib b/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib index 2070a6c6..dbbdb919 100644 --- a/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib +++ b/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib @@ -302,6 +302,11 @@ + + + + + diff --git a/ui/flutter/test/shared/widgets/app_path_placeholder_button_test.dart b/ui/flutter/test/shared/widgets/app_path_placeholder_button_test.dart new file mode 100644 index 00000000..540610f1 --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_path_placeholder_button_test.dart @@ -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); + }); + } +} diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index cb08b07f..7269398e 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -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(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 {