mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 04:43:41 +00:00
Merge PR #1518 into 2.0.0-beta.2
This commit is contained in:
commit
d41e27234d
17 files changed
+624
-182
No files matched your search
@@ -15,6 +15,7 @@ import '../../../../core/utils/compact_count_formatter.dart';
|
|||||||
import '../../../../core/window/app_window_chrome.dart';
|
import '../../../../core/window/app_window_chrome.dart';
|
||||||
import '../../../../shared/theme/app_design_tokens.dart';
|
import '../../../../shared/theme/app_design_tokens.dart';
|
||||||
import '../../../../shared/theme/app_palette.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_primary_button.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/app_tooltip.dart';
|
import '../../../../shared/widgets/app_tooltip.dart';
|
||||||
@@ -391,80 +392,86 @@ class _Content extends StatelessWidget {
|
|||||||
child: _FilterBar(state: state, onFilter: onFilter),
|
child: _FilterBar(state: state, onFilter: onFilter),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: CustomScrollView(
|
child: AppSwipeTabs<ExtensionListFilter>(
|
||||||
key: const ValueKey('extensions-list-scroll-view'),
|
enabled: !isDesktop,
|
||||||
controller: scrollController,
|
values: const [ExtensionListFilter.market, ExtensionListFilter.installed],
|
||||||
slivers: [
|
selectedValue: state.listFilter,
|
||||||
if ((state.loadingInstalled || state.loadingStore) && state.displayItems.isEmpty)
|
onSelected: onFilter,
|
||||||
SliverPadding(
|
child: CustomScrollView(
|
||||||
padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20),
|
key: const ValueKey('extensions-list-scroll-view'),
|
||||||
sliver: const _ExtensionSkeletonGrid(key: ValueKey('extensions-initial-skeleton')),
|
controller: scrollController,
|
||||||
)
|
slivers: [
|
||||||
else if (state.displayItems.isEmpty)
|
if ((state.loadingInstalled || state.loadingStore) && state.displayItems.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverPadding(
|
||||||
child: Center(
|
padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 20),
|
||||||
child: Text(
|
sliver: const _ExtensionSkeletonGrid(key: ValueKey('extensions-initial-skeleton')),
|
||||||
context.l10n.noExtensions,
|
)
|
||||||
style: TextStyle(color: palette.textSecondary, fontSize: 14),
|
else if (state.displayItems.isEmpty)
|
||||||
),
|
SliverFillRemaining(
|
||||||
),
|
|
||||||
)
|
|
||||||
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: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.extensionNoMore,
|
context.l10n.noExtensions,
|
||||||
key: const ValueKey('extensions-no-more-indicator'),
|
style: TextStyle(color: palette.textSecondary, fontSize: 14),
|
||||||
style: TextStyle(color: palette.textMuted, fontSize: 12),
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import '../../../../shared/theme/app_design_tokens.dart';
|
|||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
||||||
import '../../../../shared/widgets/app_http_headers_editor.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_number_input.dart';
|
||||||
import '../../../../shared/widgets/app_path_picker_field.dart';
|
import '../../../../shared/widgets/app_path_picker_field.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
@@ -274,7 +276,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 palette = AppPalette.of(context);
|
||||||
|
|
||||||
final page = Scaffold(
|
final page = Scaffold(
|
||||||
@@ -307,7 +313,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 20),
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.downloadLink,
|
label: context.l10n.downloadLink,
|
||||||
child: DropTarget(
|
child: DropTarget(
|
||||||
enable: !_creating,
|
enable: !_creating,
|
||||||
@@ -364,7 +371,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.rename,
|
label: context.l10n.rename,
|
||||||
child: _WindowTextField(
|
child: _WindowTextField(
|
||||||
key: const ValueKey('create-task-rename-input'),
|
key: const ValueKey('create-task-rename-input'),
|
||||||
@@ -373,7 +381,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.connections,
|
label: context.l10n.connections,
|
||||||
child: AppNumberInput(
|
child: AppNumberInput(
|
||||||
fieldKey: const ValueKey('create-task-connections-input'),
|
fieldKey: const ValueKey('create-task-connections-input'),
|
||||||
@@ -384,7 +393,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.directory,
|
label: context.l10n.directory,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -407,6 +417,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_DirectoryOptions(
|
_DirectoryOptions(
|
||||||
key: const ValueKey('create-task-directory-options-row'),
|
key: const ValueKey('create-task-directory-options-row'),
|
||||||
|
stacked: stacked,
|
||||||
asDefaultPath: _asDefaultPath,
|
asDefaultPath: _asDefaultPath,
|
||||||
onAsDefaultPathChanged: (value) => setState(() => _asDefaultPath = value),
|
onAsDefaultPathChanged: (value) => setState(() => _asDefaultPath = value),
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
@@ -428,7 +439,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.directDownload,
|
label: context.l10n.directDownload,
|
||||||
child: _DirectDownloadToggle(
|
child: _DirectDownloadToggle(
|
||||||
value: _directDownload,
|
value: _directDownload,
|
||||||
@@ -465,10 +477,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.proxy,
|
label: context.l10n.proxy,
|
||||||
child: AppChoiceSegmentedControl<RequestProxyMode>(
|
child: AppChoiceSegmentedControl<RequestProxyMode>(
|
||||||
key: const ValueKey('create-task-proxy-mode'),
|
key: const ValueKey('create-task-proxy-mode'),
|
||||||
|
showIcons: !stacked,
|
||||||
value: _proxyMode,
|
value: _proxyMode,
|
||||||
buttonKeyPrefix: 'create-task-proxy-mode',
|
buttonKeyPrefix: 'create-task-proxy-mode',
|
||||||
alignment: WrapAlignment.start,
|
alignment: WrapAlignment.start,
|
||||||
@@ -497,10 +511,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
if (_proxyMode == RequestProxyMode.custom) ...[
|
if (_proxyMode == RequestProxyMode.custom) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.proxyProtocol,
|
label: context.l10n.proxyProtocol,
|
||||||
child: AppChoiceSegmentedControl<String>(
|
child: AppChoiceSegmentedControl<String>(
|
||||||
key: const ValueKey('create-task-proxy-scheme'),
|
key: const ValueKey('create-task-proxy-scheme'),
|
||||||
|
showIcons: !stacked,
|
||||||
value: _proxyScheme,
|
value: _proxyScheme,
|
||||||
buttonKeyPrefix: 'create-task-proxy-scheme',
|
buttonKeyPrefix: 'create-task-proxy-scheme',
|
||||||
alignment: WrapAlignment.start,
|
alignment: WrapAlignment.start,
|
||||||
@@ -513,59 +529,45 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_FormRow(
|
AppFormPair(
|
||||||
label: context.l10n.server,
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
child: Row(
|
firstLabel: context.l10n.server,
|
||||||
children: [
|
secondLabel: context.l10n.port,
|
||||||
Expanded(
|
firstFlex: 618,
|
||||||
flex: 618,
|
secondFlex: 382,
|
||||||
child: _WindowTextField(
|
first: _WindowTextField(
|
||||||
key: const ValueKey('create-task-proxy-server'),
|
key: const ValueKey('create-task-proxy-server'),
|
||||||
controller: _proxyServerController,
|
controller: _proxyServerController,
|
||||||
hintText: context.l10n.server,
|
hintText: context.l10n.server,
|
||||||
),
|
),
|
||||||
),
|
second: _WindowTextField(
|
||||||
const SizedBox(width: 8),
|
key: const ValueKey('create-task-proxy-port'),
|
||||||
Expanded(
|
controller: _proxyPortController,
|
||||||
flex: 382,
|
hintText: context.l10n.port,
|
||||||
child: _WindowTextField(
|
keyboardType: TextInputType.number,
|
||||||
key: const ValueKey('create-task-proxy-port'),
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
controller: _proxyPortController,
|
|
||||||
hintText: context.l10n.port,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_FormRow(
|
AppFormPair(
|
||||||
label: context.l10n.username,
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
child: Row(
|
firstLabel: context.l10n.username,
|
||||||
children: [
|
secondLabel: context.l10n.password,
|
||||||
Expanded(
|
first: _WindowTextField(
|
||||||
child: _WindowTextField(
|
key: const ValueKey('create-task-proxy-username'),
|
||||||
key: const ValueKey('create-task-proxy-username'),
|
controller: _proxyUsernameController,
|
||||||
controller: _proxyUsernameController,
|
hintText: context.l10n.username,
|
||||||
hintText: context.l10n.username,
|
),
|
||||||
),
|
second: _WindowTextField(
|
||||||
),
|
key: const ValueKey('create-task-proxy-password'),
|
||||||
const SizedBox(width: 8),
|
controller: _proxyPasswordController,
|
||||||
Expanded(
|
hintText: context.l10n.password,
|
||||||
child: _WindowTextField(
|
obscureText: true,
|
||||||
key: const ValueKey('create-task-proxy-password'),
|
|
||||||
controller: _proxyPasswordController,
|
|
||||||
hintText: context.l10n.password,
|
|
||||||
obscureText: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Container(height: 1, color: palette.border),
|
if (!stacked) Container(height: 1, color: palette.border),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
@@ -597,11 +599,13 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
if (_protocolTab == 0) ...[
|
if (_protocolTab == 0) ...[
|
||||||
AppHttpHeadersEditor(
|
AppHttpHeadersEditor(
|
||||||
controller: _httpHeadersController,
|
controller: _httpHeadersController,
|
||||||
|
stacked: stacked,
|
||||||
label: context.l10n.httpHeader,
|
label: context.l10n.httpHeader,
|
||||||
keyPrefix: 'create-task-http-header',
|
keyPrefix: 'create-task-http-header',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.skipVerifyCert,
|
label: context.l10n.skipVerifyCert,
|
||||||
child: _OptionSwitch(
|
child: _OptionSwitch(
|
||||||
key: const ValueKey('create-task-skip-verify-cert'),
|
key: const ValueKey('create-task-skip-verify-cert'),
|
||||||
@@ -610,7 +614,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.autoTorrentEnable,
|
label: context.l10n.autoTorrentEnable,
|
||||||
child: _OptionSwitch(
|
child: _OptionSwitch(
|
||||||
key: const ValueKey('create-task-auto-torrent'),
|
key: const ValueKey('create-task-auto-torrent'),
|
||||||
@@ -620,7 +625,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
if (_autoTorrent == true) ...[
|
if (_autoTorrent == true) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.autoTorrentDeleteAfterDownload,
|
label: context.l10n.autoTorrentDeleteAfterDownload,
|
||||||
child: _OptionSwitch(
|
child: _OptionSwitch(
|
||||||
key: const ValueKey('create-task-delete-torrent'),
|
key: const ValueKey('create-task-delete-torrent'),
|
||||||
@@ -630,7 +636,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.autoExtract,
|
label: context.l10n.autoExtract,
|
||||||
child: _OptionSwitch(
|
child: _OptionSwitch(
|
||||||
key: const ValueKey('create-task-auto-extract'),
|
key: const ValueKey('create-task-auto-extract'),
|
||||||
@@ -640,7 +647,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
if (_autoExtract == true) ...[
|
if (_autoExtract == true) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.archivePassword,
|
label: context.l10n.archivePassword,
|
||||||
child: _WindowTextField(
|
child: _WindowTextField(
|
||||||
key: const ValueKey('create-task-archive-password'),
|
key: const ValueKey('create-task-archive-password'),
|
||||||
@@ -650,7 +658,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.deleteAfterExtract,
|
label: context.l10n.deleteAfterExtract,
|
||||||
child: _OptionSwitch(
|
child: _OptionSwitch(
|
||||||
key: const ValueKey('create-task-delete-after-extract'),
|
key: const ValueKey('create-task-delete-after-extract'),
|
||||||
@@ -660,7 +669,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
] else ...[
|
] else ...[
|
||||||
_FormRow(
|
AppFormRow(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
label: context.l10n.trackers,
|
label: context.l10n.trackers,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 96,
|
height: 96,
|
||||||
@@ -694,7 +704,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(top: BorderSide(color: palette.border)),
|
border: stacked ? null : Border(top: BorderSide(color: palette.border)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -1380,35 +1390,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
|
|
||||||
enum _TaskProtocol { http, bt, ed2k }
|
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 {
|
class _DirectDownloadToggle extends StatelessWidget {
|
||||||
const _DirectDownloadToggle({required this.value, required this.onChanged});
|
const _DirectDownloadToggle({required this.value, required this.onChanged});
|
||||||
|
|
||||||
@@ -1499,12 +1480,14 @@ class _DirectoryShortcut {
|
|||||||
class _DirectoryOptions extends StatelessWidget {
|
class _DirectoryOptions extends StatelessWidget {
|
||||||
const _DirectoryOptions({
|
const _DirectoryOptions({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.stacked,
|
||||||
required this.asDefaultPath,
|
required this.asDefaultPath,
|
||||||
required this.onAsDefaultPathChanged,
|
required this.onAsDefaultPathChanged,
|
||||||
required this.shortcuts,
|
required this.shortcuts,
|
||||||
required this.onShortcutSelected,
|
required this.onShortcutSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final bool stacked;
|
||||||
final bool asDefaultPath;
|
final bool asDefaultPath;
|
||||||
final ValueChanged<bool> onAsDefaultPathChanged;
|
final ValueChanged<bool> onAsDefaultPathChanged;
|
||||||
final List<_DirectoryShortcut> shortcuts;
|
final List<_DirectoryShortcut> shortcuts;
|
||||||
@@ -1512,7 +1495,6 @@ class _DirectoryOptions extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final stacked = shortcuts.isNotEmpty && MediaQuery.sizeOf(context).width < Breakpoints.mobile;
|
|
||||||
final toggle = _AsDefaultPathToggle(value: asDefaultPath, onChanged: onAsDefaultPathChanged);
|
final toggle = _AsDefaultPathToggle(value: asDefaultPath, onChanged: onAsDefaultPathChanged);
|
||||||
final shortcutList = _DirectoryShortcuts(shortcuts: shortcuts, alignEnd: !stacked, onSelected: onShortcutSelected);
|
final shortcutList = _DirectoryShortcuts(shortcuts: shortcuts, alignEnd: !stacked, onSelected: onShortcutSelected);
|
||||||
|
|
||||||
@@ -1557,6 +1539,21 @@ class _DirectoryShortcuts extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return SizedBox(
|
||||||
key: const ValueKey('create-task-directory-shortcuts'),
|
key: const ValueKey('create-task-directory-shortcuts'),
|
||||||
height: 28,
|
height: 28,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: FileTreeView<int>(
|
child: FileTreeView<int>(
|
||||||
items: _treeItems,
|
items: _treeItems,
|
||||||
|
initialExpandedDepth: 1,
|
||||||
keyPrefix: 'resolve-tree',
|
keyPrefix: 'resolve-tree',
|
||||||
rowHeight: _resolveTreeRowHeight,
|
rowHeight: _resolveTreeRowHeight,
|
||||||
contentTextStyle: _resolveFileNameStyle,
|
contentTextStyle: _resolveFileNameStyle,
|
||||||
|
|||||||
@@ -436,7 +436,7 @@
|
|||||||
"androidForegroundServiceChannel": "Gopeed 后台服务",
|
"androidForegroundServiceChannel": "Gopeed 后台服务",
|
||||||
"androidForegroundNotificationTitle": "Gopeed 正在运行",
|
"androidForegroundNotificationTitle": "Gopeed 正在运行",
|
||||||
"androidForegroundNotificationText": "下载可在后台继续",
|
"androidForegroundNotificationText": "下载可在后台继续",
|
||||||
"mcpDescription": "与 AI Agent 的协同,探索无限可能",
|
"mcpDescription": "与 AI Agent 协同,探索无限可能",
|
||||||
"connectAgent": "接入指南",
|
"connectAgent": "接入指南",
|
||||||
"connectMcpAgent": "接入 MCP Agent",
|
"connectMcpAgent": "接入 MCP Agent",
|
||||||
"mcpAgentDialogDescription": "选择你使用的 Agent,然后复制对应的接入配置。",
|
"mcpAgentDialogDescription": "选择你使用的 Agent,然后复制对应的接入配置。",
|
||||||
|
|||||||
@@ -436,7 +436,7 @@
|
|||||||
"androidForegroundServiceChannel": "Gopeed 背景服務",
|
"androidForegroundServiceChannel": "Gopeed 背景服務",
|
||||||
"androidForegroundNotificationTitle": "Gopeed 正在執行",
|
"androidForegroundNotificationTitle": "Gopeed 正在執行",
|
||||||
"androidForegroundNotificationText": "下載可在背景繼續",
|
"androidForegroundNotificationText": "下載可在背景繼續",
|
||||||
"mcpDescription": "與 AI Agent 的協同,探索無限可能",
|
"mcpDescription": "與 AI Agent 協同,探索無限可能",
|
||||||
"connectAgent": "連接指南",
|
"connectAgent": "連接指南",
|
||||||
"connectMcpAgent": "接入 MCP Agent",
|
"connectMcpAgent": "接入 MCP Agent",
|
||||||
"mcpAgentDialogDescription": "選擇你使用的 Agent,然後複製對應的接入設定。",
|
"mcpAgentDialogDescription": "選擇你使用的 Agent,然後複製對應的接入設定。",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
|
|||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
this.buttonKeyPrefix = 'choice',
|
this.buttonKeyPrefix = 'choice',
|
||||||
this.alignment,
|
this.alignment,
|
||||||
|
this.showIcons = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final T value;
|
final T value;
|
||||||
@@ -28,6 +29,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
|
|||||||
final ValueChanged<T> onChanged;
|
final ValueChanged<T> onChanged;
|
||||||
final String buttonKeyPrefix;
|
final String buttonKeyPrefix;
|
||||||
final WrapAlignment? alignment;
|
final WrapAlignment? alignment;
|
||||||
|
final bool showIcons;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -44,6 +46,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
|
|||||||
option: option,
|
option: option,
|
||||||
selected: option.value == value,
|
selected: option.value == value,
|
||||||
palette: palette,
|
palette: palette,
|
||||||
|
showIcon: showIcons,
|
||||||
onPressed: () => onChanged(option.value),
|
onPressed: () => onChanged(option.value),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -58,12 +61,14 @@ class _ChoiceButton<T extends Object> extends StatelessWidget {
|
|||||||
required this.selected,
|
required this.selected,
|
||||||
required this.palette,
|
required this.palette,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
|
required this.showIcon,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppChoiceOption<T> option;
|
final AppChoiceOption<T> option;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final AppPalette palette;
|
final AppPalette palette;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
final bool showIcon;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -82,8 +87,10 @@ class _ChoiceButton<T extends Object> extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(option.icon, size: 15, color: selected ? palette.textPrimary : palette.textSecondary),
|
if (showIcon) ...[
|
||||||
const SizedBox(width: 7),
|
Icon(option.icon, size: 15, color: selected ? palette.textPrimary : palette.textSecondary),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
option.label,
|
option.label,
|
||||||
style: TextStyle(
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import '../theme/app_design_tokens.dart';
|
|||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
import '../../l10n/l10n.dart';
|
import '../../l10n/l10n.dart';
|
||||||
import 'app_text_field.dart';
|
import 'app_text_field.dart';
|
||||||
|
import 'app_http_header_group.dart';
|
||||||
|
|
||||||
class AppHttpHeadersController extends ChangeNotifier {
|
class AppHttpHeadersController extends ChangeNotifier {
|
||||||
AppHttpHeadersController({Map<String, String>? headers, Iterable<String> defaultNames = const []}) {
|
AppHttpHeadersController({Map<String, String>? headers, Iterable<String> defaultNames = const []}) {
|
||||||
@@ -69,15 +70,77 @@ class AppHttpHeadersController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AppHttpHeadersEditor extends StatelessWidget {
|
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 AppHttpHeadersController controller;
|
||||||
final String label;
|
final String label;
|
||||||
final String keyPrefix;
|
final String keyPrefix;
|
||||||
|
final bool stacked;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = AppPalette.of(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(
|
return Row(
|
||||||
key: ValueKey('$keyPrefix-editor'),
|
key: ValueKey('$keyPrefix-editor'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ class FileTreeView<T> extends StatefulWidget {
|
|||||||
this.rowHeight = _treeRowHeight,
|
this.rowHeight = _treeRowHeight,
|
||||||
this.contentTextStyle,
|
this.contentTextStyle,
|
||||||
this.iconSize = 18,
|
this.iconSize = 18,
|
||||||
|
this.initialExpandedDepth,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<FileTreeItem<T>> items;
|
final List<FileTreeItem<T>> items;
|
||||||
@@ -85,6 +86,9 @@ class FileTreeView<T> extends StatefulWidget {
|
|||||||
final TextStyle? contentTextStyle;
|
final TextStyle? contentTextStyle;
|
||||||
final double iconSize;
|
final double iconSize;
|
||||||
|
|
||||||
|
/// Number of directory levels initially expanded; null expands all levels.
|
||||||
|
final int? initialExpandedDepth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FileTreeView<T>> createState() => _FileTreeViewState<T>();
|
State<FileTreeView<T>> createState() => _FileTreeViewState<T>();
|
||||||
}
|
}
|
||||||
@@ -109,7 +113,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nodes = _buildTree(widget.items);
|
_nodes = _buildTree(widget.items, expandedDepth: widget.initialExpandedDepth);
|
||||||
_allFoldersExpanded = _foldersAreExpanded(_nodes);
|
_allFoldersExpanded = _foldersAreExpanded(_nodes);
|
||||||
_transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1)
|
_transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1)
|
||||||
..addStatusListener(_onTransitionStatus);
|
..addStatusListener(_onTransitionStatus);
|
||||||
@@ -830,8 +834,10 @@ class _BuildNode<T> {
|
|||||||
|
|
||||||
int get size => item?.size ?? children.fold<int>(0, (sum, child) => sum + child.size);
|
int get size => item?.size ?? children.fold<int>(0, (sum, child) => sum + child.size);
|
||||||
|
|
||||||
TreeItemNode<FileTreeNode<T>> toTreeItem() {
|
TreeItemNode<FileTreeNode<T>> toTreeItem({int? expandedDepth, int depth = 0}) {
|
||||||
final childItems = children.map((child) => child.toTreeItem()).toList(growable: false);
|
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!];
|
final leaves = item == null ? [for (final child in childItems) ...child.data.leafItems] : <FileTreeItem<T>>[item!];
|
||||||
return TreeItemNode(
|
return TreeItemNode(
|
||||||
data: FileTreeNode(
|
data: FileTreeNode(
|
||||||
@@ -842,13 +848,13 @@ class _BuildNode<T> {
|
|||||||
originalIndex: originalIndex,
|
originalIndex: originalIndex,
|
||||||
item: item,
|
item: item,
|
||||||
),
|
),
|
||||||
expanded: true,
|
expanded: expandedDepth == null || depth < expandedDepth,
|
||||||
children: childItems,
|
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 root = <_BuildNode<T>>[];
|
||||||
final folders = <String, _BuildNode<T>>{};
|
final folders = <String, _BuildNode<T>>{};
|
||||||
var originalIndex = 0;
|
var originalIndex = 0;
|
||||||
@@ -885,7 +891,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) {
|
List<String> _visibleNodeKeysInOrder<T>(List<TreeNode<FileTreeNode<T>>> nodes) {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class _ResponsiveMenuLayoutState<T> extends State<ResponsiveMenuLayout<T>> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
selectedValue: widget.selectedValue,
|
selectedValue: null,
|
||||||
onSelected: widget.onSelected,
|
onSelected: widget.onSelected,
|
||||||
footer: widget.sidebarFooter,
|
footer: widget.sidebarFooter,
|
||||||
mobile: true,
|
mobile: true,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
import '../../core/utils/breakpoints.dart';
|
import '../../core/utils/breakpoints.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
import 'secondary_navigation_pane.dart';
|
import 'secondary_navigation_pane.dart';
|
||||||
|
import 'app_swipe_tabs.dart';
|
||||||
|
|
||||||
class ResponsiveNavigationItem<T> {
|
class ResponsiveNavigationItem<T> {
|
||||||
const ResponsiveNavigationItem({required this.value, required this.label, this.icon, this.count});
|
const ResponsiveNavigationItem({required this.value, required this.label, this.icon, this.count});
|
||||||
@@ -72,7 +73,14 @@ class ResponsiveNavigationLayout<T> extends StatelessWidget {
|
|||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
reserveTopSafeArea: mobileHeader == null,
|
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 String title;
|
||||||
final List<SecondaryNavigationPaneItem<T>> items;
|
final List<SecondaryNavigationPaneItem<T>> items;
|
||||||
final T selectedValue;
|
final T? selectedValue;
|
||||||
final ValueChanged<T> onSelected;
|
final ValueChanged<T> onSelected;
|
||||||
final Widget? footer;
|
final Widget? footer;
|
||||||
final bool mobile;
|
final bool mobile;
|
||||||
|
|||||||
@@ -377,12 +377,14 @@ void main() {
|
|||||||
|
|
||||||
tester.view.physicalSize = const Size(520, 720);
|
tester.view.physicalSize = const Size(520, 720);
|
||||||
await tester.pumpAndSettle();
|
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.getTopLeft(shortcutRow).dy, greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy));
|
||||||
expect(tester.getSize(shortcutRow).width, closeTo(tester.getSize(directoryComponent).width, 0.5));
|
expect(tester.getSize(shortcutRow).width, closeTo(tester.getSize(directoryComponent).width, 0.5));
|
||||||
expect(tester.getSize(shortcutContent).width, greaterThan(tester.getSize(shortcutRow).width));
|
expect(find.descendant(of: shortcutRow, matching: find.byType(Scrollable)), findsNothing);
|
||||||
final shortcutScrollable = find.descendant(of: shortcutRow, matching: find.byType(Scrollable));
|
for (var index = 0; index < 4; index++) {
|
||||||
expect(tester.state<ScrollableState>(shortcutScrollable).position.maxScrollExtent, greaterThan(0));
|
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);
|
tester.view.physicalSize = const Size(719, 720);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -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_text_field.dart';
|
||||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||||
import 'package:gopeed/shared/widgets/app_toast.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/gopeed_app_mark.dart';
|
||||||
import 'package:gopeed/shared/widgets/responsive_menu_layout.dart';
|
import 'package:gopeed/shared/widgets/responsive_menu_layout.dart';
|
||||||
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
|
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
|
||||||
@@ -541,6 +542,39 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(find.byKey(const ValueKey('tasks-empty-create-button')), findsOneWidget);
|
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', (
|
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.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.state<ScrollableState>(scrollable).position.pixels, greaterThan(0));
|
||||||
expect(tester.takeException(), isNull);
|
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 {
|
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 {
|
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.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input'));
|
final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input'));
|
||||||
@@ -2891,6 +2947,73 @@ void main() {
|
|||||||
await tester.pump(const Duration(seconds: 1));
|
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<AppHttpHeadersEditor>(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 {
|
testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async {
|
||||||
await _setTestSize(tester, const Size(700, 500));
|
await _setTestSize(tester, const Size(700, 500));
|
||||||
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
|
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
|
||||||
@@ -2904,8 +3027,8 @@ void main() {
|
|||||||
matching: find.byType(shad.Checkbox),
|
matching: find.byType(shad.Checkbox),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester.getRect(directDownloadCheckbox).left,
|
tester.getRect(directDownloadCheckbox).top,
|
||||||
greaterThan(tester.getRect(find.text('Direct Download')).right),
|
greaterThan(tester.getRect(find.text('Direct Download')).bottom),
|
||||||
);
|
);
|
||||||
await tester.pump(const Duration(seconds: 1));
|
await tester.pump(const Duration(seconds: 1));
|
||||||
});
|
});
|
||||||
@@ -3051,6 +3174,51 @@ void main() {
|
|||||||
expect(hungarianWidth, lessThanOrEqualTo(190));
|
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 {
|
testWidgets('resolved file selector uses aligned tree header and sortable columns', (WidgetTester tester) async {
|
||||||
await _setTestSize(tester, const Size(760, 620));
|
await _setTestSize(tester, const Size(760, 620));
|
||||||
var selected = <int>[];
|
var selected = <int>[];
|
||||||
|
|||||||
Reference in new issue
Block a user