817 lines
26 KiB
Dart
817 lines
26 KiB
Dart
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);
|
|
}
|
|
}
|