feat(home): 顶部导航随翻页联动并把推荐更名为精选

This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-30 22:53:15 +08:00
1 parent 2f4593a46e
commit 335e5dc286
27 files changed
+1095 -214

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
// Author: fruit.com.cn
// 职责:首页顶部精选与底部推荐使用不同文案,覆盖支持的三种语言。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/widgets/app_root_tab.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
const _labels = [
('zh', '精选', '推荐', '剧单', '全部短剧'),
('en', 'Featured', 'Recommend', 'Playlists', 'All Dramas'),
('id', 'Unggulan', 'Rekomendasi', 'Daftar Putar', 'Semua'),
];
void main() {
for (final (language, featured, recommend, playlists, all) in _labels) {
testWidgets(
'should_distinguish_featured_from_recommend_when_locale_is_$language',
(tester) async {
await tester.pumpWidget(_host(language));
await tester.pumpAndSettle();
final top = find.byKey(const Key('recommend-top-nav'));
final bottom = find.byKey(const Key('tab-recommend'));
expect(
find.descendant(of: top, matching: find.text(featured)),
findsOneWidget,
);
expect(
find.descendant(of: top, matching: find.text(recommend)),
findsNothing,
);
expect(
find.descendant(of: bottom, matching: find.text(recommend)),
findsOneWidget,
);
expect(
find.descendant(of: top, matching: find.text(playlists)),
findsOneWidget,
);
expect(
find.descendant(of: top, matching: find.text(all)),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
);
}
}
Widget _host(String language) => MaterialApp(
locale: Locale(language),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecommendTopNav(
selected: RecommendInnerTab.feed,
onSelected: (_) {},
onSearch: () {},
),
bottomNavigationBar: AppRootBottomNav(
selected: AppRootTab.home,
onSelected: (_) {},
),
),
);
+44 -2
View File
@@ -13,6 +13,8 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.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';
@@ -50,7 +52,7 @@ void main() {
.toList();
expect(centers, orderedEquals(centers.toList()..sort()));
expect(find.byKey(const Key('recommend-section-hits')), findsNothing);
expect(find.text('Featured'), findsNothing);
expect(find.text('Featured'), findsOneWidget);
expect(find.text('Categories'), findsNothing);
expect(find.text('Charts'), findsNothing);
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
@@ -173,7 +175,7 @@ void main() {
matching: find.text('Playlists'),
),
);
expect(selected.style?.fontWeight, FontWeight.w700);
expect(selected.style?.fontWeight, FontWeight.w400);
expect(
tester.getRect(find.byKey(const Key('recommend-top-nav'))),
topNavBefore,
@@ -191,6 +193,46 @@ void main() {
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
});
testWidgets('should_follow_partial_swipe_before_selected_page_changes', (
tester,
) async {
await _pumpApp(tester, _PagedCatalogRepository());
final pages = find.byKey(const Key('recommend-tab-page-view'));
final gesture = await tester.startGesture(tester.getCenter(pages));
await gesture.moveBy(const Offset(-30, 0));
await tester.pump();
await gesture.moveBy(const Offset(-80, 0));
await tester.pump();
final nav = tester.widget<RecommendTopNav>(find.byType(RecommendTopNav));
expect(nav.pagePosition, inExclusiveRange(0.0, 0.5));
final underline = tester.widget<Container>(
find.byKey(const Key('recommend-tab-playlist-underline')),
);
final color = (underline.decoration! as BoxDecoration).color!;
expect(color.a, inExclusiveRange(0.0, 0.5));
await gesture.up();
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
});
testWidgets(
'should_keep_all_dramas_selected_during_direct_cross_page_click',
(tester) async {
await _pumpApp(tester, _PagedCatalogRepository());
await tester.ensureVisible(find.byKey(const Key('recommend-tab-all')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('recommend-tab-all')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 70));
final nav = tester.widget<RecommendTopNav>(find.byType(RecommendTopNav));
expect(nav.pagePosition, inExclusiveRange(0.5, 1.5));
expect(nav.selected, RecommendInnerTab.allDramas);
await tester.pumpAndSettle();
_drainLayoutExceptions(tester);
expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget);
},
);
testWidgets('should_load_next_page_without_expanding_page_size', (
tester,
) async {
+3 -6
View File
@@ -67,10 +67,7 @@ void main() {
tester.getCenter(searchEntry).dy,
closeTo(tester.getCenter(walletEntry).dy, 0.1),
);
expect(
tester.getTopRight(walletEntry).dx,
closeTo(390 - 8, 0.1),
);
expect(tester.getTopRight(walletEntry).dx, closeTo(390 - 8, 0.1));
expect(
tester.getTopRight(searchEntry).dx,
closeTo(tester.getTopLeft(walletEntry).dx, 0.1),
@@ -160,7 +157,7 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-recommend')),
matching: find.byIcon(Icons.celebration_outlined),
matching: find.byIcon(Icons.smart_display_outlined),
),
findsOneWidget,
);
@@ -191,7 +188,7 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-recommend')),
matching: find.byIcon(Icons.celebration_rounded),
matching: find.byIcon(Icons.smart_display_rounded),
),
findsOneWidget,
);
+24 -13
View File
@@ -198,6 +198,11 @@ void main() {
posterUrl: '',
genre: 'Romance',
category: 'Urban',
types: [
{'en': 'Romance'},
{'en': 'Urban'},
{'en': 'Revenge'},
],
totalEpisodes: 8,
freeEpisodes: 8,
viewCount: 419000,
@@ -255,7 +260,7 @@ void main() {
);
expect(seriesTitle.data, 'Feed Drama');
expect(find.text('Episode 2'), findsNothing);
expect(seriesTitle.style?.fontSize, 17);
expect(seriesTitle.style?.fontSize, 18);
expect(seriesTitle.style?.fontWeight, FontWeight.w400);
expect(seriesTitle.style?.color, AppColors.textPrimary);
final seriesChevron = tester.widget<Icon>(
@@ -284,11 +289,13 @@ void main() {
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tags')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tag-Romance')), findsOneWidget);
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
expect(find.byKey(const Key('home-feed-tag-Urban')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tag-Revenge')), findsNothing);
final collapsedSynopsis = tester.widget<Text>(
find.byKey(const Key('home-feed-synopsis')),
);
expect(collapsedSynopsis.maxLines, 1);
expect(collapsedSynopsis.maxLines, 2);
expect(collapsedSynopsis.overflow, TextOverflow.ellipsis);
expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
@@ -302,8 +309,9 @@ void main() {
.widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded')))
.textSpan!
.toPlainText(),
allOf(contains('EP2'), contains('dangerous city')),
contains('dangerous city'),
);
expect(find.text('Episode 2 / 8'), findsOneWidget);
expect(
tester.widget<Text>(find.byKey(const Key('rail-like-count'))).data,
'1006',
@@ -358,10 +366,10 @@ void main() {
final likeRect = tester.getRect(find.byKey(const Key('rail-like')));
final commentsRect = tester.getRect(find.byKey(const Key('rail-comments')));
final shareRect = tester.getRect(find.byKey(const Key('rail-share')));
// 新版互动栏顺序:收藏 → 评论 → 喜欢 → 分享,间距 12。
expect(commentsRect.top - saveRect.bottom, closeTo(12, 0.1));
expect(likeRect.top - commentsRect.bottom, closeTo(12, 0.1));
expect(shareRect.top - likeRect.bottom, closeTo(12, 0.1));
// 互动栏顺序:收藏 → 评论 → 喜欢 → 分享,紧凑间距 6。
expect(commentsRect.top - saveRect.bottom, closeTo(6, 0.1));
expect(likeRect.top - commentsRect.bottom, closeTo(6, 0.1));
expect(shareRect.top - likeRect.bottom, closeTo(6, 0.1));
expect(
tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right,
closeTo(
@@ -582,9 +590,13 @@ void main() {
final overlayRect = tester.getRect(
find.byKey(const Key('home-feed-gesture')),
);
// 观看条与底部进度条共用同一组左右边距(各 20pt),宽度一致。
// 观看条与左侧信息等宽,进度条仍横跨整个页面,避开右侧互动栏。
expect(bar.left, closeTo(overlayRect.left + 20, 0.1));
expect(bar.width, closeTo(overlayRect.width - 40, 0.1));
final infoRect = tester.getRect(
find.byKey(const Key('home-feed-info-panel')),
);
expect(bar.width, closeTo(infoRect.width, 0.1));
expect(bar.right, closeTo(infoRect.right, 0.1));
expect(
bar.left,
closeTo(
@@ -594,9 +606,8 @@ void main() {
);
expect(
bar.width,
closeTo(
lessThan(
tester.getRect(find.byKey(const Key('home-feed-progress'))).width,
0.1,
),
);
// 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。
@@ -609,10 +620,10 @@ void main() {
tester
.widget<Material>(find.byKey(const Key('home-feed-full-series-bg')))
.color,
AppColors.textPrimary.withValues(alpha: 0.15),
Colors.transparent,
);
expect(
tester.getRect(find.textContaining('Watch full series')).center.dy,
tester.getRect(find.text('Watch all')).center.dy,
closeTo(bar.center.dy, 2),
);
expect(
+149
View File
@@ -0,0 +1,149 @@
// Author: fruit.com.cn
// 职责:验证三种顶部导航接入稳定文字过渡并保留点击与选中语义。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/widgets/animated_nav_label.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/widgets/home_grid_header.dart';
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_channel_nav.dart';
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
const _midpoint = Duration(milliseconds: 90);
const _duration = Duration(milliseconds: 180);
const _categories = [
ContentCategory(id: 'featured', name: '精选'),
ContentCategory(id: 'new', name: '新剧'),
];
const _shortcuts = [
HomeGridCategoryShortcut(
label: '精选',
categoryName: 'featured',
categoryId: 'featured',
tab: HomePrimaryTab.featured,
),
HomeGridCategoryShortcut(
label: '新剧',
categoryName: 'new',
categoryId: 'new',
tab: HomePrimaryTab.newReleases,
),
];
Widget _host(Widget child) => MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
);
double _scale(WidgetTester tester, Finder label) {
return tester
.widget<Transform>(
find.descendant(of: label, matching: find.byType(Transform)),
)
.transform
.entry(0, 0);
}
Future<void> _verifyTransition(
WidgetTester tester,
Widget Function(bool) build,
) async {
await tester.pumpWidget(_host(build(true)));
await tester.pumpAndSettle();
final labels = find.byType(AnimatedNavLabel);
final rects = [tester.getRect(labels.at(0)), tester.getRect(labels.at(1))];
await tester.pumpWidget(_host(build(false)));
await tester.pump(_midpoint);
expect(_scale(tester, labels.at(0)), inExclusiveRange(0.9, 1.0));
expect(_scale(tester, labels.at(1)), inExclusiveRange(0.9, 1.0));
expect(tester.getRect(labels.at(0)), rects[0]);
expect(tester.getRect(labels.at(1)), rects[1]);
await tester.pump(_duration);
expect(tester.getRect(labels.at(1)), rects[1]);
final semantics = tester.widget<Semantics>(
find.descendant(of: labels.at(1), matching: find.byType(Semantics)).first,
);
expect(semantics.properties.selected, isTrue);
}
void main() {
testWidgets('should_keep_channel_labels_regular_when_selection_changes', (
tester,
) async {
const categories = [
ContentCategory(id: 'featured', name: '精选'),
ContentCategory(id: 'weekly', name: '本周必看'),
];
for (final selected in categories) {
await tester.pumpWidget(
_host(
RecommendChannelNav(
categories: categories,
selectedId: selected.id,
onSelected: (_) {},
onSearch: () {},
),
),
);
await tester.pumpAndSettle();
for (final category in categories) {
final text = tester.widget<Text>(find.text(category.name));
expect(text.style?.fontWeight, FontWeight.w400);
expect(text.style?.fontSize, 17);
}
expect(tester.takeException(), isNull);
}
});
testWidgets('should_keep_home_labels_stable_when_category_changes', (
tester,
) async {
int? tapped;
HomeGridHeader header(bool first) => HomeGridHeader(
callbacks: HomeGridCallbacks(
onSearch: () {},
onOpenVip: () {},
onOpenWallet: () {},
onOpenFilter: () {},
),
categories: _shortcuts,
selectedIndex: first ? 0 : 1,
onSectionSelected: (index) => tapped = index,
);
await _verifyTransition(tester, header);
await tester.tap(find.byKey(const Key('home-primary-label-new')));
expect(tapped, 1);
});
testWidgets('should_keep_inner_nav_labels_stable_when_tab_changes', (
tester,
) async {
RecommendInnerTab? tapped;
RecommendTopNav nav(bool first) => RecommendTopNav(
selected: first ? RecommendInnerTab.feed : RecommendInnerTab.playlists,
onSelected: (tab) => tapped = tab,
onSearch: () {},
);
await _verifyTransition(tester, nav);
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
expect(tapped, RecommendInnerTab.playlists);
});
testWidgets('should_keep_channel_labels_stable_when_selection_changes', (
tester,
) async {
String? tapped;
RecommendChannelNav nav(bool first) => RecommendChannelNav(
categories: _categories,
selectedId: first ? 'featured' : 'new',
onSelected: (id) => tapped = id,
onSearch: () {},
);
await _verifyTransition(tester, nav);
await tester.tap(find.byKey(const Key('recommend-tab-new')));
expect(tapped, 'new');
});
}