Initial commit
This commit is contained in:
@@ -0,0 +1,816 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/contracts/script_widget.dart';
|
||||
import '../../providers/di_providers.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/utils/user_error_formatter.dart';
|
||||
import '../../shared/widgets/adaptive_modal.dart';
|
||||
import '../../shared/widgets/app_card.dart';
|
||||
import '../../shared/widgets/app_dialog.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
import '../../shared/widgets/app_inline_alert.dart';
|
||||
import '../../shared/widgets/app_loading_ring.dart';
|
||||
import '../../shared/widgets/app_snack_bar.dart';
|
||||
import '../../shared/widgets/auto_dismiss_menu.dart';
|
||||
import '../../shared/widgets/empty_state.dart';
|
||||
import '../../shared/widgets/pill_tab_bar.dart';
|
||||
import 'script_widget_parameter_form.dart';
|
||||
|
||||
|
||||
Future<void> showModuleCenterSheet(BuildContext context) {
|
||||
return showAdaptiveModal<void>(
|
||||
context: context,
|
||||
maxWidth: 720,
|
||||
compactHeightFactor: 0.92,
|
||||
useRootNavigator: true,
|
||||
builder: (sheetContext) => const _ModuleCenterSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ModuleCenterSheet extends StatelessWidget {
|
||||
const _ModuleCenterSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: const ModuleCenterContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> actions;
|
||||
final bool showCloseButton;
|
||||
|
||||
const _SheetHeader({
|
||||
required this.title,
|
||||
required this.actions,
|
||||
this.showCloseButton = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
...actions,
|
||||
if (showCloseButton)
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ModuleCenterContent extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const ModuleCenterContent({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<ModuleCenterContent> createState() =>
|
||||
_ModuleCenterContentState();
|
||||
}
|
||||
|
||||
class _ModuleCenterContentState extends ConsumerState<ModuleCenterContent> {
|
||||
int _selectedSectionIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final installedWidgets = ref.watch(installedScriptWidgetsProvider);
|
||||
final subscriptions = ref.watch(scriptWidgetSubscriptionsProvider);
|
||||
final embedded = widget.embedded;
|
||||
|
||||
final headerActions = <Widget>[
|
||||
IconButton(
|
||||
onPressed: () => _addFromFile(context),
|
||||
icon: const Icon(Icons.file_open_outlined),
|
||||
tooltip: '从本地文件导入',
|
||||
),
|
||||
FButton(
|
||||
onPress: () => _addFromUrl(context),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add_link_rounded, size: 17),
|
||||
SizedBox(width: 6),
|
||||
Text('添加 URL'),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
final headerHorizontalPadding = embedded
|
||||
? const EdgeInsets.fromLTRB(4, 0, 4, 4)
|
||||
: EdgeInsets.zero;
|
||||
final tabPadding = embedded
|
||||
? const EdgeInsets.fromLTRB(4, 8, 4, 4)
|
||||
: const EdgeInsets.fromLTRB(20, 14, 20, 4);
|
||||
final sectionPadding = embedded
|
||||
? const EdgeInsets.fromLTRB(4, 12, 4, 24)
|
||||
: const EdgeInsets.fromLTRB(20, 12, 20, 48);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: headerHorizontalPadding,
|
||||
child: _SheetHeader(
|
||||
title: embedded ? '' : '模块中心',
|
||||
actions: headerActions,
|
||||
showCloseButton: !embedded,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: tabPadding,
|
||||
child: PillTabBar(
|
||||
tabs: const <String>['已安装', '模块订阅'],
|
||||
selectedIndex: _selectedSectionIndex,
|
||||
onSelect: (selectedIndex) {
|
||||
setState(() => _selectedSectionIndex = selectedIndex);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: sectionPadding,
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _selectedSectionIndex == 0
|
||||
? installedWidgets.when(
|
||||
data: (widgets) => _InstalledWidgetsSection(
|
||||
widgets: widgets,
|
||||
onChanged: () => ref.invalidate(
|
||||
installedScriptWidgetsProvider,
|
||||
),
|
||||
),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: AppLoadingRing()),
|
||||
),
|
||||
error: (error, _) => AppErrorState(
|
||||
compact: true,
|
||||
message: formatUserError(error),
|
||||
onRetry: () => ref.invalidate(
|
||||
installedScriptWidgetsProvider,
|
||||
),
|
||||
),
|
||||
)
|
||||
: subscriptions.when(
|
||||
data: (items) => _SubscriptionsSection(
|
||||
subscriptions: items,
|
||||
onChanged: () {
|
||||
ref.invalidate(scriptWidgetSubscriptionsProvider);
|
||||
ref.invalidate(installedScriptWidgetsProvider);
|
||||
},
|
||||
),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: AppLoadingRing(size: 30)),
|
||||
),
|
||||
error: (error, _) => AppErrorState(
|
||||
compact: true,
|
||||
message: formatUserError(error),
|
||||
onRetry: () => ref.invalidate(
|
||||
scriptWidgetSubscriptionsProvider,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
ref.invalidate(installedScriptWidgetsProvider);
|
||||
ref.invalidate(scriptWidgetSubscriptionsProvider);
|
||||
await Future.wait([
|
||||
ref.read(installedScriptWidgetsProvider.future),
|
||||
ref.read(scriptWidgetSubscriptionsProvider.future),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _addFromUrl(BuildContext context) async {
|
||||
final url = await showModuleUrlImportSheet(context);
|
||||
if (url == null || url.trim().isEmpty || !context.mounted) return;
|
||||
try {
|
||||
final service = await ref.read(scriptWidgetServiceProvider.future);
|
||||
if (url.toLowerCase().split('?').first.endsWith('.fwd')) {
|
||||
await service.importSubscription(url.trim());
|
||||
ref.invalidate(scriptWidgetSubscriptionsProvider);
|
||||
if (context.mounted) showAppToast(context, '模块订阅已添加');
|
||||
} else {
|
||||
final installed = await service.installFromUrl(url.trim());
|
||||
ref.invalidate(installedScriptWidgetsProvider);
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '已安装 ${installed.manifest.title}');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(context, formatUserError(error), tone: AppAlertTone.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addFromFile(BuildContext context) async {
|
||||
const acceptedFiles = XTypeGroup(
|
||||
label: 'ForwardWidgets',
|
||||
extensions: <String>['js', 'fwd'],
|
||||
);
|
||||
final selectedFile = await openFile(
|
||||
acceptedTypeGroups: const <XTypeGroup>[acceptedFiles],
|
||||
);
|
||||
if (selectedFile == null || !context.mounted) return;
|
||||
try {
|
||||
final source = await selectedFile.readAsString();
|
||||
if (!context.mounted) return;
|
||||
final service = await ref.read(scriptWidgetServiceProvider.future);
|
||||
if (selectedFile.name.toLowerCase().endsWith('.fwd')) {
|
||||
await service.importSubscriptionSource(
|
||||
source,
|
||||
sourceId: 'local:${selectedFile.name}',
|
||||
);
|
||||
ref.invalidate(scriptWidgetSubscriptionsProvider);
|
||||
if (context.mounted) showAppToast(context, '本地模块订阅已添加');
|
||||
return;
|
||||
}
|
||||
final installed = await service.installFromSource(source);
|
||||
ref.invalidate(installedScriptWidgetsProvider);
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '已安装 ${installed.manifest.title}');
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(context, formatUserError(error), tone: AppAlertTone.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InstalledWidgetsSection extends ConsumerWidget {
|
||||
final List<InstalledScriptWidget> widgets;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const _InstalledWidgetsSection({
|
||||
required this.widgets,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (widgets.isEmpty) {
|
||||
return const EmptyState(
|
||||
compact: true,
|
||||
icon: Icons.widgets_outlined,
|
||||
title: '尚未安装模块',
|
||||
message: '可导入 ForwardWidgets 的 JS 地址、.fwd 订阅或本地脚本。',
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (var index = 0; index < widgets.length; index++) ...[
|
||||
_InstalledWidgetTile(
|
||||
installedWidget: widgets[index],
|
||||
onOpen: () {
|
||||
final widgetId = widgets[index].manifest.id;
|
||||
|
||||
|
||||
Navigator.of(context).pop();
|
||||
context.go('/modules/${Uri.encodeComponent(widgetId)}');
|
||||
},
|
||||
onToggle: (enabled) async {
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.setWidgetEnabled(
|
||||
widgets[index].manifest.id,
|
||||
enabled,
|
||||
);
|
||||
onChanged();
|
||||
},
|
||||
onConfigure: widgets[index].manifest.globalParams.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
final values = await showAppRawDialog<Map<String, Object?>>(
|
||||
context,
|
||||
builder: (_) => _GlobalParametersDialog(
|
||||
parameters: widgets[index].manifest.globalParams,
|
||||
initialValues: widgets[index].globalParams,
|
||||
),
|
||||
);
|
||||
if (values == null || !context.mounted) return;
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.updateGlobalParams(
|
||||
widgets[index].manifest.id,
|
||||
values,
|
||||
);
|
||||
ref.invalidate(
|
||||
installedScriptWidgetProvider(widgets[index].manifest.id),
|
||||
);
|
||||
onChanged();
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '模块参数已保存');
|
||||
}
|
||||
},
|
||||
onRefresh: widgets[index].sourceUrl.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.refreshWidget(widgets[index].manifest.id);
|
||||
onChanged();
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '模块已更新');
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(
|
||||
context,
|
||||
formatUserError(error),
|
||||
tone: AppAlertTone.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDelete: () async {
|
||||
final confirmed = await showAppConfirmDialog(
|
||||
context,
|
||||
title: '删除模块',
|
||||
body: Text('确定删除“${widgets[index].manifest.title}”吗?'),
|
||||
confirmLabel: '删除',
|
||||
destructive: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.deleteWidget(widgets[index].manifest.id);
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
if (index < widgets.length - 1) const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstalledWidgetTile extends StatelessWidget {
|
||||
final InstalledScriptWidget installedWidget;
|
||||
final VoidCallback onOpen;
|
||||
final ValueChanged<bool> onToggle;
|
||||
final VoidCallback? onConfigure;
|
||||
final VoidCallback? onRefresh;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _InstalledWidgetTile({
|
||||
required this.installedWidget,
|
||||
required this.onOpen,
|
||||
required this.onToggle,
|
||||
required this.onConfigure,
|
||||
required this.onRefresh,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final manifest = installedWidget.manifest;
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final menuItems = <FItem>[
|
||||
if (onConfigure != null)
|
||||
FItem(
|
||||
prefix: const Icon(Icons.settings_outlined, size: 17),
|
||||
title: const Text('配置全局参数'),
|
||||
onPress: onConfigure,
|
||||
),
|
||||
if (onRefresh != null)
|
||||
FItem(
|
||||
prefix: const Icon(Icons.refresh_rounded, size: 17),
|
||||
title: const Text('检查更新'),
|
||||
onPress: onRefresh,
|
||||
),
|
||||
FItem(
|
||||
prefix: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
size: 17,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
title: Text('删除模块', style: TextStyle(color: theme.colorScheme.error)),
|
||||
onPress: onDelete,
|
||||
),
|
||||
];
|
||||
|
||||
return Opacity(
|
||||
opacity: installedWidget.enabled ? 1 : 0.62,
|
||||
child: AppCard(
|
||||
onTap: installedWidget.enabled ? onOpen : null,
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusCard),
|
||||
),
|
||||
child: Text(
|
||||
manifest.title.isEmpty ? '?' : manifest.title[0],
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
manifest.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
if (manifest.author.isNotEmpty) manifest.author,
|
||||
'v${manifest.version}',
|
||||
'${manifest.modules.length} 个功能',
|
||||
].join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FSwitch(value: installedWidget.enabled, onChange: onToggle),
|
||||
const SizedBox(width: 6),
|
||||
FPopoverMenu(
|
||||
menuBuilder: autoDismissMenuBuilder,
|
||||
menuAnchor: Alignment.topRight,
|
||||
childAnchor: Alignment.bottomRight,
|
||||
menu: [FItemGroup(children: menuItems)],
|
||||
builder: (context, controller, child) =>
|
||||
GestureDetector(onTap: controller.toggle, child: child),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(Icons.more_vert_rounded, size: 19),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubscriptionsSection extends ConsumerWidget {
|
||||
final List<ScriptWidgetSubscription> subscriptions;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const _SubscriptionsSection({
|
||||
required this.subscriptions,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (subscriptions.isEmpty) {
|
||||
return const EmptyState(
|
||||
compact: true,
|
||||
icon: Icons.inventory_2_outlined,
|
||||
title: '尚未添加订阅',
|
||||
message: '添加 .fwd URL 后,可从仓库中安装和更新模块。',
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
for (final subscription in subscriptions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: AppCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
title: Text(subscription.catalog.title),
|
||||
subtitle: Text(
|
||||
'${subscription.catalog.widgets.length} 个模块 · ${subscription.url}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: '刷新订阅',
|
||||
onPressed: subscription.url.startsWith('http')
|
||||
? () async {
|
||||
try {
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.importSubscription(subscription.url);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(
|
||||
context,
|
||||
formatUserError(error),
|
||||
tone: AppAlertTone.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
children: [
|
||||
for (final entry in subscription.catalog.widgets)
|
||||
_CatalogEntryRow(
|
||||
entry: entry,
|
||||
onInstall: () async {
|
||||
try {
|
||||
final service = await ref.read(
|
||||
scriptWidgetServiceProvider.future,
|
||||
);
|
||||
await service.installFromUrl(entry.url);
|
||||
onChanged();
|
||||
if (context.mounted) {
|
||||
showAppToast(context, '已安装 ${entry.title}');
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(
|
||||
context,
|
||||
formatUserError(error),
|
||||
tone: AppAlertTone.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CatalogEntryRow extends StatelessWidget {
|
||||
final ScriptWidgetCatalogEntry entry;
|
||||
final VoidCallback onInstall;
|
||||
|
||||
const _CatalogEntryRow({required this.entry, required this.onInstall});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
entry.description.isNotEmpty
|
||||
? entry.description
|
||||
: '${entry.author} · v${entry.version}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: onInstall,
|
||||
child: const Text('安装'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlobalParametersDialog extends StatefulWidget {
|
||||
final List<ScriptWidgetParam> parameters;
|
||||
final Map<String, Object?> initialValues;
|
||||
|
||||
const _GlobalParametersDialog({
|
||||
required this.parameters,
|
||||
required this.initialValues,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_GlobalParametersDialog> createState() =>
|
||||
_GlobalParametersDialogState();
|
||||
}
|
||||
|
||||
class _GlobalParametersDialogState extends State<_GlobalParametersDialog> {
|
||||
late Map<String, Object?> _values;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_values = <String, Object?>{
|
||||
...buildScriptWidgetParameterDefaults(widget.parameters),
|
||||
...widget.initialValues,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('全局参数', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
const Text('这些值会自动合并到该模块的每次调用中。'),
|
||||
const SizedBox(height: 18),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 440),
|
||||
child: SingleChildScrollView(
|
||||
child: ScriptWidgetParameterForm(
|
||||
parameters: widget.parameters,
|
||||
initialValues: _values,
|
||||
onChanged: (values) {
|
||||
_values = Map<String, Object?>.from(values);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FButton(
|
||||
onPress: () => Navigator.of(context).pop(_values),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> showModuleUrlImportSheet(BuildContext context) {
|
||||
return showAdaptiveModal<String>(
|
||||
context: context,
|
||||
maxWidth: 520,
|
||||
useRootNavigator: true,
|
||||
compactMaxHeightFactor: 0.6,
|
||||
builder: (sheetContext) => const _UrlImportSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _UrlImportSheet extends StatefulWidget {
|
||||
const _UrlImportSheet();
|
||||
|
||||
@override
|
||||
State<_UrlImportSheet> createState() => _UrlImportSheetState();
|
||||
}
|
||||
|
||||
class _UrlImportSheetState extends State<_UrlImportSheet> {
|
||||
final TextEditingController _urlController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final inputBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: tokens.mutedForeground.withValues(alpha: 0.28),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('添加模块', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
const Text('支持单个 .js 脚本地址或 ForwardWidgets .fwd 订阅地址。'),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: _urlController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'HTTP(S) URL',
|
||||
hintText: 'https://example.com/widgets.fwd',
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surface.withValues(alpha: 0.42),
|
||||
border: inputBorder,
|
||||
enabledBorder: inputBorder,
|
||||
focusedBorder: inputBorder.copyWith(
|
||||
borderSide: BorderSide(color: theme.colorScheme.primary),
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FButton(
|
||||
variant: FButtonVariant.outline,
|
||||
onPress: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FButton(onPress: _submit, child: const Text('继续')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final url = _urlController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
Navigator.of(context).pop(url);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user