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

+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+48
View File
@@ -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
+25
View File
@@ -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';
}
+24
View File
@@ -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';
}
+24
View File
@@ -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 => '今日榜首';
}
+9 -1
View File
@@ -467,5 +467,13 @@
"ledgerUnlock": "剧集解锁",
"ledgerRecharge": "金币充值",
"ledgerRefund": "退款",
"ledgerAdjustment": "余额调整"
"ledgerAdjustment": "余额调整",
"rankingTab": "榜单",
"rankingTitle": "热门榜单",
"rankingSubtitle": "依据目录实时分区排序。",
"rankingHot": "热播",
"rankingNew": "新剧",
"rankingEmptyTitle": "暂无榜单内容",
"rankingEmptyBody": "新剧上线后会自动进入榜单。",
"rankingTopPick": "今日榜首"
}
+20 -1
View File
@@ -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<Drama> dramas;
final List<FeedSection> sections;
@override
State<_HomeTabs> createState() => _HomeTabsState();
@@ -235,6 +239,13 @@ class _HomeTabsState extends State<_HomeTabs> {
onSignOut: () => context.read<AuthController>().signOut(),
wallet: context.read<WalletController>(),
),
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,
+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';
}
}
+10 -2
View File
@@ -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);
+74
View File
@@ -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',
);
+88
View File
@@ -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<void> _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();
}