// 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/formatting/count_format.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 '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; const int _topCount = 50; const double _loadMoreThreshold = 200; const double _championLift = 12; const double _podiumGap = 8; const double _podiumHorizontal = 8; const double _championScale = 1.18; const double _coverAspect = 146 / 110; /// 剧场榜单页:前三名领奖台,第 4 名起为单列排行行。 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 podium = top.take(3).toList(growable: false); final rest = top.skip(3).toList(growable: false); 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), if (podium.isNotEmpty) Padding( padding: const EdgeInsets.symmetric( horizontal: _podiumHorizontal, ), child: _Podium(podium: podium, l10n: l10n), ), if (rest.isNotEmpty) ...[ const SizedBox(height: AppDimens.spaceXl), for (var index = 0; index < rest.length; index++) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), child: _RankingRow( index: index + 3, drama: rest[index], l10n: l10n, ), ), if (index != rest.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, 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()), ), ), ); } } class _Podium extends StatelessWidget { const _Podium({required this.podium, required this.l10n}); final List podium; final AppLocalizations l10n; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final available = constraints.maxWidth - _podiumGap * 2; final side = available / (2 + _championScale); final champion = side * _championScale; return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ SizedBox( width: side, child: podium.length > 1 ? _PodiumCard( rank: 2, drama: podium[1], l10n: l10n, coverWidth: side, ) : const SizedBox.shrink(), ), const SizedBox(width: _podiumGap), SizedBox( width: champion, child: podium.isNotEmpty ? Transform.translate( offset: const Offset(0, -_championLift), child: _PodiumCard( rank: 1, drama: podium.first, l10n: l10n, coverWidth: champion, ), ) : const SizedBox.shrink(), ), const SizedBox(width: _podiumGap), SizedBox( width: side, child: podium.length > 2 ? _PodiumCard( rank: 3, drama: podium[2], l10n: l10n, coverWidth: side, ) : const SizedBox.shrink(), ), ], ); }, ); } } class _PodiumCard extends StatelessWidget { const _PodiumCard({ required this.rank, required this.drama, required this.l10n, required this.coverWidth, }); final int rank; final Drama drama; final AppLocalizations l10n; final double coverWidth; double get _coverHeight => coverWidth * _coverAspect; Color get _badgeColor => switch (rank) { 1 => AppColors.homeCrownGold, 2 => AppColors.rankSilver, _ => AppColors.bronze, }; Color get _badgeTextColor => rank == 3 ? AppColors.textPrimary : AppColors.onGold; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => unawaited(playDramaFromCatalog(context, drama)), child: Column( mainAxisSize: MainAxisSize.min, children: [ ClipRRect( borderRadius: BorderRadius.circular(AppDimens.rankingPodiumRadius), child: SizedBox( key: Key('ranking-cover-$rank'), width: coverWidth, height: _coverHeight, child: Stack( fit: StackFit.expand, children: [ RemoteImage( drama.posterUrl, fit: BoxFit.cover, showFallbackIcon: true, fallbackAspectRatio: 3 / 4, ), Positioned( top: 4, left: 4, child: Container( key: Key('ranking-top-$rank'), padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: _badgeColor, borderRadius: BorderRadius.circular(6), ), child: Text( l10n.topLabel(rank), maxLines: 1, style: TextStyle( color: _badgeTextColor, fontSize: 11, fontWeight: FontWeight.w800, ), ), ), ), ], ), ), ), const SizedBox(height: 6), SizedBox( height: 34, child: Text( drama.title, key: Key('ranking-title-$rank'), maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: const TextStyle( color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w600, height: 1.25, ), ), ), if (drama.viewCount > 0) ...[ const SizedBox(height: 2), _heat(drama.viewCount, l10n), ], ], ), ); } } class _RankingRow extends StatelessWidget { const _RankingRow({ required this.index, required this.drama, required this.l10n, }); final int index; final Drama drama; final AppLocalizations l10n; int get _rank => index + 1; String get _category { final values = {}; for (final value in [drama.genre, drama.category]) { for (final part in value.split(RegExp(r'[,/、·]'))) { final tag = part.trim(); if (tag.isNotEmpty) values.add(tag); } } return values.join(' · '); } @override Widget build(BuildContext context) { return SizedBox( height: AppDimens.rankingRowHeight, child: Material( color: AppColors.transparent, child: InkWell( key: Key('ranking-row-$index'), onTap: () => unawaited(playDramaFromCatalog(context, drama)), child: Row( children: [ SizedBox( width: AppDimens.rankingNumberWidth, child: Text( '$_rank', key: Key('ranking-top-$_rank'), textAlign: TextAlign.center, maxLines: 1, style: TextStyle( color: AppColors.textPrimary.withValues(alpha: 0.12), fontSize: AppDimens.rankingNumberFont, fontWeight: FontWeight.w800, height: 1, ), ), ), ClipRRect( borderRadius: BorderRadius.circular( AppDimens.rankingCoverRadius, ), child: SizedBox( key: Key('ranking-row-cover-$_rank'), width: AppDimens.rankingCoverWidth, height: AppDimens.rankingCoverHeight, child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, showFallbackIcon: true, fallbackAspectRatio: 3 / 4, ), ), ), const SizedBox(width: AppDimens.spaceMd), Expanded( child: SizedBox( height: AppDimens.rankingCoverHeight, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( drama.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textPrimary, fontSize: 15, fontWeight: FontWeight.w500, ), ), if (_category.isNotEmpty) ...[ const SizedBox(height: AppDimens.spaceXs), Text( _category, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textTertiary, fontSize: 11, ), ), ], const Spacer(), if (drama.viewCount > 0) _heat(drama.viewCount, l10n), ], ), ), ), ], ), ), ), ); } } Widget _heat(int count, AppLocalizations l10n) { return Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.local_fire_department_rounded, color: AppColors.textTertiary, size: 11, ), const SizedBox(width: 4), Text( l10n.heatLabel(formatPlayCount(count)), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), ), ], ); }