// 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_theme.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 _podiumGap = 8; const double _coverRadius = 12; const double _titleSlotHeight = 24; const double _rowGap = 12; const double _rankColumnWidth = 56; const double _listCoverWidth = 72; const double _listCoverHeight = 96; const double _loadMoreThreshold = 200; const Color _goldStart = Color(0xFFF3B34C); const Color _goldEnd = Color(0xFFD9A23A); const Color _bronzeStart = Color(0xFFCD7F32); const Color _bronzeEnd = Color(0xFFA9713A); /// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。 class RankingPage extends StatefulWidget { const RankingPage({super.key}); @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; final reachedBottom = notification.metrics.extentAfter <= _loadMoreThreshold; if (reachedBottom && (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 pager = _pager!; final top = pager.visibleItems; final podium = top.take(3).toList(); final rest = top.skip(3).toList(); return PlainPageScaffold( pageKey: const Key('ranking-page'), backKey: const Key('ranking-back'), title: l10n.topCharts, subtitle: l10n.rankingSubtitle, child: 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: 4, bottom: 24), children: top.isEmpty ? [ SizedBox( height: 420, child: EmptyState( icon: Icons.leaderboard_outlined, title: l10n.noStories, ), ), ] : [ if (podium.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 14), child: _Podium(podium: podium, l10n: l10n), ), if (rest.isNotEmpty) ...[ const SizedBox(height: 16), for (var index = 0; index < rest.length; index++) ...[ if (index > 0) const SizedBox(height: _rowGap), Padding( padding: const EdgeInsets.symmetric(horizontal: 14), child: _RankingRow( index: index + 3, drama: rest[index], l10n: l10n, ), ), ], ], PagedLoadFooter( loading: pager.loading, hasMore: pager.hasMore, hasItems: pager.items.isNotEmpty, loadMoreKey: const Key('ranking-load-more'), noMoreKey: const Key('ranking-no-more'), noMoreLabel: l10n.noMore, ), ], ), ), ), ); } } class _Podium extends StatelessWidget { const _Podium({required this.podium, required this.l10n}); final List podium; final AppLocalizations l10n; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded(flex: 100, child: _slot(2)), const SizedBox(width: _podiumGap), Expanded(flex: 115, child: _slot(1)), const SizedBox(width: _podiumGap), Expanded(flex: 100, child: _slot(3)), ], ); } Widget _slot(int rank) { if (podium.length < rank) return const SizedBox.shrink(); return _PodiumCard(rank: rank, drama: podium[rank - 1], l10n: l10n); } } class _PodiumCard extends StatelessWidget { const _PodiumCard({ required this.rank, required this.drama, required this.l10n, }); final int rank; final Drama drama; final AppLocalizations l10n; bool get _center => rank == 1; LinearGradient get _badgeGradient => switch (rank) { 1 => const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.blue, AppColors.bluePressed], ), 2 => const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [_goldStart, _goldEnd], ), _ => const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [_bronzeStart, _bronzeEnd], ), }; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => unawaited(playDramaFromCatalog(context, drama)), child: Column( mainAxisSize: MainAxisSize.min, children: [ ClipRRect( borderRadius: BorderRadius.circular(_coverRadius), child: AspectRatio( key: Key('ranking-cover-$rank'), aspectRatio: 3 / 4, child: Stack( fit: StackFit.expand, children: [ RemoteImage( drama.posterUrl, fit: BoxFit.cover, showFallbackIcon: true, fallbackAspectRatio: 3 / 4, ), Positioned( top: 8, left: 8, child: Container( key: Key('ranking-top-$rank'), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( gradient: _badgeGradient, borderRadius: BorderRadius.circular(999), ), child: Text( l10n.topLabel(rank), maxLines: 1, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w800, ), ), ), ), ], ), ), ), const SizedBox(height: 6), SizedBox( height: _titleSlotHeight, child: Center( child: Text( drama.title, key: Key('ranking-title-$rank'), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( color: AppColors.onSurface, fontSize: _center ? 15 : 13, fontWeight: _center ? FontWeight.w800 : FontWeight.w700, ), ), ), ), ], ), ); } } 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; List _tagValues() { final result = []; for (final value in [drama.genre, drama.category]) { for (final part in value.split(RegExp(r'[,/、·]'))) { final tag = part.trim(); if (tag.isEmpty || result.contains(tag)) continue; result.add(tag); if (result.length == 3) return result; } } return result; } @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(14), boxShadow: const [AppTheme.cardShadow], ), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(14), clipBehavior: Clip.antiAlias, child: InkWell( key: Key('ranking-row-$index'), onTap: () => unawaited(playDramaFromCatalog(context, drama)), child: Padding( padding: const EdgeInsets.all(10), child: Row( children: [ SizedBox( width: _rankColumnWidth, child: Text( l10n.topLabel(_rank), key: Key('ranking-top-$_rank'), textAlign: TextAlign.center, maxLines: 1, style: const TextStyle( color: AppColors.subtle, fontSize: 18, fontWeight: FontWeight.w800, ), ), ), ClipRRect( borderRadius: BorderRadius.circular(10), child: SizedBox( width: _listCoverWidth, height: _listCoverHeight, child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, showFallbackIcon: true, fallbackAspectRatio: 3 / 4, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( drama.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w700, ), ), if (drama.synopsis.trim().isNotEmpty) ...[ const SizedBox(height: 4), Text( drama.synopsis, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 12, height: 1.3, ), ), ], if (_tagValues().isNotEmpty) ...[ const SizedBox(height: 6), Wrap( spacing: 6, runSpacing: 4, children: [ for (final tag in _tagValues()) Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: AppColors.raised, borderRadius: BorderRadius.circular(6), ), child: Text( tag, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 11, ), ), ), ], ), ], const SizedBox(height: 8), Row( children: [ const Icon( Icons.local_fire_department_rounded, color: AppColors.blue, size: 12, ), const SizedBox(width: 4), Flexible( child: Text( l10n.heatLabel( formatInteractionCount(drama.viewCount), ), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, fontSize: 11, ), ), ), const SizedBox(width: 8), Text( l10n.episodeCount(drama.episodeCount), maxLines: 1, style: const TextStyle( color: AppColors.muted, fontSize: 11, ), ), ], ), ], ), ), ], ), ), ), ), ); } }