feat(app): 增加首页榜单 tab

This commit is contained in:
Zhou Weidong committed 2026-10-19 23:50:03 +08:00
1 parent 136ee74618
commit 2b95c79931
13 files changed
+859 -6

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
/// Ranking boards backed by the feed sections the server already returns.
enum RankingBoard { hot, newReleases }
/// One ranked title; rank is one-based and mirrors server section order.
class RankingEntry {
const RankingEntry({required this.rank, required this.drama});
final int rank;
final Drama drama;
}
/// Keeps the active board and turns real feed sections into ranked entries.
///
/// The backend does not expose a dedicated ranking endpoint, so this class
/// never invents scores: it preserves the order of the `hot` and `new`
/// sections and only falls back to the catalog when a section is absent.
class RankingLogic extends ChangeNotifier {
RankingLogic({required this.sections, required this.fallback});
static const hotSectionCode = 'hot';
static const newSectionCode = 'new';
List<FeedSection> sections;
List<Drama> fallback;
RankingBoard _board = RankingBoard.hot;
RankingBoard get board => _board;
List<RankingEntry> get entries {
final sectionCode = _board == RankingBoard.hot
? hotSectionCode
: newSectionCode;
final source = _sectionDramas(sectionCode);
final dramas = source.isEmpty ? fallback : source;
final seen = <String>{};
final unique = [
for (final drama in dramas)
if (seen.add(drama.id)) drama,
];
return [
for (var index = 0; index < unique.length; index++)
RankingEntry(rank: index + 1, drama: unique[index]),
];
}
/// Switches boards and notifies only when the visible source changes.
void select(RankingBoard next) {
if (_board == next) return;
_board = next;
notifyListeners();
}
/// Applies refreshed feed data without rebuilding the page state.
void updateSources({
required List<FeedSection> sections,
required List<Drama> fallback,
}) {
this.sections = sections;
this.fallback = fallback;
notifyListeners();
}
List<Drama> _sectionDramas(String code) {
for (final section in sections) {
if (section.code == code) return section.dramas;
}
return const [];
}
}
+443
View File
@@ -0,0 +1,443 @@
// 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<FeedSection> sections;
final List<Drama> fallback;
final ValueChanged<Drama> onOpen;
final bool embedded;
final bool active;
@override
State<RankingPage> createState() => _RankingPageState();
}
class _RankingPageState extends State<RankingPage> {
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';
}
}