Files

817 lines
26 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
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);
}
}