import 'dart:async'; 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 '../../core/domain/usecases/script_widget/script_widget_service.dart'; import '../../providers/di_providers.dart'; import '../../shared/constants/layout_constants.dart'; import '../../shared/theme/app_theme.dart'; import '../../shared/utils/user_error_formatter.dart'; import '../../shared/widgets/app_card.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/widgets/app_sliver_header.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/error_media_row.dart'; import '../../shared/widgets/hover_scrollable_row.dart'; import '../../shared/widgets/media_poster_card.dart'; import '../../shared/widgets/media_row_metrics.dart'; import '../../shared/widgets/page_background.dart'; import '../../shared/widgets/script_video_card.dart'; import '../../shared/widgets/section_header.dart'; import '../../shared/widgets/skeleton_media_row.dart'; import 'script_widget_parameter_form.dart'; class ModuleBrowsePage extends ConsumerStatefulWidget { final String widgetId; const ModuleBrowsePage({super.key, required this.widgetId}); @override ConsumerState createState() => _ModuleBrowsePageState(); } class _ModuleBrowsePageState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); final Map> _moduleParameters = >{}; InstalledScriptWidget? _installedWidget; List _searchSections = const []; bool _searchMode = false; bool _filtersExpanded = false; bool _loadingSearch = false; String? _searchError; @override void dispose() { _searchController.dispose(); super.dispose(); } List _contentModules(ScriptWidgetManifest manifest) { return manifest.modules .where( (module) => module.type != 'danmu' && module.type != 'stream' && module.id != 'loadResource', ) .toList(growable: false); } Map _parametersFor(ScriptWidgetModule module) { return _moduleParameters.putIfAbsent( _moduleKey(module), () => buildScriptWidgetParameterDefaults(module.params), ); } List _searchParameters(ScriptWidgetModule? searchModule) { if (searchModule == null) return const []; return searchModule.params .where((parameter) { final normalizedName = parameter.name.toLowerCase(); final isKeywordParameter = const { 'keyword', 'query', 'search', 'title', }.contains(normalizedName); final isInternalParameter = const { 'constant', 'page', 'offset', }.contains(parameter.type); return !isKeywordParameter && !isInternalParameter; }) .toList(growable: false); } Future _search() async { final searchModule = _installedWidget?.manifest.search; if (searchModule == null) return; final keyword = _searchController.text.trim(); if (keyword.isEmpty) return; final searchParameters = { ...buildScriptWidgetParameterDefaults(searchModule.params), ..._parametersFor(searchModule), }; final keywordParameter = searchModule.params .where( (parameter) => const { 'keyword', 'query', 'search', 'title', }.contains(parameter.name.toLowerCase()), ) .firstOrNull; searchParameters[keywordParameter?.name ?? 'keyword'] = keyword; setState(() { _loadingSearch = true; _searchError = null; }); try { final service = await ref.read(scriptWidgetServiceProvider.future); final items = await service.search(widget.widgetId, searchParameters); if (!mounted) return; setState(() { _searchSections = [ ScriptWidgetContentSection(title: '搜索结果', items: items), ]; }); } catch (error) { if (!mounted) return; setState(() { _searchError = formatUserError(error); _searchSections = const []; }); } finally { if (mounted) { setState(() => _loadingSearch = false); } } } ScriptWidgetParam? get _searchPagingParameter { final searchModule = _installedWidget?.manifest.search; return searchModule?.params .where( (parameter) => parameter.type == 'page' || parameter.type == 'offset', ) .firstOrNull; } Future _changeSearchPage(int direction) async { final searchModule = _installedWidget?.manifest.search; final pagingParameter = _searchPagingParameter; if (searchModule == null || pagingParameter == null) return; final parameters = _parametersFor(searchModule); final minimumValue = pagingParameter.type == 'page' ? 1 : 0; final currentValue = int.tryParse(parameters[pagingParameter.name]?.toString() ?? '') ?? minimumValue; final countParameter = searchModule.params .where((parameter) => parameter.type == 'count') .firstOrNull; final pageStep = pagingParameter.type == 'offset' ? int.tryParse(parameters[countParameter?.name]?.toString() ?? '') ?? 20 : 1; parameters[pagingParameter.name] = (currentValue + direction * pageStep) .clamp(minimumValue, 1 << 31); await _search(); } bool get _canGoBackSearchPage { final searchModule = _installedWidget?.manifest.search; final pagingParameter = _searchPagingParameter; if (searchModule == null || pagingParameter == null) return false; final minimumValue = pagingParameter.type == 'page' ? 1 : 0; final currentValue = int.tryParse( _parametersFor(searchModule)[pagingParameter.name]?.toString() ?? '', ) ?? minimumValue; return currentValue > minimumValue; } String get _currentSearchPageLabel { final searchModule = _installedWidget?.manifest.search; final pagingParameter = _searchPagingParameter; if (searchModule == null || pagingParameter == null) return '第 1 页'; final parameters = _parametersFor(searchModule); final minimumValue = pagingParameter.type == 'page' ? 1 : 0; final currentValue = int.tryParse(parameters[pagingParameter.name]?.toString() ?? '') ?? minimumValue; if (pagingParameter.type == 'page') return '第 $currentValue 页'; final countParameter = searchModule.params .where((parameter) => parameter.type == 'count') .firstOrNull; final pageSize = int.tryParse(parameters[countParameter?.name]?.toString() ?? '') ?? 20; return '第 ${(currentValue ~/ pageSize) + 1} 页'; } @override Widget build(BuildContext context) { final installedWidget = ref.watch( installedScriptWidgetProvider(widget.widgetId), ); return Scaffold( backgroundColor: Colors.transparent, extendBodyBehindAppBar: true, body: Stack( children: [ const Positioned.fill(child: PageBackground()), installedWidget.when( loading: () => const Center(child: AppLoadingRing()), error: (error, _) => Center( child: AppErrorState( message: formatUserError(error), onRetry: () => ref.invalidate( installedScriptWidgetProvider(widget.widgetId), ), ), ), data: (widgetData) { _installedWidget = widgetData; return _buildContent(widgetData); }, ), ], ), ); } Widget _buildContent(InstalledScriptWidget installedWidget) { final manifest = installedWidget.manifest; final contentModules = _contentModules(manifest); final searchParameters = _searchParameters(manifest.search); return CustomScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), slivers: [ AppSliverHeader( title: manifest.title, bottom: _searchMode ? PreferredSize( preferredSize: const Size.fromHeight(60), child: _SearchControlBar( searchController: _searchController, searchHint: manifest.search?.description, filtersExpanded: _filtersExpanded, hasFilters: searchParameters.isNotEmpty, onSearch: _search, onToggleFilters: () { setState(() => _filtersExpanded = !_filtersExpanded); }, ), ) : null, actions: [ if (manifest.search != null) IconButton( onPressed: () { setState(() { _searchMode = !_searchMode; _filtersExpanded = false; }); }, tooltip: _searchMode ? '关闭搜索' : '搜索', icon: Icon( _searchMode ? Icons.close_rounded : Icons.search_rounded, ), ), ], ), if (manifest.description.trim().isNotEmpty) SliverPadding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 6), sliver: SliverToBoxAdapter( child: _ModuleIdentityCard(installedWidget: installedWidget), ), ), if (_searchMode) SliverPadding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 56), sliver: SliverList( delegate: SliverChildListDelegate.fixed([ _buildSearchFilterPanel(manifest), if (_filtersExpanded && searchParameters.isNotEmpty) const SizedBox(height: 18), _buildSearchResults(), ]), ), ) else if (contentModules.isEmpty) const SliverFillRemaining( hasScrollBody: false, child: EmptyState( icon: Icons.video_library_outlined, title: '没有内容功能', message: '该模块没有可浏览的内容功能。', ), ) else SliverList( delegate: SliverChildListDelegate.fixed([ for (final module in contentModules) _ModulePreviewSection( widgetId: widget.widgetId, module: module, ), const SizedBox(height: 56), ]), ), ], ); } Widget _buildSearchFilterPanel(ScriptWidgetManifest manifest) { final searchModule = manifest.search; final parameters = _searchParameters(searchModule); return AnimatedSize( duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, child: !_filtersExpanded || searchModule == null || parameters.isEmpty ? const SizedBox.shrink() : AppCard( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (searchModule.description.trim().isNotEmpty) ...[ Text( searchModule.description, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: context.appTokens.mutedForeground, ), ), const SizedBox(height: 14), ], ScriptWidgetParameterForm( parameters: parameters, initialValues: _parametersFor(searchModule), onChanged: (values) { _parametersFor(searchModule).addAll(values); }, ), const SizedBox(height: 14), Align( alignment: Alignment.centerRight, child: FButton( onPress: _search, child: const Text('应用并搜索'), ), ), ], ), ), ); } Widget _buildSearchResults() { if (_loadingSearch) { return const Padding( padding: EdgeInsets.symmetric(vertical: 64), child: Center(child: AppLoadingRing()), ); } if (_searchError case final searchError?) { return AppErrorState( compact: true, message: searchError, onRetry: _search, ); } if (_searchSections.every((section) => section.items.isEmpty)) { return const EmptyState( compact: true, icon: Icons.search_rounded, title: '输入关键词开始搜索', message: '搜索结果会显示在这里。', ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final section in _searchSections) ...[ _ModuleSearchSection(section: section, widgetId: widget.widgetId), const SizedBox(height: 28), ], if (_searchPagingParameter != null) _PaginationBar( canGoBack: _canGoBackSearchPage, pageLabel: _currentSearchPageLabel, onPrevious: () => unawaited(_changeSearchPage(-1)), onNext: () => unawaited(_changeSearchPage(1)), ), ], ); } } String _moduleKey(ScriptWidgetModule module) { return module.id.isEmpty ? module.functionName : module.id; } class _ModulePreviewSection extends ConsumerWidget { final String widgetId; final ScriptWidgetModule module; const _ModulePreviewSection({required this.widgetId, required this.module}); ScriptWidgetParam? get _primaryEnumerationParam { return module.params .where( (parameter) => parameter.type == 'enumeration' && parameter.enumOptions.length >= 2, ) .firstOrNull; } @override Widget build(BuildContext context, WidgetRef ref) { final previewKey = (widgetId: widgetId, moduleKey: _moduleKey(module)); final sections = ref.watch(scriptWidgetModulePreviewProvider(previewKey)); final enumerationParam = _primaryEnumerationParam; final categoryRow = enumerationParam == null ? const SizedBox.shrink() : _ModuleCategoryRow( widgetId: widgetId, moduleKey: _moduleKey(module), moduleTitle: module.title, parameter: enumerationParam, ); return sections.when( loading: () => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [categoryRow, SkeletonMediaRow(title: module.title)], ), error: (error, _) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ categoryRow, ErrorMediaRow( title: module.title, message: formatUserError(error), onRetry: () => ref.invalidate(scriptWidgetModulePreviewProvider(previewKey)), ), ], ), data: (loadedSections) { final visibleSections = loadedSections .where((section) => section.items.isNotEmpty) .toList(growable: false); if (visibleSections.isEmpty && enumerationParam == null) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ categoryRow, for (final section in visibleSections) _ModulePreviewRow( widgetId: widgetId, moduleKey: _moduleKey(module), section: section, ), ], ); }, ); } } class _ModuleCategoryRow extends StatelessWidget { final String widgetId; final String moduleKey; final String moduleTitle; final ScriptWidgetParam parameter; const _ModuleCategoryRow({ required this.widgetId, required this.moduleKey, required this.moduleTitle, required this.parameter, }); static const double _cardHeight = 74; static const double _cardWidth = 168; @override Widget build(BuildContext context) { final options = parameter.enumOptions; final headerLabel = parameter.title.trim().isEmpty ? '$moduleTitle · 类型' : '$moduleTitle · ${parameter.title.trim()}'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(24, 12, 24, 4), child: SectionHeader(label: headerLabel, count: options.length), ), SizedBox( height: _cardHeight, child: HoverScrollableRow( builder: (_, scrollController) => ListView.separated( controller: scrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 24), itemCount: options.length, separatorBuilder: (_, _) => const SizedBox(width: 12), itemBuilder: (context, index) { final option = options[index]; return _CategoryCard( label: option.title.isEmpty ? option.value : option.title, width: _cardWidth, onTap: () => _openCategory(context, option), ); }, ), ), ), ], ); } void _openCategory(BuildContext context, ScriptWidgetOption option) { final targetUri = Uri( path: '/modules/${Uri.encodeComponent(widgetId)}/module/' '${Uri.encodeComponent(moduleKey)}', queryParameters: {parameter.name: option.value}, ); context.push(targetUri.toString()); } } class _CategoryCard extends StatefulWidget { final String label; final double width; final VoidCallback onTap; const _CategoryCard({ required this.label, required this.width, required this.onTap, }); @override State<_CategoryCard> createState() => _CategoryCardState(); } class _CategoryCardState extends State<_CategoryCard> { bool _isHovered = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; return SizedBox( width: widget.width, child: AppCard( filled: true, enableHover: false, radius: tokens.radiusCard, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), onTap: widget.onTap, onHoverChanged: (isHovered) { if (_isHovered == isHovered) return; setState(() => _isHovered = isHovered); }, child: Row( children: [ Icon( Icons.style_outlined, size: 20, color: theme.colorScheme.primary.withValues( alpha: _isHovered ? 0.9 : 0.65, ), ), const SizedBox(width: 10), Expanded( child: Text( widget.label, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), ], ), ), ); } } class _ModulePreviewRow extends StatelessWidget { final String widgetId; final String moduleKey; final ScriptWidgetContentSection section; const _ModulePreviewRow({ required this.widgetId, required this.moduleKey, required this.section, }); @override Widget build(BuildContext context) { final statusItems = section.items .where((item) => item.type.toLowerCase() == 'text') .toList(growable: false); final contentItems = section.items .where((item) => item.type.toLowerCase() != 'text') .take(20) .toList(growable: false); final compact = MediaRowMetrics.isCompact(context); final cardWidth = MediaRowMetrics.cardWidth( MediaCardVariant.poster, compact: compact, ); final rowHeight = MediaRowMetrics.rowHeight( MediaCardVariant.poster, compact: compact, ); final allRoute = '/modules/${Uri.encodeComponent(widgetId)}/module/' '${Uri.encodeComponent(moduleKey)}'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (contentItems.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(24, 12, 24, 4), child: SectionHeader( label: section.title, count: contentItems.length, trailing: TextButton( onPressed: () => context.push(allRoute), child: const Text('查看全部'), ), ), ), for (final statusItem in statusItems) Padding( padding: const EdgeInsets.fromLTRB(24, 4, 24, 12), child: _ModuleStatusCard(item: statusItem), ), if (contentItems.isNotEmpty) SizedBox( height: rowHeight, child: HoverScrollableRow( builder: (_, scrollController) => ListView.separated( controller: scrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 24), itemCount: contentItems.length, separatorBuilder: (_, _) => const SizedBox(width: 14), itemBuilder: (context, index) { final item = contentItems[index]; return ScriptVideoCard( item: item, width: cardWidth, onTap: () => context.push( '/modules/${Uri.encodeComponent(widgetId)}/detail', extra: item, ), ); }, ), ), ), ], ); } } class _ModuleSearchSection extends StatelessWidget { final ScriptWidgetContentSection section; final String widgetId; const _ModuleSearchSection({required this.section, required this.widgetId}); @override Widget build(BuildContext context) { final statusItems = section.items .where((item) => item.type.toLowerCase() == 'text') .toList(growable: false); final contentItems = section.items .where((item) => item.type.toLowerCase() != 'text') .toList(growable: false); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SectionHeader(label: section.title, count: contentItems.length), for (final statusItem in statusItems) ...[ _ModuleStatusCard(item: statusItem), const SizedBox(height: 12), ], if (contentItems.isNotEmpty) GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: contentItems.length, gridDelegate: mediaGridDelegate, itemBuilder: (context, index) { final item = contentItems[index]; return ScriptVideoCard( item: item, width: double.infinity, onTap: () => context.push( '/modules/${Uri.encodeComponent(widgetId)}/detail', extra: item, ), ); }, ), ], ); } } class _ModuleIdentityCard extends StatelessWidget { final InstalledScriptWidget installedWidget; const _ModuleIdentityCard({required this.installedWidget}); @override Widget build(BuildContext context) { final manifest = installedWidget.manifest; return AppCard( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: Theme.of( context, ).colorScheme.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(14), ), child: Icon( Icons.extension_rounded, color: Theme.of(context).colorScheme.primary, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( manifest.description, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 6), Text( [ if (manifest.author.isNotEmpty) manifest.author, 'v${manifest.version}', ].join(' · '), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: context.appTokens.mutedForeground, ), ), ], ), ), ], ), ); } } class _ModuleStatusCard extends StatelessWidget { final ScriptVideoItem item; const _ModuleStatusCard({required this.item}); @override Widget build(BuildContext context) { final normalizedId = item.id.toLowerCase(); final indicatesError = normalizedId.startsWith('err') || item.title.contains('失败') || item.title.contains('错误') || item.title.contains('拦截'); final foregroundColor = indicatesError ? Theme.of(context).colorScheme.error : context.appTokens.mutedForeground; return AppCard( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( indicatesError ? Icons.error_outline_rounded : Icons.info_outline_rounded, color: foregroundColor, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.title.isEmpty ? '模块提示' : item.title, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: foregroundColor, fontWeight: FontWeight.w700, ), ), if (item.description.trim().isNotEmpty) ...[ const SizedBox(height: 4), Text( item.description, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: context.appTokens.mutedForeground, ), ), ], ], ), ), ], ), ); } } class _SearchControlBar extends StatelessWidget { final TextEditingController searchController; final String? searchHint; final bool filtersExpanded; final bool hasFilters; final VoidCallback onSearch; final VoidCallback onToggleFilters; const _SearchControlBar({ required this.searchController, required this.searchHint, required this.filtersExpanded, required this.hasFilters, required this.onSearch, required this.onToggleFilters, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; return SizedBox( height: 60, child: Padding( padding: const EdgeInsets.fromLTRB(16, 6, 12, 8), child: Row( children: [ Expanded( child: TextField( controller: searchController, autofocus: true, textInputAction: TextInputAction.search, decoration: InputDecoration( hintText: searchHint?.trim().isNotEmpty == true ? searchHint : '输入关键词', prefixIcon: const Icon(Icons.search_rounded, size: 19), filled: true, fillColor: theme.colorScheme.onSurface.withValues( alpha: 0.05, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), isDense: true, ), onSubmitted: (_) => onSearch(), ), ), const SizedBox(width: 8), IconButton( onPressed: onSearch, icon: const Icon(Icons.arrow_forward_rounded), tooltip: '搜索', ), if (hasFilters) IconButton( onPressed: onToggleFilters, icon: Icon( Icons.tune_rounded, color: filtersExpanded ? theme.colorScheme.primary : tokens.mutedForeground, ), tooltip: filtersExpanded ? '收起筛选' : '展开筛选', ), ], ), ), ); } } class _PaginationBar extends StatelessWidget { final bool canGoBack; final String pageLabel; final VoidCallback onPrevious; final VoidCallback onNext; const _PaginationBar({ required this.canGoBack, required this.pageLabel, required this.onPrevious, required this.onNext, }); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ FButton( variant: FButtonVariant.outline, onPress: canGoBack ? onPrevious : null, child: const Text('上一页'), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 14), child: Text( pageLabel, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: context.appTokens.mutedForeground, ), ), ), FButton(onPress: onNext, child: const Text('下一页')), ], ); } }