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 showModuleCenterSheet(BuildContext context) { return showAdaptiveModal( 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 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 createState() => _ModuleCenterContentState(); } class _ModuleCenterContentState extends ConsumerState { int _selectedSectionIndex = 0; @override Widget build(BuildContext context) { final installedWidgets = ref.watch(installedScriptWidgetsProvider); final subscriptions = ref.watch(scriptWidgetSubscriptionsProvider); final embedded = widget.embedded; final headerActions = [ 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 ['已安装', '模块订阅'], 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 _refresh() async { ref.invalidate(installedScriptWidgetsProvider); ref.invalidate(scriptWidgetSubscriptionsProvider); await Future.wait([ ref.read(installedScriptWidgetsProvider.future), ref.read(scriptWidgetSubscriptionsProvider.future), ]); } Future _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 _addFromFile(BuildContext context) async { const acceptedFiles = XTypeGroup( label: 'ForwardWidgets', extensions: ['js', 'fwd'], ); final selectedFile = await openFile( acceptedTypeGroups: const [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 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>( 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 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 = [ 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 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 parameters; final Map initialValues; const _GlobalParametersDialog({ required this.parameters, required this.initialValues, }); @override State<_GlobalParametersDialog> createState() => _GlobalParametersDialogState(); } class _GlobalParametersDialogState extends State<_GlobalParametersDialog> { late Map _values; @override void initState() { super.initState(); _values = { ...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.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 showModuleUrlImportSheet(BuildContext context) { return showAdaptiveModal( 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); } }