diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eb5a4d0..e63895a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -507,5 +507,13 @@ "ledgerUnlock": "Episode unlock", "ledgerRecharge": "Coin top-up", "ledgerRefund": "Refund", - "ledgerAdjustment": "Adjustment" + "ledgerAdjustment": "Adjustment", + "rankingTab": "Charts", + "rankingTitle": "Top Charts", + "rankingSubtitle": "Ranked from today's live catalog feed.", + "rankingHot": "Trending", + "rankingNew": "New releases", + "rankingEmptyTitle": "No ranked stories yet", + "rankingEmptyBody": "Titles appear here as soon as they are published.", + "rankingTopPick": "TOP PICK" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4efa7a5..8a84486 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -505,5 +505,13 @@ "ledgerUnlock": "Buka episode", "ledgerRecharge": "Isi ulang koin", "ledgerRefund": "Pengembalian", - "ledgerAdjustment": "Penyesuaian" + "ledgerAdjustment": "Penyesuaian", + "rankingTab": "Peringkat", + "rankingTitle": "Peringkat Teratas", + "rankingSubtitle": "Diurutkan dari feed katalog terkini.", + "rankingHot": "Populer", + "rankingNew": "Rilis baru", + "rankingEmptyTitle": "Belum ada peringkat", + "rankingEmptyBody": "Judul muncul di sini setelah diterbitkan.", + "rankingTopPick": "PILIHAN UTAMA" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b0f0c4f..18215cf 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2295,6 +2295,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Adjustment'** String get ledgerAdjustment; + + /// No description provided for @rankingTab. + /// + /// In en, this message translates to: + /// **'Charts'** + String get rankingTab; + + /// No description provided for @rankingTitle. + /// + /// In en, this message translates to: + /// **'Top Charts'** + String get rankingTitle; + + /// No description provided for @rankingSubtitle. + /// + /// In en, this message translates to: + /// **'Ranked from today\'s live catalog feed.'** + String get rankingSubtitle; + + /// No description provided for @rankingHot. + /// + /// In en, this message translates to: + /// **'Trending'** + String get rankingHot; + + /// No description provided for @rankingNew. + /// + /// In en, this message translates to: + /// **'New releases'** + String get rankingNew; + + /// No description provided for @rankingEmptyTitle. + /// + /// In en, this message translates to: + /// **'No ranked stories yet'** + String get rankingEmptyTitle; + + /// No description provided for @rankingEmptyBody. + /// + /// In en, this message translates to: + /// **'Titles appear here as soon as they are published.'** + String get rankingEmptyBody; + + /// No description provided for @rankingTopPick. + /// + /// In en, this message translates to: + /// **'TOP PICK'** + String get rankingTopPick; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2682cea..24f9aa8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1174,4 +1174,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get ledgerAdjustment => 'Adjustment'; + + @override + String get rankingTab => 'Charts'; + + @override + String get rankingTitle => 'Top Charts'; + + @override + String get rankingSubtitle => 'Ranked from today\'s live catalog feed.'; + + @override + String get rankingHot => 'Trending'; + + @override + String get rankingNew => 'New releases'; + + @override + String get rankingEmptyTitle => 'No ranked stories yet'; + + @override + String get rankingEmptyBody => + 'Titles appear here as soon as they are published.'; + + @override + String get rankingTopPick => 'TOP PICK'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 66cc4bc..d45a22e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1173,4 +1173,28 @@ class AppLocalizationsId extends AppLocalizations { @override String get ledgerAdjustment => 'Penyesuaian'; + + @override + String get rankingTab => 'Peringkat'; + + @override + String get rankingTitle => 'Peringkat Teratas'; + + @override + String get rankingSubtitle => 'Diurutkan dari feed katalog terkini.'; + + @override + String get rankingHot => 'Populer'; + + @override + String get rankingNew => 'Rilis baru'; + + @override + String get rankingEmptyTitle => 'Belum ada peringkat'; + + @override + String get rankingEmptyBody => 'Judul muncul di sini setelah diterbitkan.'; + + @override + String get rankingTopPick => 'PILIHAN UTAMA'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index db49ae5..d384d2b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1152,4 +1152,28 @@ class AppLocalizationsZh extends AppLocalizations { @override String get ledgerAdjustment => '余额调整'; + + @override + String get rankingTab => '榜单'; + + @override + String get rankingTitle => '热门榜单'; + + @override + String get rankingSubtitle => '依据目录实时分区排序。'; + + @override + String get rankingHot => '热播'; + + @override + String get rankingNew => '新剧'; + + @override + String get rankingEmptyTitle => '暂无榜单内容'; + + @override + String get rankingEmptyBody => '新剧上线后会自动进入榜单。'; + + @override + String get rankingTopPick => '今日榜首'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5b45d6c..60b8761 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -467,5 +467,13 @@ "ledgerUnlock": "剧集解锁", "ledgerRecharge": "金币充值", "ledgerRefund": "退款", - "ledgerAdjustment": "余额调整" + "ledgerAdjustment": "余额调整", + "rankingTab": "榜单", + "rankingTitle": "热门榜单", + "rankingSubtitle": "依据目录实时分区排序。", + "rankingHot": "热播", + "rankingNew": "新剧", + "rankingEmptyTitle": "暂无榜单内容", + "rankingEmptyBody": "新剧上线后会自动进入榜单。", + "rankingTopPick": "今日榜首" } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 9a45785..9dddeb5 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/download_controller.dart'; @@ -24,6 +25,7 @@ import '../discover/discover_view.dart'; import '../splash/splash_page.dart'; import '../mine/mine_view.dart'; import '../player/player_view.dart'; +import '../ranking/ranking_view.dart'; import 'home_binding.dart'; import 'home_logic.dart'; import 'widgets/home_menu_sheet.dart'; @@ -79,6 +81,7 @@ class HomePage extends StatelessWidget { return _HomeTabs( key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, + sections: catalog.sections, ); } } @@ -136,9 +139,10 @@ class HomeFailureView extends StatelessWidget { /// The three primary destinations share a single route and keep their state. class _HomeTabs extends StatefulWidget { - const _HomeTabs({super.key, required this.dramas}); + const _HomeTabs({super.key, required this.dramas, required this.sections}); final List dramas; + final List sections; @override State<_HomeTabs> createState() => _HomeTabsState(); @@ -235,6 +239,13 @@ class _HomeTabsState extends State<_HomeTabs> { onSignOut: () => context.read().signOut(), wallet: context.read(), ), + RankingPage( + sections: widget.sections, + fallback: widget.dramas, + onOpen: (drama) => logic.openDrama(context, drama), + embedded: true, + active: logic.tab == 3, + ), ], ), bottomNavigationBar: logic.videoImmersive @@ -258,6 +269,14 @@ class _HomeTabsState extends State<_HomeTabs> { selected: logic.tab == 1, onTap: () => _selectTab(context, 1), ), + AppBottomNavItem( + tabKey: const Key('tab-ranking'), + icon: Icons.leaderboard_outlined, + selectedIcon: Icons.leaderboard_rounded, + label: l10n.rankingTab, + selected: logic.tab == 3, + onTap: () => _selectTab(context, 3), + ), AppBottomNavItem( tabKey: const Key('tab-my'), icon: Icons.video_library_outlined, diff --git a/lib/pages/ranking/ranking_logic.dart b/lib/pages/ranking/ranking_logic.dart new file mode 100644 index 0000000..5312e30 --- /dev/null +++ b/lib/pages/ranking/ranking_logic.dart @@ -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 sections; + List fallback; + RankingBoard _board = RankingBoard.hot; + + RankingBoard get board => _board; + + List get entries { + final sectionCode = _board == RankingBoard.hot + ? hotSectionCode + : newSectionCode; + final source = _sectionDramas(sectionCode); + final dramas = source.isEmpty ? fallback : source; + final seen = {}; + 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 sections, + required List fallback, + }) { + this.sections = sections; + this.fallback = fallback; + notifyListeners(); + } + + List _sectionDramas(String code) { + for (final section in sections) { + if (section.code == code) return section.dramas; + } + return const []; + } +} diff --git a/lib/pages/ranking/ranking_view.dart b/lib/pages/ranking/ranking_view.dart new file mode 100644 index 0000000..25fcf0d --- /dev/null +++ b/lib/pages/ranking/ranking_view.dart @@ -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 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'; + } +} diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 0c9d15a..9ae1b1c 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -20,6 +20,7 @@ const _keys = [ Key('home-tabs-bar'), Key('tab-home'), Key('tab-discover'), + Key('tab-ranking'), Key('tab-my'), ]; @@ -33,7 +34,7 @@ void main() { }); }); - testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', ( + testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', ( tester, ) async { final haptics = FakeHaptics(); @@ -95,11 +96,14 @@ void main() { ); }); - testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { + testWidgets('should_map_four_tabs_to_home_discover_ranking_and_me', ( + tester, + ) async { await _pumpApp(tester); expect(find.byKey(const Key('tab-home')), findsOneWidget); expect(find.byKey(const Key('tab-discover')), findsOneWidget); + expect(find.byKey(const Key('tab-ranking')), findsOneWidget); expect(find.byKey(const Key('tab-my')), findsOneWidget); expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); @@ -107,6 +111,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-ranking'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); expect(find.text('Viewer'), findsOneWidget); diff --git a/test/ranking/ranking_logic_test.dart b/test/ranking/ranking_logic_test.dart new file mode 100644 index 0000000..8d1f935 --- /dev/null +++ b/test/ranking/ranking_logic_test.dart @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/pages/ranking/ranking_logic.dart'; + +void main() { + test('should_use_backend_hot_order_when_hot_board_is_selected', () { + final logic = RankingLogic( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [_drama('first'), _drama('second')], + ), + FeedSection(code: 'new', title: 'New', dramas: [_drama('fresh')]), + ], + fallback: [_drama('fallback')], + ); + + expect(logic.board, RankingBoard.hot); + expect(logic.entries.map((entry) => entry.drama.id), ['first', 'second']); + expect(logic.entries.map((entry) => entry.rank), [1, 2]); + logic.dispose(); + }); + + test('should_switch_to_new_releases_when_new_board_is_selected', () { + final logic = RankingLogic( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot')]), + FeedSection(code: 'new', title: 'New', dramas: [_drama('fresh')]), + ], + fallback: const [], + ); + + logic.select(RankingBoard.newReleases); + + expect(logic.board, RankingBoard.newReleases); + expect(logic.entries.single.drama.id, 'fresh'); + logic.dispose(); + }); + + test( + 'should_deduplicate_and_fall_back_when_requested_section_is_missing', + () { + final logic = RankingLogic( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [_drama('a'), _drama('a')], + ), + ], + fallback: [_drama('b'), _drama('a')], + ); + + expect(logic.entries.map((entry) => entry.drama.id), ['a']); + + logic.select(RankingBoard.newReleases); + + expect(logic.entries.map((entry) => entry.drama.id), ['b', 'a']); + expect(logic.entries.last.rank, 2); + logic.dispose(); + }, + ); +} + +Drama _drama(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'Synopsis $id', + posterUrl: '/files/$id.jpg', +); diff --git a/test/ranking/ranking_view_test.dart b/test/ranking/ranking_view_test.dart new file mode 100644 index 0000000..7c226a8 --- /dev/null +++ b/test/ranking/ranking_view_test.dart @@ -0,0 +1,88 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({}); + }); + + testWidgets('should_render_real_section_order_when_ranking_tab_is_opened', ( + tester, + ) async { + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('tab-ranking'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget); + expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget); + expect( + find.byKey(const Key('ranking-entry-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('ranking-entry-city-lights-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('ranking-tab-new'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('ranking-entry-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets('should_open_the_selected_drama_when_ranking_entry_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-ranking'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('ranking-entry-city-lights-demo'))); + await tester.pumpAndSettle(); + + expect(find.byType(DramaDetailPage), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + }); +} + +Future _pumpApp(WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +}