// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/home_feed.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'ranking_logic.dart'; const double _pageHeaderHeight = 62; const double _selectorHeight = 48; const double _championHeight = 214; const double _posterWidth = 62; const double _posterHeight = 84; const double _rankWidth = 34; const int _topThreeLimit = 3; /// Full-screen charts page driven by the real `hot` and `new` feed sections. class RankingPage extends StatefulWidget { const RankingPage({ super.key, required this.sections, required this.fallback, required this.onOpen, this.embedded = false, this.active = true, }); final List sections; final List fallback; final ValueChanged onOpen; final bool embedded; final bool active; @override State createState() => _RankingPageState(); } class _RankingPageState extends State { late final RankingLogic logic = RankingLogic( sections: widget.sections, fallback: widget.fallback, ); @override void didUpdateWidget(covariant RankingPage oldWidget) { super.didUpdateWidget(oldWidget); if (!identical(oldWidget.sections, widget.sections) || !identical(oldWidget.fallback, widget.fallback)) { logic.updateSources(sections: widget.sections, fallback: widget.fallback); } } @override void dispose() { logic.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ListenableBuilder( listenable: logic, builder: (context, _) => _page(context), ); Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final content = SafeArea( bottom: !widget.embedded, child: Container( key: const Key('ranking-page'), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF17131A), AppColors.ink], stops: [0, 0.42], ), ), child: Column( children: [ _header(l10n), _boardSelector(l10n), const SizedBox(height: AppDimens.spaceMd), Expanded(child: _entries(l10n)), ], ), ), ); return Theme( data: AppTheme.dark(), child: widget.embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), ); } Widget _header(AppLocalizations l10n) => SizedBox( height: _pageHeaderHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), child: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: AppColors.blue.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.blue.withValues(alpha: 0.3)), ), child: const Icon( Icons.leaderboard_rounded, color: AppColors.blue, size: 20, ), ), const SizedBox(width: AppDimens.spaceMd), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.rankingTitle, style: const TextStyle( color: AppColors.onSurface, fontSize: AppDimens.pageTitle, fontWeight: FontWeight.w800, letterSpacing: -0.4, ), ), Text( l10n.rankingSubtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.subtle, fontSize: AppDimens.caption, ), ), ], ), ), ], ), ), ); Widget _boardSelector(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), child: Container( height: _selectorHeight, padding: const EdgeInsets.all(AppDimens.spaceXs), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(AppDimens.pillRadius), border: Border.all(color: AppColors.border), ), child: Row( children: [ _boardButton( key: const Key('ranking-tab-hot'), label: l10n.rankingHot, board: RankingBoard.hot, ), _boardButton( key: const Key('ranking-tab-new'), label: l10n.rankingNew, board: RankingBoard.newReleases, ), ], ), ), ); } Widget _boardButton({ required Key key, required String label, required RankingBoard board, }) { final selected = logic.board == board; return Expanded( child: Material( color: Colors.transparent, child: InkWell( key: key, borderRadius: BorderRadius.circular(AppDimens.pillRadius), onTap: () => logic.select(board), child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, alignment: Alignment.center, decoration: BoxDecoration( color: selected ? AppColors.blue : Colors.transparent, borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Text( label, style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.muted, fontSize: AppDimens.body, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, ), ), ), ), ), ); } Widget _entries(AppLocalizations l10n) { final entries = logic.entries; if (entries.isEmpty) { return EmptyState( key: const Key('ranking-empty'), icon: Icons.leaderboard_outlined, title: l10n.rankingEmptyTitle, subtitle: l10n.rankingEmptyBody, ); } return ListView.separated( key: const Key('ranking-list'), padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), physics: const AlwaysScrollableScrollPhysics(), itemCount: entries.length, separatorBuilder: (_, _) => const SizedBox(height: AppDimens.spaceSm), itemBuilder: (context, index) { final entry = entries[index]; if (index == 0) return _champion(entry, l10n); return _rankRow(entry, l10n); }, ); } Widget _champion(RankingEntry entry, AppLocalizations l10n) { final drama = entry.drama; return SizedBox( key: Key('ranking-entry-${drama.id}'), height: _championHeight, child: Material( color: AppColors.card, borderRadius: BorderRadius.circular(AppDimens.authCardRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => widget.onOpen(drama), child: Stack( fit: StackFit.expand, children: [ RemoteImage(drama.posterUrl), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x22000000), Color(0xF20B0C10)], stops: [0.15, 1], ), ), ), Positioned( top: AppDimens.spaceMd, left: AppDimens.spaceMd, child: _rankBadge(entry.rank, large: true), ), Positioned( top: AppDimens.spaceMd, right: AppDimens.spaceMd, child: _topPickPill(l10n.rankingTopPick), ), Positioned( left: AppDimens.spaceLg, right: AppDimens.spaceLg, bottom: AppDimens.spaceLg, child: _championCopy(drama, l10n), ), ], ), ), ), ); } Widget _championCopy(Drama drama, AppLocalizations l10n) => Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( drama.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 22, fontWeight: FontWeight.w800, height: 1.15, letterSpacing: -0.4, ), ), const SizedBox(height: AppDimens.spaceSm), Row( children: [ Expanded( child: Text( _subtitle(drama, l10n), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: AppColors.muted, fontSize: 12), ), ), const Icon(Icons.play_circle_fill_rounded, color: AppColors.blue), ], ), ], ); Widget _rankRow(RankingEntry entry, AppLocalizations l10n) { final drama = entry.drama; return Material( key: Key('ranking-entry-${drama.id}'), color: AppColors.card.withValues(alpha: 0.82), borderRadius: BorderRadius.circular(AppDimens.glassRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => widget.onOpen(drama), child: SizedBox( height: 104, child: Padding( padding: const EdgeInsets.all(AppDimens.spaceMd), child: Row( children: [ SizedBox( width: _rankWidth, child: Center(child: _rankBadge(entry.rank)), ), const SizedBox(width: AppDimens.spaceSm), ClipRRect( borderRadius: BorderRadius.circular(AppDimens.coverRadius), child: SizedBox( width: _posterWidth, height: _posterHeight, child: RemoteImage(drama.posterUrl), ), ), const SizedBox(width: AppDimens.spaceMd), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( drama.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: AppDimens.listTitle, fontWeight: FontWeight.w700, height: 1.25, ), ), const SizedBox(height: AppDimens.spaceXs), Text( _subtitle(drama, l10n), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.subtle, fontSize: AppDimens.caption, ), ), ], ), ), const Icon( Icons.chevron_right_rounded, color: AppColors.subtle, size: 20, ), ], ), ), ), ), ); } Widget _rankBadge(int rank, {bool large = false}) { final top = rank <= _topThreeLimit; return Container( width: large ? 44 : 30, height: large ? 34 : 30, alignment: Alignment.center, decoration: BoxDecoration( color: top ? AppColors.gold.withValues(alpha: large ? 0.95 : 0.12) : AppColors.raised, borderRadius: BorderRadius.circular(large ? 11 : 9), border: top && !large ? Border.all(color: AppColors.gold.withValues(alpha: 0.35)) : null, ), child: Text( '$rank', style: TextStyle( color: top && large ? AppColors.onGold : AppColors.gold, fontSize: large ? 17 : 13, fontWeight: FontWeight.w900, ), ), ); } Widget _topPickPill(String label) => Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: AppColors.blue.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Text( label, style: const TextStyle( color: AppColors.onPrimary, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.8, ), ), ); String _subtitle(Drama drama, AppLocalizations l10n) { final genre = drama.genre.isNotEmpty ? drama.genre : drama.category; final count = l10n.episodeCount(drama.episodeCount); return genre.isEmpty ? count : '$genre ยท $count'; } }