From c703c52cbc0cc03922a5318cc41a8a4a3d16964e Mon Sep 17 00:00:00 2001 From: Levi Date: Tue, 15 Sep 2026 22:58:06 +0800 Subject: [PATCH] fix(ui): refine narrow layouts and enable tab swipes --- .../presentation/pages/extensions_page.dart | 145 +++++++------- .../pages/create_task_window_page.dart | 181 +++++++++--------- .../widgets/resolve_file_tree.dart | 1 + ui/flutter/lib/l10n/app_zh.arb | 2 +- ui/flutter/lib/l10n/app_zh_TW.arb | 2 +- .../widgets/app_choice_segmented_control.dart | 11 +- .../lib/shared/widgets/app_form_pair.dart | 49 +++++ .../lib/shared/widgets/app_form_row.dart | 42 ++++ .../shared/widgets/app_http_header_group.dart | 43 +++++ .../widgets/app_http_headers_editor.dart | 65 ++++++- .../lib/shared/widgets/app_swipe_tabs.dart | 49 +++++ .../lib/shared/widgets/file_tree_view.dart | 18 +- .../widgets/responsive_menu_layout.dart | 2 +- .../widgets/responsive_navigation_layout.dart | 10 +- .../widgets/secondary_navigation_pane.dart | 2 +- .../create_task_window_page_test.dart | 10 +- ui/flutter/test/widget_test.dart | 174 ++++++++++++++++- 17 files changed, 624 insertions(+), 182 deletions(-) create mode 100644 ui/flutter/lib/shared/widgets/app_form_pair.dart create mode 100644 ui/flutter/lib/shared/widgets/app_form_row.dart create mode 100644 ui/flutter/lib/shared/widgets/app_http_header_group.dart create mode 100644 ui/flutter/lib/shared/widgets/app_swipe_tabs.dart diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index d4effbec..f2bd73b9 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -15,6 +15,7 @@ import '../../../../core/utils/compact_count_formatter.dart'; import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_swipe_tabs.dart'; import '../../../../shared/widgets/app_primary_button.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; @@ -391,80 +392,86 @@ class _Content extends StatelessWidget { child: _FilterBar(state: state, onFilter: onFilter), ), Expanded( - child: CustomScrollView( - key: const ValueKey('extensions-list-scroll-view'), - controller: scrollController, - slivers: [ - if ((state.loadingInstalled || state.loadingStore) && state.displayItems.isEmpty) - SliverPadding( - padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20), - sliver: const _ExtensionSkeletonGrid(key: ValueKey('extensions-initial-skeleton')), - ) - else if (state.displayItems.isEmpty) - SliverFillRemaining( - child: Center( - child: Text( - context.l10n.noExtensions, - style: TextStyle(color: palette.textSecondary, fontSize: 14), - ), - ), - ) - else - SliverPadding( - padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20), - sliver: SliverLayoutBuilder( - builder: (context, constraints) { - final width = constraints.crossAxisExtent; - final crossAxisCount = _extensionGridColumnCount(width); - return SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, index) => _ExtensionCard( - item: state.displayItems[index], - busy: state.busyExtensionIds.contains(state.displayItems[index].id), - canUpdate: _canUpdate(state, state.displayItems[index]), - onAction: onItemAction, - onOpenDetails: onOpenDetails, - onOpenSettings: onOpenSettings, - ), - childCount: state.displayItems.length, - ), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: _extensionGridSpacing, - crossAxisSpacing: _extensionGridSpacing, - mainAxisExtent: 178, - ), - ); - }, - ), - ), - if (state.listFilter == ExtensionListFilter.market && state.loadingMoreStore) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: 24, top: 4), - child: Center( - child: const SizedBox.square(dimension: 18, child: shad.CircularProgressIndicator()), - ), - ), - ), - if (state.listFilter == ExtensionListFilter.market && - state.displayItems.isNotEmpty && - state.storePagination != null && - !state.storePagination!.hasNext && - !state.loadingMoreStore) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: 24, top: 4), + child: AppSwipeTabs( + enabled: !isDesktop, + values: const [ExtensionListFilter.market, ExtensionListFilter.installed], + selectedValue: state.listFilter, + onSelected: onFilter, + child: CustomScrollView( + key: const ValueKey('extensions-list-scroll-view'), + controller: scrollController, + slivers: [ + if ((state.loadingInstalled || state.loadingStore) && state.displayItems.isEmpty) + SliverPadding( + padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20), + sliver: const _ExtensionSkeletonGrid(key: ValueKey('extensions-initial-skeleton')), + ) + else if (state.displayItems.isEmpty) + SliverFillRemaining( child: Center( child: Text( - context.l10n.extensionNoMore, - key: const ValueKey('extensions-no-more-indicator'), - style: TextStyle(color: palette.textMuted, fontSize: 12), + context.l10n.noExtensions, + style: TextStyle(color: palette.textSecondary, fontSize: 14), + ), + ), + ) + else + SliverPadding( + padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final width = constraints.crossAxisExtent; + final crossAxisCount = _extensionGridColumnCount(width); + return SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) => _ExtensionCard( + item: state.displayItems[index], + busy: state.busyExtensionIds.contains(state.displayItems[index].id), + canUpdate: _canUpdate(state, state.displayItems[index]), + onAction: onItemAction, + onOpenDetails: onOpenDetails, + onOpenSettings: onOpenSettings, + ), + childCount: state.displayItems.length, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: _extensionGridSpacing, + crossAxisSpacing: _extensionGridSpacing, + mainAxisExtent: 178, + ), + ); + }, + ), + ), + if (state.listFilter == ExtensionListFilter.market && state.loadingMoreStore) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 24, top: 4), + child: Center( + child: const SizedBox.square(dimension: 18, child: shad.CircularProgressIndicator()), ), ), ), - ), - ], + if (state.listFilter == ExtensionListFilter.market && + state.displayItems.isNotEmpty && + state.storePagination != null && + !state.storePagination!.hasNext && + !state.loadingMoreStore) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 24, top: 4), + child: Center( + child: Text( + context.l10n.extensionNoMore, + key: const ValueKey('extensions-no-more-indicator'), + style: TextStyle(color: palette.textMuted, fontSize: 12), + ), + ), + ), + ), + ], + ), ), ), ], diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 44d19ac2..a1a4b5d5 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -29,6 +29,8 @@ import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_choice_segmented_control.dart'; import '../../../../shared/widgets/app_http_headers_editor.dart'; +import '../../../../shared/widgets/app_form_row.dart'; +import '../../../../shared/widgets/app_form_pair.dart'; import '../../../../shared/widgets/app_number_input.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; import '../../../../shared/widgets/app_text_field.dart'; @@ -274,7 +276,11 @@ class _CreateTaskWindowPageState extends ConsumerState { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => _buildPanel(context, stacked: constraints.maxWidth < Breakpoints.mobile), + ); + + Widget _buildPanel(BuildContext context, {required bool stacked}) { final palette = AppPalette.of(context); final page = Scaffold( @@ -307,7 +313,8 @@ class _CreateTaskWindowPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 8, 24, 20), child: Column( children: [ - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.downloadLink, child: DropTarget( enable: !_creating, @@ -364,7 +371,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), const SizedBox(height: 16), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.rename, child: _WindowTextField( key: const ValueKey('create-task-rename-input'), @@ -373,7 +381,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), const SizedBox(height: 16), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.connections, child: AppNumberInput( fieldKey: const ValueKey('create-task-connections-input'), @@ -384,7 +393,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), const SizedBox(height: 16), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.directory, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -407,6 +417,7 @@ class _CreateTaskWindowPageState extends ConsumerState { const SizedBox(height: 8), _DirectoryOptions( key: const ValueKey('create-task-directory-options-row'), + stacked: stacked, asDefaultPath: _asDefaultPath, onAsDefaultPathChanged: (value) => setState(() => _asDefaultPath = value), shortcuts: [ @@ -428,7 +439,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), const SizedBox(height: 14), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.directDownload, child: _DirectDownloadToggle( value: _directDownload, @@ -465,10 +477,12 @@ class _CreateTaskWindowPageState extends ConsumerState { padding: const EdgeInsets.only(top: 18, bottom: 4), child: Column( children: [ - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.proxy, child: AppChoiceSegmentedControl( key: const ValueKey('create-task-proxy-mode'), + showIcons: !stacked, value: _proxyMode, buttonKeyPrefix: 'create-task-proxy-mode', alignment: WrapAlignment.start, @@ -497,10 +511,12 @@ class _CreateTaskWindowPageState extends ConsumerState { ), if (_proxyMode == RequestProxyMode.custom) ...[ const SizedBox(height: 12), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.proxyProtocol, child: AppChoiceSegmentedControl( key: const ValueKey('create-task-proxy-scheme'), + showIcons: !stacked, value: _proxyScheme, buttonKeyPrefix: 'create-task-proxy-scheme', alignment: WrapAlignment.start, @@ -513,59 +529,45 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), const SizedBox(height: 12), - _FormRow( - label: context.l10n.server, - child: Row( - children: [ - Expanded( - flex: 618, - child: _WindowTextField( - key: const ValueKey('create-task-proxy-server'), - controller: _proxyServerController, - hintText: context.l10n.server, - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 382, - child: _WindowTextField( - key: const ValueKey('create-task-proxy-port'), - controller: _proxyPortController, - hintText: context.l10n.port, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ), - ), - ], + AppFormPair( + direction: stacked ? Axis.vertical : Axis.horizontal, + firstLabel: context.l10n.server, + secondLabel: context.l10n.port, + firstFlex: 618, + secondFlex: 382, + first: _WindowTextField( + key: const ValueKey('create-task-proxy-server'), + controller: _proxyServerController, + hintText: context.l10n.server, + ), + second: _WindowTextField( + key: const ValueKey('create-task-proxy-port'), + controller: _proxyPortController, + hintText: context.l10n.port, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), ), const SizedBox(height: 12), - _FormRow( - label: context.l10n.username, - child: Row( - children: [ - Expanded( - child: _WindowTextField( - key: const ValueKey('create-task-proxy-username'), - controller: _proxyUsernameController, - hintText: context.l10n.username, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _WindowTextField( - key: const ValueKey('create-task-proxy-password'), - controller: _proxyPasswordController, - hintText: context.l10n.password, - obscureText: true, - ), - ), - ], + AppFormPair( + direction: stacked ? Axis.vertical : Axis.horizontal, + firstLabel: context.l10n.username, + secondLabel: context.l10n.password, + first: _WindowTextField( + key: const ValueKey('create-task-proxy-username'), + controller: _proxyUsernameController, + hintText: context.l10n.username, + ), + second: _WindowTextField( + key: const ValueKey('create-task-proxy-password'), + controller: _proxyPasswordController, + hintText: context.l10n.password, + obscureText: true, ), ), ], const SizedBox(height: 18), - Container(height: 1, color: palette.border), + if (!stacked) Container(height: 1, color: palette.border), const SizedBox(height: 16), Align( alignment: Alignment.centerLeft, @@ -597,11 +599,13 @@ class _CreateTaskWindowPageState extends ConsumerState { if (_protocolTab == 0) ...[ AppHttpHeadersEditor( controller: _httpHeadersController, + stacked: stacked, label: context.l10n.httpHeader, keyPrefix: 'create-task-http-header', ), const SizedBox(height: 14), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.skipVerifyCert, child: _OptionSwitch( key: const ValueKey('create-task-skip-verify-cert'), @@ -610,7 +614,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), const SizedBox(height: 14), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.autoTorrentEnable, child: _OptionSwitch( key: const ValueKey('create-task-auto-torrent'), @@ -620,7 +625,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), if (_autoTorrent == true) ...[ const SizedBox(height: 12), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.autoTorrentDeleteAfterDownload, child: _OptionSwitch( key: const ValueKey('create-task-delete-torrent'), @@ -630,7 +636,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ], const SizedBox(height: 14), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.autoExtract, child: _OptionSwitch( key: const ValueKey('create-task-auto-extract'), @@ -640,7 +647,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), if (_autoExtract == true) ...[ const SizedBox(height: 12), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.archivePassword, child: _WindowTextField( key: const ValueKey('create-task-archive-password'), @@ -650,7 +658,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), const SizedBox(height: 12), - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.deleteAfterExtract, child: _OptionSwitch( key: const ValueKey('create-task-delete-after-extract'), @@ -660,7 +669,8 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ], ] else ...[ - _FormRow( + AppFormRow( + direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.trackers, child: SizedBox( height: 96, @@ -694,7 +704,7 @@ class _CreateTaskWindowPageState extends ConsumerState { child: Container( padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), + border: stacked ? null : Border(top: BorderSide(color: palette.border)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -1380,35 +1390,6 @@ class _CreateTaskWindowPageState extends ConsumerState { enum _TaskProtocol { http, bt, ed2k } -class _FormRow extends StatelessWidget { - const _FormRow({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final palette = AppPalette.of(context); - return Row( - 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: child), - ], - ); - } -} - class _DirectDownloadToggle extends StatelessWidget { const _DirectDownloadToggle({required this.value, required this.onChanged}); @@ -1499,12 +1480,14 @@ class _DirectoryShortcut { class _DirectoryOptions extends StatelessWidget { const _DirectoryOptions({ super.key, + required this.stacked, required this.asDefaultPath, required this.onAsDefaultPathChanged, required this.shortcuts, required this.onShortcutSelected, }); + final bool stacked; final bool asDefaultPath; final ValueChanged onAsDefaultPathChanged; final List<_DirectoryShortcut> shortcuts; @@ -1512,7 +1495,6 @@ class _DirectoryOptions extends StatelessWidget { @override Widget build(BuildContext context) { - final stacked = shortcuts.isNotEmpty && MediaQuery.sizeOf(context).width < Breakpoints.mobile; final toggle = _AsDefaultPathToggle(value: asDefaultPath, onChanged: onAsDefaultPathChanged); final shortcutList = _DirectoryShortcuts(shortcuts: shortcuts, alignEnd: !stacked, onSelected: onShortcutSelected); @@ -1557,6 +1539,21 @@ class _DirectoryShortcuts extends StatelessWidget { @override Widget build(BuildContext context) { + if (!alignEnd) { + return Wrap( + key: const ValueKey('create-task-directory-shortcuts'), + spacing: AppDesignTokens.space8, + runSpacing: AppDesignTokens.space8, + children: [ + for (final shortcut in shortcuts) + _DirectoryShortcutButton( + key: ValueKey('create-task-category-${shortcut.index}'), + shortcut: shortcut, + onPressed: () => onSelected(shortcut), + ), + ], + ); + } return SizedBox( key: const ValueKey('create-task-directory-shortcuts'), height: 28, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart index 14eeccf7..9ad5e482 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart @@ -60,6 +60,7 @@ class _ResolveFileTreeState extends State { Expanded( child: FileTreeView( items: _treeItems, + initialExpandedDepth: 1, keyPrefix: 'resolve-tree', rowHeight: _resolveTreeRowHeight, contentTextStyle: _resolveFileNameStyle, diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 2395f69b..a6928e76 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -435,7 +435,7 @@ "androidForegroundServiceChannel": "Gopeed 后台服务", "androidForegroundNotificationTitle": "Gopeed 正在运行", "androidForegroundNotificationText": "下载可在后台继续", - "mcpDescription": "与 AI Agent 的协同,探索无限可能", + "mcpDescription": "与 AI Agent 协同,探索无限可能", "connectAgent": "接入指南", "connectMcpAgent": "接入 MCP Agent", "mcpAgentDialogDescription": "选择你使用的 Agent,然后复制对应的接入配置。", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 2692840b..df211463 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -435,7 +435,7 @@ "androidForegroundServiceChannel": "Gopeed 背景服務", "androidForegroundNotificationTitle": "Gopeed 正在執行", "androidForegroundNotificationText": "下載可在背景繼續", - "mcpDescription": "與 AI Agent 的協同,探索無限可能", + "mcpDescription": "與 AI Agent 協同,探索無限可能", "connectAgent": "連接指南", "connectMcpAgent": "接入 MCP Agent", "mcpAgentDialogDescription": "選擇你使用的 Agent,然後複製對應的接入設定。", diff --git a/ui/flutter/lib/shared/widgets/app_choice_segmented_control.dart b/ui/flutter/lib/shared/widgets/app_choice_segmented_control.dart index b40d1fc1..f0df3e74 100644 --- a/ui/flutter/lib/shared/widgets/app_choice_segmented_control.dart +++ b/ui/flutter/lib/shared/widgets/app_choice_segmented_control.dart @@ -21,6 +21,7 @@ class AppChoiceSegmentedControl extends StatelessWidget { required this.onChanged, this.buttonKeyPrefix = 'choice', this.alignment, + this.showIcons = true, }); final T value; @@ -28,6 +29,7 @@ class AppChoiceSegmentedControl extends StatelessWidget { final ValueChanged onChanged; final String buttonKeyPrefix; final WrapAlignment? alignment; + final bool showIcons; @override Widget build(BuildContext context) { @@ -44,6 +46,7 @@ class AppChoiceSegmentedControl extends StatelessWidget { option: option, selected: option.value == value, palette: palette, + showIcon: showIcons, onPressed: () => onChanged(option.value), ), ], @@ -58,12 +61,14 @@ class _ChoiceButton extends StatelessWidget { required this.selected, required this.palette, required this.onPressed, + required this.showIcon, }); final AppChoiceOption option; final bool selected; final AppPalette palette; final VoidCallback onPressed; + final bool showIcon; @override Widget build(BuildContext context) { @@ -82,8 +87,10 @@ class _ChoiceButton extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(option.icon, size: 15, color: selected ? palette.textPrimary : palette.textSecondary), - const SizedBox(width: 7), + if (showIcon) ...[ + Icon(option.icon, size: 15, color: selected ? palette.textPrimary : palette.textSecondary), + const SizedBox(width: 7), + ], Text( option.label, style: TextStyle( diff --git a/ui/flutter/lib/shared/widgets/app_form_pair.dart b/ui/flutter/lib/shared/widgets/app_form_pair.dart new file mode 100644 index 00000000..40d91153 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_form_pair.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; + +import '../theme/app_design_tokens.dart'; +import 'app_form_row.dart'; + +/// Related fields that share a row on wide forms and get individual labels when stacked. +class AppFormPair extends StatelessWidget { + const AppFormPair({ + super.key, + required this.firstLabel, + required this.secondLabel, + required this.first, + required this.second, + this.direction = Axis.horizontal, + this.firstFlex = 1, + this.secondFlex = 1, + }); + + final String firstLabel; + final String secondLabel; + final Widget first; + final Widget second; + final Axis direction; + final int firstFlex; + final int secondFlex; + + @override + Widget build(BuildContext context) { + if (direction == Axis.vertical) { + return Column( + children: [ + AppFormRow(label: firstLabel, direction: direction, child: first), + const SizedBox(height: AppDesignTokens.space12), + AppFormRow(label: secondLabel, direction: direction, child: second), + ], + ); + } + return AppFormRow( + label: firstLabel, + child: Row( + children: [ + Expanded(flex: firstFlex, child: first), + const SizedBox(width: AppDesignTokens.space8), + Expanded(flex: secondFlex, child: second), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/app_form_row.dart b/ui/flutter/lib/shared/widgets/app_form_row.dart new file mode 100644 index 00000000..b574eeff --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_form_row.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; + +import '../theme/app_design_tokens.dart'; +import '../theme/app_palette.dart'; + +/// A labelled control whose caller chooses the layout for the available width. +class AppFormRow extends StatelessWidget { + const AppFormRow({super.key, required this.label, required this.child, this.direction = Axis.horizontal}); + + final String label; + final Widget child; + final Axis direction; + + @override + Widget build(BuildContext context) { + final title = Text( + label, + style: TextStyle(color: AppPalette.of(context).textPrimary, fontSize: 13, fontWeight: FontWeight.w500), + ); + if (direction == Axis.vertical) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + title, + const SizedBox(height: AppDesignTokens.space8), + SizedBox(width: double.infinity, child: child), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 104, + child: Padding(padding: const EdgeInsets.only(top: 10), child: title), + ), + const SizedBox(width: AppDesignTokens.space12), + Expanded(child: child), + ], + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/app_http_header_group.dart b/ui/flutter/lib/shared/widgets/app_http_header_group.dart new file mode 100644 index 00000000..c3205e8e --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_http_header_group.dart @@ -0,0 +1,43 @@ +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 332448d4..9bb231ec 100644 --- a/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart +++ b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart @@ -6,6 +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'; class AppHttpHeadersController extends ChangeNotifier { AppHttpHeadersController({Map? headers, Iterable defaultNames = const []}) { @@ -69,15 +70,77 @@ class AppHttpHeadersController extends ChangeNotifier { } class AppHttpHeadersEditor extends StatelessWidget { - const AppHttpHeadersEditor({super.key, required this.controller, required this.label, required this.keyPrefix}); + const AppHttpHeadersEditor({ + super.key, + required this.controller, + required this.label, + required this.keyPrefix, + this.stacked = false, + }); final AppHttpHeadersController controller; final String label; final String keyPrefix; + 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: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + 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(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( + key: ValueKey('$keyPrefix-remove-$index'), + icon: Icons.delete_outline, + 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, diff --git a/ui/flutter/lib/shared/widgets/app_swipe_tabs.dart b/ui/flutter/lib/shared/widgets/app_swipe_tabs.dart new file mode 100644 index 00000000..c03ceadf --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_swipe_tabs.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; + +/// Switches adjacent tabs after a deliberate horizontal swipe over the content. +class AppSwipeTabs extends StatefulWidget { + const AppSwipeTabs({ + super.key, + required this.values, + required this.selectedValue, + required this.onSelected, + required this.child, + this.enabled = true, + }); + + final List values; + final T selectedValue; + final ValueChanged onSelected; + final Widget child; + final bool enabled; + + @override + State> createState() => _AppSwipeTabsState(); +} + +class _AppSwipeTabsState extends State> { + double _distance = 0; + + void _finish(DragEndDetails details) { + final distance = _distance; + _distance = 0; + final velocity = details.primaryVelocity ?? 0; + if (distance.abs() < 48 && (distance.abs() < 12 || velocity.abs() < 400)) return; + final direction = velocity.abs() >= 400 ? velocity : distance; + final index = widget.values.indexOf(widget.selectedValue); + if (index < 0) return; + final forward = Directionality.of(context) == TextDirection.rtl ? direction > 0 : direction < 0; + final nextIndex = index + (forward ? 1 : -1); + if (nextIndex >= 0 && nextIndex < widget.values.length) widget.onSelected(widget.values[nextIndex]); + } + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: widget.enabled ? (_) => _distance = 0 : null, + onHorizontalDragUpdate: widget.enabled ? (details) => _distance += details.primaryDelta ?? 0 : null, + onHorizontalDragEnd: widget.enabled ? _finish : null, + onHorizontalDragCancel: widget.enabled ? () => _distance = 0 : null, + child: widget.child, + ); +} diff --git a/ui/flutter/lib/shared/widgets/file_tree_view.dart b/ui/flutter/lib/shared/widgets/file_tree_view.dart index efab4ddb..c5254aa8 100644 --- a/ui/flutter/lib/shared/widgets/file_tree_view.dart +++ b/ui/flutter/lib/shared/widgets/file_tree_view.dart @@ -69,6 +69,7 @@ class FileTreeView extends StatefulWidget { this.rowHeight = _treeRowHeight, this.contentTextStyle, this.iconSize = 18, + this.initialExpandedDepth, }); final List> items; @@ -85,6 +86,9 @@ class FileTreeView extends StatefulWidget { final TextStyle? contentTextStyle; final double iconSize; + /// Number of directory levels initially expanded; null expands all levels. + final int? initialExpandedDepth; + @override State> createState() => _FileTreeViewState(); } @@ -109,7 +113,7 @@ class _FileTreeViewState extends State> with SingleTickerProv @override void initState() { super.initState(); - _nodes = _buildTree(widget.items); + _nodes = _buildTree(widget.items, expandedDepth: widget.initialExpandedDepth); _allFoldersExpanded = _foldersAreExpanded(_nodes); _transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1) ..addStatusListener(_onTransitionStatus); @@ -830,8 +834,10 @@ class _BuildNode { int get size => item?.size ?? children.fold(0, (sum, child) => sum + child.size); - TreeItemNode> toTreeItem() { - final childItems = children.map((child) => child.toTreeItem()).toList(growable: false); + TreeItemNode> toTreeItem({int? expandedDepth, int depth = 0}) { + final childItems = children + .map((child) => child.toTreeItem(expandedDepth: expandedDepth, depth: depth + 1)) + .toList(growable: false); final leaves = item == null ? [for (final child in childItems) ...child.data.leafItems] : >[item!]; return TreeItemNode( data: FileTreeNode( @@ -842,13 +848,13 @@ class _BuildNode { originalIndex: originalIndex, item: item, ), - expanded: true, + expanded: expandedDepth == null || depth < expandedDepth, children: childItems, ); } } -List>> _buildTree(List> items) { +List>> _buildTree(List> items, {int? expandedDepth}) { final root = <_BuildNode>[]; final folders = >{}; var originalIndex = 0; @@ -885,7 +891,7 @@ List>> _buildTree(List> items) { } } - return root.map((node) => node.toTreeItem()).toList(growable: false); + return root.map((node) => node.toTreeItem(expandedDepth: expandedDepth)).toList(growable: false); } List _visibleNodeKeysInOrder(List>> nodes) { diff --git a/ui/flutter/lib/shared/widgets/responsive_menu_layout.dart b/ui/flutter/lib/shared/widgets/responsive_menu_layout.dart index 6c189a33..7995dcf8 100644 --- a/ui/flutter/lib/shared/widgets/responsive_menu_layout.dart +++ b/ui/flutter/lib/shared/widgets/responsive_menu_layout.dart @@ -89,7 +89,7 @@ class _ResponsiveMenuLayoutState extends State> { ), ) .toList(), - selectedValue: widget.selectedValue, + selectedValue: null, onSelected: widget.onSelected, footer: widget.sidebarFooter, mobile: true, diff --git a/ui/flutter/lib/shared/widgets/responsive_navigation_layout.dart b/ui/flutter/lib/shared/widgets/responsive_navigation_layout.dart index e25a3985..818e9193 100644 --- a/ui/flutter/lib/shared/widgets/responsive_navigation_layout.dart +++ b/ui/flutter/lib/shared/widgets/responsive_navigation_layout.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import '../../core/utils/breakpoints.dart'; import '../theme/app_palette.dart'; import 'secondary_navigation_pane.dart'; +import 'app_swipe_tabs.dart'; class ResponsiveNavigationItem { const ResponsiveNavigationItem({required this.value, required this.label, this.icon, this.count}); @@ -72,7 +73,14 @@ class ResponsiveNavigationLayout extends StatelessWidget { onSelected: onSelected, reserveTopSafeArea: mobileHeader == null, ), - Expanded(child: child), + Expanded( + child: AppSwipeTabs( + values: items.map((item) => item.value).toList(growable: false), + selectedValue: selectedValue, + onSelected: onSelected, + child: child, + ), + ), ], ); } diff --git a/ui/flutter/lib/shared/widgets/secondary_navigation_pane.dart b/ui/flutter/lib/shared/widgets/secondary_navigation_pane.dart index 607e3bc1..7ceadf35 100644 --- a/ui/flutter/lib/shared/widgets/secondary_navigation_pane.dart +++ b/ui/flutter/lib/shared/widgets/secondary_navigation_pane.dart @@ -29,7 +29,7 @@ class SecondaryNavigationPane extends StatelessWidget { final String title; final List> items; - final T selectedValue; + final T? selectedValue; final ValueChanged onSelected; final Widget? footer; final bool mobile; diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index c9114bcc..f07f6615 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -377,12 +377,14 @@ void main() { tester.view.physicalSize = const Size(520, 720); await tester.pumpAndSettle(); - final shortcutContent = find.byKey(const ValueKey('create-task-directory-shortcuts-content')); expect(tester.getTopLeft(shortcutRow).dy, greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy)); expect(tester.getSize(shortcutRow).width, closeTo(tester.getSize(directoryComponent).width, 0.5)); - expect(tester.getSize(shortcutContent).width, greaterThan(tester.getSize(shortcutRow).width)); - final shortcutScrollable = find.descendant(of: shortcutRow, matching: find.byType(Scrollable)); - expect(tester.state(shortcutScrollable).position.maxScrollExtent, greaterThan(0)); + expect(find.descendant(of: shortcutRow, matching: find.byType(Scrollable)), findsNothing); + for (var index = 0; index < 4; index++) { + final shortcut = tester.getRect(find.byKey(ValueKey('create-task-category-$index'))); + expect(shortcut.left, greaterThanOrEqualTo(tester.getRect(shortcutRow).left)); + expect(shortcut.right, lessThanOrEqualTo(tester.getRect(shortcutRow).right)); + } tester.view.physicalSize = const Size(719, 720); await tester.pumpAndSettle(); diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 3f0732e3..cb08b07f 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -97,6 +97,7 @@ import 'package:gopeed/shared/widgets/app_primary_button.dart'; import 'package:gopeed/shared/widgets/app_text_field.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:gopeed/shared/widgets/app_toast.dart'; +import 'package:gopeed/shared/widgets/app_swipe_tabs.dart'; import 'package:gopeed/shared/widgets/gopeed_app_mark.dart'; import 'package:gopeed/shared/widgets/responsive_menu_layout.dart'; import 'package:gopeed/shared/widgets/virtual_tree_view.dart'; @@ -541,6 +542,39 @@ void main() { expect(tester.takeException(), isNull); expect(find.byKey(const ValueKey('tasks-empty-create-button')), findsOneWidget); } + tester.view.physicalSize = const Size(390, 760); + await tester.pumpAndSettle(); + + final content = find.byWidgetPredicate((widget) => widget is AppSwipeTabs); + void expectActive(String label) { + expect(tester.widget(find.text(label)).style!.fontWeight, FontWeight.w700); + } + + expectActive('Downloading 0'); + await tester.drag(content, const Offset(160, 0)); + await tester.pumpAndSettle(); + expectActive('Downloading 0'); + await tester.drag(content, const Offset(-160, 0)); + await tester.pumpAndSettle(); + expectActive('Completed 0'); + await tester.drag(content, const Offset(-160, 0)); + await tester.pumpAndSettle(); + expectActive('Failed 0'); + await tester.drag(content, const Offset(-160, 0)); + await tester.pumpAndSettle(); + expectActive('Failed 0'); + await tester.drag(content, const Offset(160, 0)); + await tester.pumpAndSettle(); + expectActive('Completed 0'); + await tester.tap(find.text('Downloading 0')); + await tester.pumpAndSettle(); + await tester.drag(content, const Offset(0, -160)); + await tester.pumpAndSettle(); + expectActive('Downloading 0'); + await tester.drag(content, const Offset(-160, 0)); + await tester.pumpAndSettle(); + expectActive('Completed 0'); + expect(tester.takeException(), isNull); }); testWidgets('extensions grid adds columns from a minimum card width and keeps desktop toolbar aligned', ( @@ -976,6 +1010,28 @@ void main() { expect(tester.getRect(find.byKey(const ValueKey('extension-search-input'))).top, closeTo(searchRect.top, 0.01)); expect(tester.state(scrollable).position.pixels, greaterThan(0)); expect(tester.takeException(), isNull); + + final container = ProviderScope.containerOf(tester.element(find.byType(ExtensionsPage))); + ExtensionListFilter filter() => container.read(extensionsControllerProvider).requireValue.listFilter; + expect(filter(), ExtensionListFilter.market); + await tester.drag(list, const Offset(-160, 0)); + await tester.pumpAndSettle(); + expect(filter(), ExtensionListFilter.installed); + await tester.drag(list, const Offset(-160, 0)); + await tester.pumpAndSettle(); + expect(filter(), ExtensionListFilter.installed); + await tester.drag(list, const Offset(160, 0)); + await tester.pumpAndSettle(); + expect(filter(), ExtensionListFilter.market); + await tester.drag(list, const Offset(-20, 0)); + await tester.pumpAndSettle(); + expect(filter(), ExtensionListFilter.market); + tester.view.physicalSize = const Size(1024, 760); + await tester.pumpAndSettle(); + await tester.drag(list, const Offset(-160, 0)); + await tester.pumpAndSettle(); + expect(filter(), ExtensionListFilter.market); + expect(tester.takeException(), isNull); }); testWidgets('removing an extension requires explicit confirmation', (WidgetTester tester) async { @@ -2761,7 +2817,7 @@ void main() { }); testWidgets('create task advanced options scroll smoothly into view', (WidgetTester tester) async { - await _setTestSize(tester, const Size(700, 500)); + await _setTestSize(tester, const Size(800, 500)); await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); await tester.pumpAndSettle(); final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input')); @@ -2891,6 +2947,73 @@ void main() { await tester.pump(const Duration(seconds: 1)); }); + testWidgets('create task stacks narrow forms and preserves header edits across resizing', (tester) async { + await _setTestSize(tester, const Size(390, 844)); + await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); + await tester.pumpAndSettle(); + + final rename = find.byKey(const ValueKey('create-task-rename-input')); + final directory = find.byKey(const ValueKey('create-task-directory-input')); + expect(tester.getRect(rename).top, greaterThan(tester.getRect(find.text('Rename')).bottom)); + expect(tester.getSize(directory).width, closeTo(342, 0.01)); + await tester.ensureVisible(find.text('Advanced')); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + final proxy = find.byKey(const ValueKey('create-task-proxy-mode')); + 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); + await tester.ensureVisible(value); + await tester.pumpAndSettle(); + await tester.enterText(find.descendant(of: value, matching: find.byType(EditableText)), 'mobile-agent'); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + + 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')); + await tester.ensureVisible(add); + await tester.pumpAndSettle(); + await tester.tap(add); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('create-task-http-header-name-3')), findsOneWidget); + final remove = find.byKey(const ValueKey('create-task-http-header-remove-1')); + await tester.ensureVisible(remove); + await tester.pumpAndSettle(); + await tester.tap(remove); + await tester.pumpAndSettle(); + expect(editor.controller.toMap()['User-Agent'], 'mobile-agent'); + expect(editor.controller.toMap().containsKey('Cookie'), isFalse); + + 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(editor.controller.toMap()['User-Agent'], 'mobile-agent'); + expect(tester.takeException(), isNull); + } + + final custom = find.byKey(const ValueKey('create-task-proxy-mode-custom')); + await tester.ensureVisible(custom); + await tester.pumpAndSettle(); + await tester.tap(custom); + await tester.pumpAndSettle(); + final server = find.byKey(const ValueKey('create-task-proxy-server')); + final port = find.byKey(const ValueKey('create-task-proxy-port')); + final user = find.byKey(const ValueKey('create-task-proxy-username')); + final password = find.byKey(const ValueKey('create-task-proxy-password')); + expect(tester.getRect(port).top, greaterThan(tester.getRect(server).bottom)); + expect(tester.getRect(password).top, greaterThan(tester.getRect(user).bottom)); + expect(tester.takeException(), isNull); + }); + testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async { await _setTestSize(tester, const Size(700, 500)); await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); @@ -2904,8 +3027,8 @@ void main() { matching: find.byType(shad.Checkbox), ); expect( - tester.getRect(directDownloadCheckbox).left, - greaterThan(tester.getRect(find.text('Direct Download')).right), + tester.getRect(directDownloadCheckbox).top, + greaterThan(tester.getRect(find.text('Direct Download')).bottom), ); await tester.pump(const Duration(seconds: 1)); }); @@ -3051,6 +3174,51 @@ void main() { expect(hungarianWidth, lessThanOrEqualTo(190)); }); + testWidgets('resolved file selector initially expands only first-level directories', (tester) async { + await _setTestSize(tester, const Size(760, 620)); + var selected = []; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: ResolveFileTree( + files: [ + FileInfo(path: '', name: 'root.txt', size: 1), + FileInfo(path: 'folder', name: 'first.txt', size: 2), + FileInfo(path: 'folder/nested', name: 'second.txt', size: 3), + FileInfo(path: 'folder/nested/deeper', name: 'third.txt', size: 4), + ], + initialSelection: const [0, 1, 2, 3], + onSelectionChanged: (values) => selected = values, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('root.txt'), findsOneWidget); + expect(find.text('first.txt'), findsOneWidget); + expect(find.text('nested'), findsOneWidget); + expect(find.text('second.txt'), findsNothing); + expect(find.text('third.txt'), findsNothing); + expect(selected, [0, 1, 2, 3]); + + await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:folder/nested'))); + await tester.pumpAndSettle(); + expect(find.text('second.txt'), findsOneWidget); + expect(find.text('deeper'), findsOneWidget); + expect(find.text('third.txt'), findsNothing); + expect(selected, [0, 1, 2, 3]); + + await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle'))); + await tester.pumpAndSettle(); + expect(find.text('third.txt'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle'))); + await tester.pumpAndSettle(); + expect(find.text('first.txt'), findsNothing); + expect(find.text('root.txt'), findsOneWidget); + expect(selected, [0, 1, 2, 3]); + expect(tester.takeException(), isNull); + }); + testWidgets('resolved file selector uses aligned tree header and sortable columns', (WidgetTester tester) async { await _setTestSize(tester, const Size(760, 620)); var selected = [];