fix(ui): fix various UI issues (#1518)

This commit is contained in:
Levi authored and GitHub committed 2026-09-18 22:40:36 +08:00
1 parent 1ab452ced8
commit 5d8267fa56
25 files changed
+792 -282

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -398,7 +398,9 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
@override
void onTrayIconRightMouseDown() {
trayManager.popUpContextMenu();
// Windows requires a foreground menu owner to dismiss on outside clicks.
// ignore: deprecated_member_use
trayManager.popUpContextMenu(bringAppToFront: true);
}
@override
@@ -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<ExtensionListFilter>(
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),
),
),
),
),
],
),
),
),
],
@@ -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)),
@@ -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<CreateTaskWindowPage> {
}
@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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
),
),
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<CreateTaskWindowPage> {
),
),
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<CreateTaskWindowPage> {
),
),
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
),
),
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<CreateTaskWindowPage> {
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<RequestProxyMode>(
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<CreateTaskWindowPage> {
),
if (_proxyMode == RequestProxyMode.custom) ...[
const SizedBox(height: 12),
_FormRow(
AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal,
label: context.l10n.proxyProtocol,
child: AppChoiceSegmentedControl<String>(
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<CreateTaskWindowPage> {
),
),
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
),
),
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<CreateTaskWindowPage> {
),
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<CreateTaskWindowPage> {
),
],
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<CreateTaskWindowPage> {
),
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<CreateTaskWindowPage> {
),
),
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<CreateTaskWindowPage> {
),
],
] 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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<bool> 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,
@@ -60,6 +60,7 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
Expanded(
child: FileTreeView<int>(
items: _treeItems,
initialExpandedDepth: 1,
keyPrefix: 'resolve-tree',
rowHeight: _resolveTreeRowHeight,
contentTextStyle: _resolveFileNameStyle,
+1 -1
View File
@@ -437,7 +437,7 @@
"androidForegroundServiceChannel": "Gopeed 后台服务",
"androidForegroundNotificationTitle": "Gopeed 正在运行",
"androidForegroundNotificationText": "下载可在后台继续",
"mcpDescription": "与 AI Agent 的协同,探索无限可能",
"mcpDescription": "与 AI Agent 协同,探索无限可能",
"connectAgent": "接入指南",
"connectMcpAgent": "接入 MCP Agent",
"mcpAgentDialogDescription": "选择你使用的 Agent,然后复制对应的接入配置。",
+1 -1
View File
@@ -437,7 +437,7 @@
"androidForegroundServiceChannel": "Gopeed 背景服務",
"androidForegroundNotificationTitle": "Gopeed 正在執行",
"androidForegroundNotificationText": "下載可在背景繼續",
"mcpDescription": "與 AI Agent 的協同,探索無限可能",
"mcpDescription": "與 AI Agent 協同,探索無限可能",
"connectAgent": "連接指南",
"connectMcpAgent": "接入 MCP Agent",
"mcpAgentDialogDescription": "選擇你使用的 Agent,然後複製對應的接入設定。",
@@ -21,6 +21,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
required this.onChanged,
this.buttonKeyPrefix = 'choice',
this.alignment,
this.showIcons = true,
});
final T value;
@@ -28,6 +29,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
final ValueChanged<T> onChanged;
final String buttonKeyPrefix;
final WrapAlignment? alignment;
final bool showIcons;
@override
Widget build(BuildContext context) {
@@ -44,6 +46,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
option: option,
selected: option.value == value,
palette: palette,
showIcon: showIcons,
onPressed: () => onChanged(option.value),
),
],
@@ -58,12 +61,14 @@ class _ChoiceButton<T extends Object> extends StatelessWidget {
required this.selected,
required this.palette,
required this.onPressed,
required this.showIcon,
});
final AppChoiceOption<T> option;
final bool selected;
final AppPalette palette;
final VoidCallback onPressed;
final bool showIcon;
@override
Widget build(BuildContext context) {
@@ -82,8 +87,10 @@ class _ChoiceButton<T extends Object> 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(
@@ -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),
],
),
);
}
}
@@ -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),
],
);
}
}
@@ -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_form_row.dart';
class AppHttpHeadersController extends ChangeNotifier {
AppHttpHeadersController({Map<String, String>? headers, Iterable<String> defaultNames = const []}) {
@@ -69,84 +70,74 @@ 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);
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(
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: [
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,
),
),
],
Expanded(
flex: 382,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-name-$index'),
controller: controller._rows[index].name,
hintText: context.l10n.httpHeaderName,
),
if (index != controller._rows.length - 1) const SizedBox(height: 8),
],
),
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,
),
),
],
),
),
),
],
);
}
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)),
);
}
@@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
/// Switches adjacent tabs after a deliberate horizontal swipe over the content.
class AppSwipeTabs<T> extends StatefulWidget {
const AppSwipeTabs({
super.key,
required this.values,
required this.selectedValue,
required this.onSelected,
required this.child,
this.enabled = true,
});
final List<T> values;
final T selectedValue;
final ValueChanged<T> onSelected;
final Widget child;
final bool enabled;
@override
State<AppSwipeTabs<T>> createState() => _AppSwipeTabsState<T>();
}
class _AppSwipeTabsState<T> extends State<AppSwipeTabs<T>> {
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,
);
}
@@ -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;
@@ -69,6 +70,7 @@ class FileTreeView<T> extends StatefulWidget {
this.rowHeight = _treeRowHeight,
this.contentTextStyle,
this.iconSize = 18,
this.initialExpandedDepth,
});
final List<FileTreeItem<T>> items;
@@ -85,6 +87,9 @@ class FileTreeView<T> extends StatefulWidget {
final TextStyle? contentTextStyle;
final double iconSize;
/// Number of directory levels initially expanded; null expands all levels.
final int? initialExpandedDepth;
@override
State<FileTreeView<T>> createState() => _FileTreeViewState<T>();
}
@@ -95,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;
@@ -109,7 +115,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> 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);
@@ -134,6 +140,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
@override
void dispose() {
_horizontalDrag?.cancel();
_transitionController.removeStatusListener(_onTransitionStatus);
_transitionController.dispose();
_scrollController.dispose();
@@ -286,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),
),
),
),
),
@@ -327,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(
@@ -830,8 +853,10 @@ class _BuildNode<T> {
int get size => item?.size ?? children.fold<int>(0, (sum, child) => sum + child.size);
TreeItemNode<FileTreeNode<T>> toTreeItem() {
final childItems = children.map((child) => child.toTreeItem()).toList(growable: false);
TreeItemNode<FileTreeNode<T>> 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] : <FileTreeItem<T>>[item!];
return TreeItemNode(
data: FileTreeNode(
@@ -842,13 +867,13 @@ class _BuildNode<T> {
originalIndex: originalIndex,
item: item,
),
expanded: true,
expanded: expandedDepth == null || depth < expandedDepth,
children: childItems,
);
}
}
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items) {
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int? expandedDepth}) {
final root = <_BuildNode<T>>[];
final folders = <String, _BuildNode<T>>{};
var originalIndex = 0;
@@ -885,7 +910,7 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items) {
}
}
return root.map((node) => node.toTreeItem()).toList(growable: false);
return root.map((node) => node.toTreeItem(expandedDepth: expandedDepth)).toList(growable: false);
}
List<String> _visibleNodeKeysInOrder<T>(List<TreeNode<FileTreeNode<T>>> nodes) {
@@ -89,7 +89,7 @@ class _ResponsiveMenuLayoutState<T> extends State<ResponsiveMenuLayout<T>> {
),
)
.toList(),
selectedValue: widget.selectedValue,
selectedValue: null,
onSelected: widget.onSelected,
footer: widget.sidebarFooter,
mobile: true,
@@ -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<T> {
const ResponsiveNavigationItem({required this.value, required this.label, this.icon, this.count});
@@ -72,7 +73,14 @@ class ResponsiveNavigationLayout<T> extends StatelessWidget {
onSelected: onSelected,
reserveTopSafeArea: mobileHeader == null,
),
Expanded(child: child),
Expanded(
child: AppSwipeTabs<T>(
values: items.map((item) => item.value).toList(growable: false),
selectedValue: selectedValue,
onSelected: onSelected,
child: child,
),
),
],
);
}
@@ -29,7 +29,7 @@ class SecondaryNavigationPane<T> extends StatelessWidget {
final String title;
final List<SecondaryNavigationPaneItem<T>> items;
final T selectedValue;
final T? selectedValue;
final ValueChanged<T> onSelected;
final Widget? footer;
final bool mobile;
@@ -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"/>
+5 -6
View File
@@ -1535,12 +1535,11 @@ packages:
tray_manager:
dependency: "direct main"
description:
path: "packages/tray_manager"
ref: main
resolved-ref: "91cdbdc12517d0d832aca715542b9d0329739d5d"
url: "https://github.com/monkeyWie/tray_manager.git"
source: git
version: "0.5.1"
name: tray_manager
sha256: "1a659b08baa6e9b91ef8ce16eda37740de398be1c4cf322b8a1ddfef25c68c5a"
url: "https://pub.dev"
source: hosted
version: "0.5.3"
typed_data:
dependency: transitive
description:
+1 -5
View File
@@ -77,11 +77,7 @@ dependencies:
url: https://github.com/hui-z/flutter_install_plugin.git
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
window_manager: ^0.5.1
tray_manager:
git:
url: https://github.com/monkeyWie/tray_manager.git
ref: main
path: packages/tray_manager
tray_manager: ^0.5.3
desktop_multi_window: ^0.3.0
go_router: ^16.2.1
@@ -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<ScrollableState>(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();
@@ -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);
});
}
}
+192 -4
View File
@@ -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<Text>(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<ScrollableState>(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 {
@@ -2418,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();
@@ -2761,7 +2823,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 +2953,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, 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');
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
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);
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();
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);
}
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 +3033,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 +3180,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 = <int>[];
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 = <int>[];
@@ -3281,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 {