import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/page_background.dart'; import '../../shared/widgets/script_video_card.dart'; import '../../shared/widgets/section_header.dart'; const double _kInfiniteScrollThreshold = 200; class ModuleAllPage extends ConsumerStatefulWidget { final String widgetId; final String moduleKey; final Map initialParams; const ModuleAllPage({ super.key, required this.widgetId, required this.moduleKey, this.initialParams = const {}, }); @override ConsumerState createState() => _ModuleAllPageState(); } class _ModuleAllPageState extends ConsumerState { final ScrollController _scrollController = ScrollController(); ScriptWidgetModule? _module; Map _parameters = {}; final List _sections = []; String? _initializedWidgetId; String? _initializationError; bool _isFirstLoad = true; bool _isLoadingMore = false; bool _hasMore = true; String? _loadError; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } @override void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } void _onScroll() { if (!_scrollController.hasClients) return; final remaining = _scrollController.position.maxScrollExtent - _scrollController.position.pixels; if (remaining <= _kInfiniteScrollThreshold) { unawaited(_fetchNextPage()); } } Future _initialize(InstalledScriptWidget installedWidget) async { if (_initializedWidgetId == installedWidget.manifest.id) return; _initializedWidgetId = installedWidget.manifest.id; final module = installedWidget.manifest.modules .where((candidate) => _moduleKey(candidate) == widget.moduleKey) .firstOrNull; if (module == null) { setState(() { _initializationError = '未找到模块功能:${widget.moduleKey}'; }); return; } final resolvedParameters = buildScriptWidgetParameterDefaults(module.params); for (final entry in widget.initialParams.entries) { if (resolvedParameters.containsKey(entry.key)) { resolvedParameters[entry.key] = entry.value; } } setState(() { _module = module; _parameters = resolvedParameters; _initializationError = null; }); await _fetchNextPage(); } String _moduleKey(ScriptWidgetModule module) { return module.id.isEmpty ? module.functionName : module.id; } ScriptWidgetParam? get _pagingParameter { return _module?.params .where( (parameter) => parameter.type == 'page' || parameter.type == 'offset', ) .firstOrNull; } bool get _supportsPagination => _pagingParameter != null; Future _fetchNextPage() async { if (_isLoadingMore || !_hasMore) return; final module = _module; if (module == null) return; setState(() { _isLoadingMore = true; _loadError = null; }); try { final service = await ref.read(scriptWidgetServiceProvider.future); final freshSections = await service.loadModuleSections( widget.widgetId, module, _parameters, ); if (!mounted) return; final contentItemCount = freshSections.fold( 0, (count, section) => count + section.items .where((item) => item.type.toLowerCase() != 'text') .length, ); setState(() { _mergeSections(freshSections); if (!_supportsPagination || contentItemCount == 0) { _hasMore = false; } else { _advancePagingCursor(); } _isFirstLoad = false; _isLoadingMore = false; }); } catch (error) { if (!mounted) return; setState(() { _loadError = formatUserError(error); _isFirstLoad = false; _isLoadingMore = false; }); } } void _mergeSections(List freshSections) { for (final freshSection in freshSections) { final existingIndex = _sections.indexWhere( (existing) => existing.title == freshSection.title, ); if (existingIndex == -1) { _sections.add(freshSection); continue; } final merged = [ ..._sections[existingIndex].items, ...freshSection.items, ]; _sections[existingIndex] = ScriptWidgetContentSection( title: freshSection.title, items: merged, ); } } void _advancePagingCursor() { final module = _module; final pagingParameter = _pagingParameter; if (module == null || pagingParameter == null) return; final minimumValue = pagingParameter.type == 'page' ? 1 : 0; final currentValue = int.tryParse(_parameters[pagingParameter.name]?.toString() ?? '') ?? minimumValue; final countParameter = module.params .where((parameter) => parameter.type == 'count') .firstOrNull; final pageStep = pagingParameter.type == 'offset' ? int.tryParse(_parameters[countParameter?.name]?.toString() ?? '') ?? 20 : 1; _parameters[pagingParameter.name] = currentValue + pageStep; } Future _refresh() async { setState(() { _sections.clear(); _isFirstLoad = true; _isLoadingMore = false; _hasMore = true; _loadError = null; final module = _module; if (module != null) { _parameters = buildScriptWidgetParameterDefaults(module.params); for (final entry in widget.initialParams.entries) { if (_parameters.containsKey(entry.key)) { _parameters[entry.key] = entry.value; } } } }); await _fetchNextPage(); } @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) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_initialize(widgetData)); }); return _buildContent(widgetData); }, ), ], ), ); } Widget _buildContent(InstalledScriptWidget installedWidget) { final moduleTitle = _module?.title.trim(); final title = moduleTitle == null || moduleTitle.isEmpty ? installedWidget.manifest.title : moduleTitle; return RefreshIndicator( onRefresh: _refresh, child: CustomScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), slivers: [ AppSliverHeader(title: title), ..._buildBodySlivers(), ], ), ); } List _buildBodySlivers() { if (_initializationError case final initializationError?) { return [ SliverFillRemaining( hasScrollBody: false, child: AppErrorState(message: initializationError), ), ]; } if (_module == null || (_isFirstLoad && _isLoadingMore)) { return const [ SliverFillRemaining( hasScrollBody: false, child: Center(child: AppLoadingRing()), ), ]; } final firstLoadError = _isFirstLoad ? _loadError : null; if (firstLoadError != null) { return [ SliverFillRemaining( hasScrollBody: false, child: AppErrorState( message: firstLoadError, onRetry: _fetchNextPage, ), ), ]; } if (_sections.every((section) => section.items.isEmpty)) { return const [ SliverFillRemaining( hasScrollBody: false, child: EmptyState( compact: true, icon: Icons.video_library_outlined, title: '没有可显示的内容', ), ), ]; } return [ SliverPadding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), sliver: SliverList( delegate: SliverChildListDelegate.fixed([ for (final section in _sections) ...[ _ModuleGridSection( section: section, widgetId: widget.widgetId, ), const SizedBox(height: 28), ], ]), ), ), SliverToBoxAdapter(child: _buildFooter()), ]; } Widget _buildFooter() { if (_isLoadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: AppLoadingRing()), ); } if (_loadError case final loadError?) { return Padding( padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), child: AppErrorState( compact: true, message: loadError, onRetry: _fetchNextPage, ), ); } if (!_hasMore) { return Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Center( child: Text( '没有更多了', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: context.appTokens.mutedForeground, ), ), ), ); } return const SizedBox(height: 40); } } class _ModuleGridSection extends StatelessWidget { final ScriptWidgetContentSection section; final String widgetId; const _ModuleGridSection({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 _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, ), ), ], ], ), ), ], ), ); } }