// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'widgets/ranking_drama_row.dart'; const int _topCount = 50; const double _loadMoreThreshold = 200; /// 剧场榜单页:统一行布局,名次角标压在封面左上角,右侧字段无数据不展示。 class RankingPage extends StatefulWidget { const RankingPage({super.key, this.embedded = false}); final bool embedded; @override State createState() => _RankingPageState(); } class _RankingPageState extends State { final ScrollController _scroll = ScrollController(); PagedLocalListController? _pager; bool _refreshing = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (_pager != null) return; _pager = PagedLocalListController( items: _ranked(context.read().dramas), )..addListener(_rebuild); } @override void dispose() { _pager ?..removeListener(_rebuild) ..dispose(); _scroll.dispose(); super.dispose(); } void _rebuild() { if (mounted) setState(() {}); } List _ranked(List source) { final dramas = [...source] ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); return dramas.take(_topCount).toList(growable: false); } Future _refresh() async { if (_refreshing) return; _refreshing = true; try { final catalog = context.read(); await catalog.refresh(); if (!mounted) return; _pager!.replaceAll(_ranked(catalog.dramas)); if (_scroll.hasClients) { _scroll.jumpTo(_scroll.position.minScrollExtent); } } finally { _refreshing = false; } } bool _onScroll(ScrollNotification notification) { if (notification.depth != 0 || _refreshing) return false; if (notification.metrics.extentAfter > _loadMoreThreshold) return false; if (notification is ScrollUpdateNotification || notification is ScrollEndNotification || notification is OverscrollNotification) { unawaited(_pager!.loadMore()); } return false; } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final catalog = context.watch(); final top = _pager!.visibleItems; final content = NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( key: const Key('ranking-refresh'), onRefresh: _refresh, child: ListView( key: const Key('ranking-list'), controller: _scroll, physics: AppPullToRefresh.physics, padding: const EdgeInsets.only(top: 8, bottom: 24), children: top.isEmpty ? [_emptyState(l10n, catalog)] : [ if (widget.embedded) _intro(l10n), const SizedBox(height: AppDimens.spaceMd), for (var index = 0; index < top.length; index++) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, vertical: AppDimens.spaceSm, ), child: RankingDramaRow( rank: index + 1, drama: top[index], onTap: () => unawaited( playDramaFromCatalog(context, top[index]), ), ), ), if (index != top.length - 1) const Padding( padding: EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), child: Divider( height: AppDimens.mineGridDividerWidth, thickness: AppDimens.mineGridDividerWidth, color: AppColors.border, ), ), ], PagedLoadFooter( loading: _pager!.loading, hasMore: _pager!.hasMore, hasItems: _pager!.visibleItems.isNotEmpty, loadMoreKey: const Key('ranking-load-more'), ), ], ), ), ); if (widget.embedded) { return KeyedSubtree(key: const Key('ranking-page'), child: content); } return PlainPageScaffold( pageKey: const Key('ranking-page'), backKey: const Key('ranking-back'), title: l10n.topCharts, subtitle: l10n.rankingSubtitle, child: content, ); } Widget _intro(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), child: Column( key: const Key('ranking-hero'), crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.topCharts, style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.rankingHeroTitle, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( l10n.rankingSubtitle, style: const TextStyle( color: AppColors.textTertiary, fontSize: AppDimens.rankingHeroSubtitle, ), ), ], ), ); } Widget _emptyState(AppLocalizations l10n, CatalogController catalog) { final theme = Theme.of(context); return SizedBox( height: 420, child: Theme( data: theme.copyWith( colorScheme: theme.colorScheme.copyWith( onSurface: AppColors.textTertiary, ), ), child: EmptyState( key: Key(catalog.error == null ? 'ranking-empty' : 'ranking-error'), icon: catalog.error == null ? Icons.leaderboard_outlined : Icons.cloud_off_rounded, title: catalog.error == null ? l10n.noStories : l10n.catalogError, actionLabel: catalog.error == null ? null : l10n.retry, onAction: catalog.error == null ? null : () => unawaited(_refresh()), stretchContent: true, ), ), ); } }