fix(home): 修正首页分类与卡片布局

This commit is contained in:
Zhou Weidong committed 2026-10-22 18:14:33 +08:00
1 parent 10477562d4
commit aa4870cce6
14 files changed
+446 -332

No files matched your search

+7
View File
@@ -1108,3 +1108,10 @@ $ flutter test
解读:首页搜索/页签/二级标签、三类卡片字体与标签、5:6 封面、网格间距、 解读:首页搜索/页签/二级标签、三类卡片字体与标签、5:6 封面、网格间距、
筛选 chip、我的标题和追剧双列 320 宽度适配均有测试覆盖。目标组件均低于 筛选 chip、我的标题和追剧双列 320 宽度适配均有测试覆盖。目标组件均低于
500 行;`pubspec.lock` 未提交,未操作真机。 500 行;`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 首页布局对齐修正
- 一级分类改为页内切换 6 个列表,选中 18/w700 白字、未选中 15/w400 灰字,无下划线。
- 二级 chips 与一级行左对齐可滑动;未选项纯文字,选中项深灰胶囊。
- 首页、剧单、全部短剧、追剧卡片移除底板/描边,标题与题材从封面左边起始,题材改为 `|` 分隔纯文字。
- 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。
+8 -2
View File
@@ -98,9 +98,15 @@ abstract final class AppDimens {
static const double cardTitleTagGap = 6; static const double cardTitleTagGap = 6;
static const double cardCoverAspectRatio = 5 / 6; static const double cardCoverAspectRatio = 5 / 6;
static const double homeCardBodyExtent = 84; static const double homeCardBodyExtent = 84;
static const double homePrimaryTabFont = 16; static const double homePrimaryTabFont = 15;
static const double homePrimaryTabSelectedFont = 18;
static const double homePrimaryRowHeight = 30;
static const Duration homePrimaryTabAnimationDuration = Duration(
milliseconds: 180,
);
static const double homeSecondaryTagFont = 13; static const double homeSecondaryTagFont = 13;
static const double homeSectionIndicator = 2; static const double homePrimaryCategoryGap = 24;
static const double homeSecondaryTagGap = 18;
static const double homeIconSlot = 44; static const double homeIconSlot = 44;
static const double homeHeaderIcon = 22; static const double homeHeaderIcon = 22;
+7 -27
View File
@@ -41,6 +41,7 @@ class FollowingGridCard extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
AspectRatio( AspectRatio(
key: Key('following-cover-$dramaId'), key: Key('following-cover-$dramaId'),
@@ -119,6 +120,7 @@ class FollowingGridCard extends StatelessWidget {
title, title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: AppDimens.cardTitleFont, fontSize: AppDimens.cardTitleFont,
@@ -161,39 +163,17 @@ class FollowingGridCard extends StatelessWidget {
} }
Widget _tags() { Widget _tags() {
final visible = tags.take(2).toList(growable: false); final visible = tags.take(3).toList(growable: false);
return Row( return Text(
children: [ visible.join(' | '),
for (final (index, tag) in visible.indexed) key: Key('following-tags-$dramaId'),
Flexible(
child: Padding(
padding: EdgeInsets.only(
right: index == visible.length - 1 ? 0 : AppDimens.spaceXs,
),
child: Container(
key: Key('following-tag-$dramaId-$index'),
height: AppDimens.cardTagHeight,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.cardTagHorizontalPadding,
),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.cardTagRadius),
),
child: Text(
tag,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.textTertiary, color: AppColors.textTertiary,
fontSize: AppDimens.cardTagFont, fontSize: AppDimens.cardTagFont,
), ),
),
),
),
),
],
); );
} }
} }
+2 -8
View File
@@ -26,8 +26,6 @@ import '../discover/discover_view.dart';
import '../following/following_page.dart'; import '../following/following_page.dart';
import '../filter/filter_page.dart'; import '../filter/filter_page.dart';
import '../mine/mine_types.dart'; import '../mine/mine_types.dart';
import '../new_releases/new_releases_page.dart';
import '../ranking/ranking_page.dart';
import '../recommend/recommend_page.dart'; import '../recommend/recommend_page.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
import '../store/vip_page.dart'; import '../store/vip_page.dart';
@@ -284,9 +282,6 @@ class _HomeTabsState extends State<_HomeTabs> {
onSearch: () => _openSearch(context), onSearch: () => _openSearch(context),
onOpenVip: () => unawaited(_openVip(context)), onOpenVip: () => unawaited(_openVip(context)),
onOpenWallet: () => unawaited(_openWallet(context)), onOpenWallet: () => unawaited(_openWallet(context)),
onOpenNewReleases: () => _pushPage(context, const NewReleasesPage()),
onOpenCategories: () => _pushPage(context, const FilterPage()),
onOpenRanking: () => _pushPage(context, const RankingPage()),
onOpenFilter: () => _pushPage(context, const FilterPage()), onOpenFilter: () => _pushPage(context, const FilterPage()),
), ),
onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)), onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)),
@@ -324,9 +319,8 @@ class _HomeTabsState extends State<_HomeTabs> {
Future<void> _openWallet(BuildContext context) async { Future<void> _openWallet(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return; if (!await ensureSignedIn(context) || !context.mounted) return;
await Navigator.of( await Navigator.of(context)
context, .push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
).push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
} }
Widget _my(BuildContext context) { Widget _my(BuildContext context) {
+13 -38
View File
@@ -29,34 +29,32 @@ class HomeFeedCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Container( return Material(
key: Key('home-feed-card-${drama.id}'), key: Key('home-feed-card-${drama.id}'),
decoration: BoxDecoration(
color: AppColors.contentCard,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
border: Border.all(color: AppColors.contentBorder),
),
child: Material(
color: AppColors.transparent, color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
onTap: onTap, onTap: onTap,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
_cover(l10n), _cover(l10n),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppDimens.homeCardPadding), padding: const EdgeInsets.only(
top: AppDimens.cardCoverTitleGap,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Text( Text(
drama.title, drama.title,
key: Key('home-feed-title-${drama.id}'), key: Key('home-feed-title-${drama.id}'),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.navSelected, color: AppColors.navSelected,
fontSize: AppDimens.cardTitleFont, fontSize: AppDimens.cardTitleFont,
@@ -73,7 +71,6 @@ class HomeFeedCard extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
@@ -180,39 +177,17 @@ class HomeFeedCard extends StatelessWidget {
Widget _tags() { Widget _tags() {
final tags = _cardTags(); final tags = _cardTags();
if (tags.isEmpty) return const SizedBox.shrink(); if (tags.isEmpty) return const SizedBox.shrink();
return Row( return Text(
children: [ tags.join(' | '),
for (final (index, tag) in tags.indexed) key: Key('home-feed-tags-${drama.id}'),
Flexible(
child: Padding(
padding: EdgeInsets.only(
right: index == tags.length - 1 ? 0 : AppDimens.spaceXs,
),
child: Container(
key: Key('home-feed-tag-${drama.id}-$index'),
height: AppDimens.cardTagHeight,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.cardTagHorizontalPadding,
),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.cardTagRadius),
),
child: Text(
tag,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.textTertiary, color: AppColors.textTertiary,
fontSize: AppDimens.cardTagFont, fontSize: AppDimens.cardTagFont,
letterSpacing: 0, letterSpacing: 0,
), ),
),
),
),
),
],
); );
} }
@@ -222,7 +197,7 @@ class HomeFeedCard extends StatelessWidget {
.expand((value) => value.split(_tagSplitter)) .expand((value) => value.split(_tagSplitter))
.map((value) => value.trim()) .map((value) => value.trim())
.where((value) => value.isNotEmpty && seen.add(value)) .where((value) => value.isNotEmpty && seen.add(value))
.take(2) .take(3)
.toList(growable: false); .toList(growable: false);
} }
} }
+122 -87
View File
@@ -42,8 +42,27 @@ class HomeFeedGrid extends StatefulWidget {
} }
class _HomeFeedGridState extends State<HomeFeedGrid> { class _HomeFeedGridState extends State<HomeFeedGrid> {
String? _selectedCategory; HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured;
String? _selectedTag; late final Map<HomePrimaryTab, ScrollController> _scrollControllers;
final Map<HomePrimaryTab, String?> _selectedTags = {
for (final tab in HomePrimaryTab.values) tab: null,
};
@override
void initState() {
super.initState();
_scrollControllers = {
for (final tab in HomePrimaryTab.values) tab: ScrollController(),
};
}
@override
void dispose() {
for (final controller in _scrollControllers.values) {
controller.dispose();
}
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -53,14 +72,10 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
final wallet = context.watch<WalletController>(); final wallet = context.watch<WalletController>();
final shortcuts = _categoryShortcuts(categories, l10n); final shortcuts = _categoryShortcuts(categories, l10n);
final tags = _availableTags(l10n); final tags = _availableTags(l10n);
final activeCategory = final activeTag =
shortcuts.any((shortcut) => shortcut.categoryName == _selectedCategory) tags.any((tag) => tag.id == _selectedTags[_selectedPrimary])
? _selectedCategory ? _selectedTags[_selectedPrimary]
: null; : null;
final activeTag = tags.any((tag) => tag.id == _selectedTag)
? _selectedTag
: null;
final dramas = _filteredDramas(activeCategory, activeTag);
final walletBadge = auth.isSignedIn && wallet.snapshot != null final walletBadge = auth.isSignedIn && wallet.snapshot != null
? wallet.balance ? wallet.balance
: null; : null;
@@ -69,7 +84,45 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
color: AppColors.appBackground, color: AppColors.appBackground,
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: LayoutBuilder( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeGridHeader(
callbacks: widget.callbacks,
categories: shortcuts,
tags: tags,
selectedPrimary: _selectedPrimary,
selectedTag: activeTag,
onPrimarySelected: (tab) =>
setState(() => _selectedPrimary = tab),
onTagSelected: (tag) =>
setState(() => _selectedTags[_selectedPrimary] = tag),
walletBadge: walletBadge,
),
Expanded(
child: IndexedStack(
index: _selectedPrimary.index,
children: [
for (final tab in HomePrimaryTab.values)
_tabGrid(context, l10n, tab: tab, shortcuts: shortcuts),
],
),
),
],
),
),
);
}
Widget _tabGrid(
BuildContext context,
AppLocalizations l10n, {
required HomePrimaryTab tab,
required List<HomeGridCategoryShortcut> shortcuts,
}) {
final tag = _selectedTags[tab];
final dramas = _dramasFor(tab, tag, shortcuts);
return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final itemWidth = final itemWidth =
(constraints.maxWidth - (constraints.maxWidth -
@@ -84,21 +137,9 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
onRefresh: _refresh, onRefresh: _refresh,
child: CustomScrollView( child: CustomScrollView(
key: const Key('home-grid-scroll'), key: const Key('home-grid-scroll'),
controller: _scrollControllers[tab],
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
slivers: [ slivers: [
SliverToBoxAdapter(
child: HomeGridHeader(
callbacks: widget.callbacks,
categories: shortcuts,
tags: tags,
selectedCategory: activeCategory,
selectedTag: activeTag,
onCategorySelected: _selectCategory,
onTagSelected: (tag) =>
setState(() => _selectedTag = tag),
walletBadge: walletBadge,
),
),
if (widget.feedController.loadingFirstPage) if (widget.feedController.loadingFirstPage)
const SliverFillRemaining( const SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
@@ -112,7 +153,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
else if (dramas.isEmpty) else if (dramas.isEmpty)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: _emptyState(l10n, activeCategory, activeTag), child: _emptyState(l10n, tab, tag),
) )
else else
SliverPadding( SliverPadding(
@@ -121,8 +162,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
), ),
sliver: SliverGrid( sliver: SliverGrid(
key: const Key('home-grid-cards'), key: const Key('home-grid-cards'),
gridDelegate: gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: 3,
crossAxisSpacing: AppDimens.homeGridGap, crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridRowGap, mainAxisSpacing: AppDimens.homeGridRowGap,
@@ -140,15 +180,12 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
if (dramas.isNotEmpty) if (dramas.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
bottom: AppDimens.spaceXl,
),
child: PagedListFooter( child: PagedListFooter(
loading: widget.feedController.loadingMore, loading: widget.feedController.loadingMore,
hasMore: widget.feedController.hasMore, hasMore: widget.feedController.hasMore,
hasItems: dramas.isNotEmpty, hasItems: dramas.isNotEmpty,
errorMessage: errorMessage: widget.feedController.lastError == null
widget.feedController.lastError == null
? null ? null
: l10n.catalogError, : l10n.catalogError,
onRetry: widget.feedController.loadMore, onRetry: widget.feedController.loadMore,
@@ -160,8 +197,6 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
), ),
); );
}, },
),
),
); );
} }
@@ -182,37 +217,24 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
return false; return false;
} }
void _selectCategory(String? category) {
setState(() {
_selectedCategory = category;
_selectedTag = null;
});
}
List<HomeGridCategoryShortcut> _categoryShortcuts( List<HomeGridCategoryShortcut> _categoryShortcuts(
List<ContentCategory> categories, List<ContentCategory> categories,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
final shortcuts = <HomeGridCategoryShortcut>[];
final costume = _findCategory(categories, _costumeAliases); final costume = _findCategory(categories, _costumeAliases);
final community = _findCategory(categories, _communityAliases); final community = _findCategory(categories, _communityAliases);
if (costume != null) { return [
shortcuts.add(
HomeGridCategoryShortcut( HomeGridCategoryShortcut(
label: l10n.homeSectionCostume, label: l10n.homeSectionCostume,
categoryName: costume.name, categoryName: costume?.name ?? '',
tab: HomePrimaryTab.costume,
), ),
);
}
if (community != null) {
shortcuts.add(
HomeGridCategoryShortcut( HomeGridCategoryShortcut(
label: l10n.homeSectionCommunity, label: l10n.homeSectionCommunity,
categoryName: community.name, categoryName: community?.name ?? '',
tab: HomePrimaryTab.community,
), ),
); ];
}
return shortcuts;
} }
ContentCategory? _findCategory( ContentCategory? _findCategory(
@@ -227,46 +249,59 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
} }
List<HomeGridTagOption> _availableTags(AppLocalizations l10n) { List<HomeGridTagOption> _availableTags(AppLocalizations l10n) {
final definitions = [ // UI 先完整展示筛选维度;后续接口补齐题材字段时只替换数据来源。
return [
HomeGridTagOption(id: 'hot', label: l10n.homeTagHot), HomeGridTagOption(id: 'hot', label: l10n.homeTagHot),
HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting), HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting),
HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet), HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet),
HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap), HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap),
HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst), HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst),
]; ];
final categoryText = context
.read<CategoryController>()
.categories
.map((category) => category.name)
.toList(growable: false);
return definitions
.where((option) {
if (option.id == 'hot') {
return widget.feedController.dramas.any(isHotDrama);
} }
return categoryText.any((text) => tagMatchesText(option.id, text)) ||
widget.feedController.dramas.any( List<Drama> _dramasFor(
(drama) => matchesHomeTag(drama, option.id), HomePrimaryTab tab,
String? tag,
List<HomeGridCategoryShortcut> shortcuts,
) {
Iterable<Drama> source = widget.feedController.dramas;
if (tab == HomePrimaryTab.costume || tab == HomePrimaryTab.community) {
final categoryName = shortcuts
.firstWhere((shortcut) => shortcut.tab == tab)
.categoryName
.toLowerCase();
if (categoryName.isEmpty) return const [];
source = source.where(
(drama) =>
drama.category.toLowerCase().contains(categoryName) ||
drama.genre.toLowerCase().contains(categoryName),
); );
}) }
.toList(growable: false); if (tag != null) {
source = source.where((drama) => matchesHomeTag(drama, tag));
}
final dramas = source.toList(growable: false);
switch (tab) {
case HomePrimaryTab.newReleases:
return [...dramas]..sort((a, b) {
final left = publishedAtForDrama(a);
final right = publishedAtForDrama(b);
if (left == null && right == null) return b.id.compareTo(a.id);
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
});
case HomePrimaryTab.ranking:
return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount));
case HomePrimaryTab.featured:
case HomePrimaryTab.categories:
case HomePrimaryTab.costume:
case HomePrimaryTab.community:
return dramas;
}
} }
List<Drama> _filteredDramas(String? category, String? tag) { Widget _emptyState(AppLocalizations l10n, HomePrimaryTab tab, String? tag) {
final normalizedCategory = category?.toLowerCase();
return widget.feedController.dramas
.where((drama) {
final categoryMatches =
normalizedCategory == null ||
drama.category.toLowerCase().contains(normalizedCategory) ||
drama.genre.toLowerCase().contains(normalizedCategory);
final tagMatches = tag == null || matchesHomeTag(drama, tag);
return categoryMatches && tagMatches;
})
.toList(growable: false);
}
Widget _emptyState(AppLocalizations l10n, String? category, String? tag) {
if (widget.feedController.lastError != null) { if (widget.feedController.lastError != null) {
return EmptyState( return EmptyState(
key: const Key('home-grid-error'), key: const Key('home-grid-error'),
@@ -276,7 +311,10 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
onAction: () => unawaited(widget.feedController.refreshCurrent()), onAction: () => unawaited(widget.feedController.refreshCurrent()),
); );
} }
final filtered = category != null || tag != null; final filtered =
tab == HomePrimaryTab.costume ||
tab == HomePrimaryTab.community ||
tag != null;
return EmptyState( return EmptyState(
key: const Key('home-grid-empty'), key: const Key('home-grid-empty'),
icon: Icons.movie_filter_outlined, icon: Icons.movie_filter_outlined,
@@ -284,10 +322,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
subtitle: filtered ? null : l10n.emptyHint, subtitle: filtered ? null : l10n.emptyHint,
actionLabel: filtered ? l10n.homeGridClearFilter : null, actionLabel: filtered ? l10n.homeGridClearFilter : null,
onAction: filtered onAction: filtered
? () => setState(() { ? () => setState(() => _selectedTags[tab] = null)
_selectedCategory = null;
_selectedTag = null;
})
: null, : null,
); );
} }
+72 -55
View File
@@ -7,14 +7,25 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
enum HomePrimaryTab {
featured,
newReleases,
categories,
ranking,
costume,
community,
}
class HomeGridCategoryShortcut { class HomeGridCategoryShortcut {
const HomeGridCategoryShortcut({ const HomeGridCategoryShortcut({
required this.label, required this.label,
required this.categoryName, required this.categoryName,
required this.tab,
}); });
final String label; final String label;
final String categoryName; final String categoryName;
final HomePrimaryTab tab;
} }
class HomeGridTagOption { class HomeGridTagOption {
@@ -29,18 +40,12 @@ class HomeGridCallbacks {
required this.onSearch, required this.onSearch,
required this.onOpenVip, required this.onOpenVip,
required this.onOpenWallet, required this.onOpenWallet,
required this.onOpenNewReleases,
required this.onOpenCategories,
required this.onOpenRanking,
required this.onOpenFilter, required this.onOpenFilter,
}); });
final VoidCallback onSearch; final VoidCallback onSearch;
final VoidCallback onOpenVip; final VoidCallback onOpenVip;
final VoidCallback onOpenWallet; final VoidCallback onOpenWallet;
final VoidCallback onOpenNewReleases;
final VoidCallback onOpenCategories;
final VoidCallback onOpenRanking;
final VoidCallback onOpenFilter; final VoidCallback onOpenFilter;
} }
@@ -50,9 +55,9 @@ class HomeGridHeader extends StatelessWidget {
required this.callbacks, required this.callbacks,
required this.categories, required this.categories,
required this.tags, required this.tags,
required this.selectedCategory, required this.selectedPrimary,
required this.selectedTag, required this.selectedTag,
required this.onCategorySelected, required this.onPrimarySelected,
required this.onTagSelected, required this.onTagSelected,
this.walletBadge, this.walletBadge,
}); });
@@ -60,9 +65,9 @@ class HomeGridHeader extends StatelessWidget {
final HomeGridCallbacks callbacks; final HomeGridCallbacks callbacks;
final List<HomeGridCategoryShortcut> categories; final List<HomeGridCategoryShortcut> categories;
final List<HomeGridTagOption> tags; final List<HomeGridTagOption> tags;
final String? selectedCategory; final HomePrimaryTab selectedPrimary;
final String? selectedTag; final String? selectedTag;
final ValueChanged<String?> onCategorySelected; final ValueChanged<HomePrimaryTab> onPrimarySelected;
final ValueChanged<String?> onTagSelected; final ValueChanged<String?> onTagSelected;
final int? walletBadge; final int? walletBadge;
@@ -71,6 +76,8 @@ class HomeGridHeader extends StatelessWidget {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Column( return Column(
key: const Key('home-grid-header'), key: const Key('home-grid-header'),
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [_searchRow(l10n), _primaryRow(l10n), _tagRow(l10n)], children: [_searchRow(l10n), _primaryRow(l10n), _tagRow(l10n)],
); );
} }
@@ -84,6 +91,8 @@ class HomeGridHeader extends StatelessWidget {
AppDimens.spaceSm, AppDimens.spaceSm,
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: InkWell( child: InkWell(
@@ -210,37 +219,38 @@ class HomeGridHeader extends StatelessWidget {
horizontal: AppDimens.feedPageHorizontal, horizontal: AppDimens.feedPageHorizontal,
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
_primaryItem( _primaryItem(
key: const Key('home-section-featured'), key: const Key('home-section-featured'),
label: l10n.homeSectionFeatured, label: l10n.homeSectionFeatured,
selected: selectedCategory == null, tab: HomePrimaryTab.featured,
onTap: () => onCategorySelected(null),
), ),
_primaryItem( _primaryItem(
key: const Key('home-section-new'), key: const Key('home-section-new'),
label: l10n.homeSectionNew, label: l10n.homeSectionNew,
selected: false, tab: HomePrimaryTab.newReleases,
onTap: callbacks.onOpenNewReleases,
), ),
_primaryItem( _primaryItem(
key: const Key('home-section-categories'), key: const Key('home-section-categories'),
label: l10n.homeSectionCategories, label: l10n.homeSectionCategories,
selected: false, tab: HomePrimaryTab.categories,
onTap: callbacks.onOpenCategories,
), ),
_primaryItem( _primaryItem(
key: const Key('home-section-ranking'), key: const Key('home-section-ranking'),
label: l10n.homeSectionRanking, label: l10n.homeSectionRanking,
selected: false, tab: HomePrimaryTab.ranking,
onTap: callbacks.onOpenRanking,
), ),
for (final category in categories) for (final category in categories)
_primaryItem( _primaryItem(
key: Key('home-section-category-${category.categoryName}'), key: Key(
category.categoryName.isEmpty
? 'home-section-${category.tab.name}'
: 'home-section-category-${category.categoryName}',
),
label: category.label, label: category.label,
selected: selectedCategory == category.categoryName, tab: category.tab,
onTap: () => onCategorySelected(category.categoryName),
), ),
], ],
), ),
@@ -250,38 +260,35 @@ class HomeGridHeader extends StatelessWidget {
Widget _primaryItem({ Widget _primaryItem({
required Key key, required Key key,
required String label, required String label,
required bool selected, required HomePrimaryTab tab,
required VoidCallback onTap,
}) { }) {
final selected = selectedPrimary == tab;
return InkWell( return InkWell(
key: key, key: key,
onTap: onTap, onTap: () => onPrimarySelected(tab),
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: AppDimens.spaceXl), padding: const EdgeInsets.only(right: AppDimens.homePrimaryCategoryGap),
child: Column( child: SizedBox(
mainAxisSize: MainAxisSize.min, height: AppDimens.homePrimaryRowHeight,
children: [ child: Align(
Text( alignment: Alignment.bottomLeft,
label, child: AnimatedDefaultTextStyle(
key: Key('home-primary-label-${tab.name}'),
duration: AppDimens.homePrimaryTabAnimationDuration,
curve: Curves.easeOut,
style: TextStyle( style: TextStyle(
color: selected color: selected
? AppColors.navSelected ? AppColors.navSelected
: AppColors.textTertiary, : AppColors.textTertiary,
fontSize: AppDimens.homePrimaryTabFont, fontSize: selected
? AppDimens.homePrimaryTabSelectedFont
: AppDimens.homePrimaryTabFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400, fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
letterSpacing: 0, letterSpacing: 0,
), ),
), child: Text(label, textAlign: TextAlign.left),
const SizedBox(height: AppDimens.spaceXs),
Container(
height: AppDimens.homeSectionIndicator,
width: selected ? 20 : 0,
decoration: BoxDecoration(
color: AppColors.navSelected,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
), ),
), ),
],
), ),
), ),
); );
@@ -296,16 +303,20 @@ class HomeGridHeader extends StatelessWidget {
AppDimens.spaceSm, AppDimens.spaceSm,
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
key: const Key('home-secondary-tags'), key: const Key('home-secondary-tags'),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
for (final tag in tags) ...[ for (final tag in tags) ...[
_tagChip(tag), _tagChip(tag),
const SizedBox(width: AppDimens.spaceSm), const SizedBox(width: AppDimens.homeSecondaryTagGap),
], ],
], ],
), ),
@@ -339,20 +350,7 @@ class HomeGridHeader extends StatelessWidget {
Widget _tagChip(HomeGridTagOption tag) { Widget _tagChip(HomeGridTagOption tag) {
final selected = selectedTag == tag.id; final selected = selectedTag == tag.id;
return InkWell( final label = Text(
key: Key('home-tag-${tag.id}'),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
onTap: () => onTagSelected(selected ? null : tag.id),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceMd,
vertical: AppDimens.spaceSm,
),
decoration: BoxDecoration(
color: selected ? AppColors.contentSurface : AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
tag.label, tag.label,
style: TextStyle( style: TextStyle(
color: selected ? AppColors.navSelected : AppColors.textTertiary, color: selected ? AppColors.navSelected : AppColors.textTertiary,
@@ -360,7 +358,26 @@ class HomeGridHeader extends StatelessWidget {
fontWeight: selected ? FontWeight.w700 : FontWeight.w400, fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
letterSpacing: 0, letterSpacing: 0,
), ),
);
return InkWell(
key: Key('home-tag-${tag.id}'),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
onTap: () => onTagSelected(selected ? null : tag.id),
child: selected
? Container(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceMd,
vertical: AppDimens.spaceSm,
), ),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: label,
)
: Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm),
child: label,
), ),
); );
} }
@@ -36,6 +36,8 @@ class AllDramasFilterGrid extends StatefulWidget {
} }
class _AllDramasFilterGridState extends State<AllDramasFilterGrid> { class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
String _region = 'all';
String _audio = 'all';
String? _genre; String? _genre;
bool _newest = false; bool _newest = false;
@@ -71,7 +73,11 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
icon: Icons.movie_filter_outlined, icon: Icons.movie_filter_outlined,
title: l10n.homeGridFilterEmpty, title: l10n.homeGridFilterEmpty,
actionLabel: l10n.homeGridClearFilter, actionLabel: l10n.homeGridClearFilter,
onAction: () => setState(() => _genre = null), onAction: () => setState(() {
_region = 'all';
_audio = 'all';
_genre = null;
}),
), ),
) )
else else
@@ -130,12 +136,28 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
child: Column( child: Column(
key: const Key('all-dramas-filters'), key: const Key('all-dramas-filters'),
children: [ children: [
_chipRow(0, [l10n.all]), _selectableRow(
_chipRow(1, [l10n.all]), 0,
_chipRow(2, [ [
(id: null, label: l10n.all), (id: 'all', label: l10n.all),
for (final genre in genres) (id: genre.id, label: genre.label), (id: 'local', label: l10n.localFilter),
]), (id: 'asia', label: l10n.asiaFilter),
(id: 'western', label: l10n.westernFilter),
],
selected: _region,
onSelected: (value) => setState(() => _region = value),
),
_selectableRow(
1,
[
(id: 'all', label: l10n.all),
(id: 'subtitles', label: l10n.subtitlesOnlyFilter),
(id: 'dubbed', label: l10n.dubbedFilter),
],
selected: _audio,
onSelected: (value) => setState(() => _audio = value),
),
_genreRow(2, genres, l10n),
Row( Row(
key: const Key('all-filter-row-3'), key: const Key('all-filter-row-3'),
children: [ children: [
@@ -173,7 +195,12 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
); );
} }
Widget _chipRow(int row, List<Object> options) { Widget _selectableRow(
int row,
List<({String id, String label})> options, {
required String selected,
required ValueChanged<String> onSelected,
}) {
return Padding( return Padding(
key: Key('all-filter-row-$row'), key: Key('all-filter-row-$row'),
padding: const EdgeInsets.only(bottom: AppDimens.spaceSm), padding: const EdgeInsets.only(bottom: AppDimens.spaceSm),
@@ -182,13 +209,11 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
child: Row( child: Row(
children: [ children: [
for (final option in options) ...[ for (final option in options) ...[
if (option is String)
_chip(label: option, selected: true, onTap: () {}),
if (option is ({String? id, String label}))
_chip( _chip(
key: Key('all-filter-${option.id}'),
label: option.label, label: option.label,
selected: _genre == option.id, selected: selected == option.id,
onTap: () => setState(() => _genre = option.id), onTap: () => onSelected(option.id),
), ),
const SizedBox(width: AppDimens.spaceSm), const SizedBox(width: AppDimens.spaceSm),
], ],
@@ -198,6 +223,23 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
); );
} }
Widget _genreRow(
int row,
List<({String id, String label})> options,
AppLocalizations l10n,
) {
return _selectableRow(
row,
[
(id: 'all', label: l10n.all),
for (final genre in options) (id: genre.id, label: genre.label),
],
selected: _genre ?? 'all',
onSelected: (value) =>
setState(() => _genre = value == 'all' ? null : value),
);
}
Widget _chip({ Widget _chip({
Key? key, Key? key,
required String label, required String label,
@@ -248,6 +290,7 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
} }
List<Drama> _filtered() { List<Drama> _filtered() {
if (_region != 'all' || _audio != 'all') return const [];
final dramas = widget.feedController.dramas final dramas = widget.feedController.dramas
.where((drama) { .where((drama) {
return _genre == null || _matchesGenre(drama, _genre!, const []); return _genre == null || _matchesGenre(drama, _genre!, const []);
@@ -35,6 +35,7 @@ class RecommendSectionCard extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ClipRRect( ClipRRect(
@@ -83,6 +84,8 @@ class RecommendSectionCard extends StatelessWidget {
const SizedBox(height: AppDimens.cardCoverTitleGap), const SizedBox(height: AppDimens.cardCoverTitleGap),
Row( Row(
key: Key('recommend-views-$sectionKey-${drama.id}'), key: Key('recommend-views-$sectionKey-${drama.id}'),
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
const Icon( const Icon(
Icons.play_arrow_rounded, Icons.play_arrow_rounded,
@@ -123,38 +126,16 @@ class RecommendSectionCard extends StatelessWidget {
Widget _tags() { Widget _tags() {
final tags = _cardTags(); final tags = _cardTags();
return Row( return Text(
children: [ tags.join(' | '),
for (final (index, tag) in tags.indexed) key: Key('recommend-tags-$sectionKey-${drama.id}'),
Flexible(
child: Padding(
padding: EdgeInsets.only(
right: index == tags.length - 1 ? 0 : AppDimens.spaceXs,
),
child: Container(
key: Key('recommend-tag-$sectionKey-${drama.id}-$index'),
height: AppDimens.cardTagHeight,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.cardTagHorizontalPadding,
),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.cardTagRadius),
),
child: Text(
tag,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.textTertiary, color: AppColors.textTertiary,
fontSize: AppDimens.cardTagFont, fontSize: AppDimens.cardTagFont,
), ),
),
),
),
),
],
); );
} }
@@ -164,7 +145,7 @@ class RecommendSectionCard extends StatelessWidget {
.expand((value) => value.split(_tagSplitter)) .expand((value) => value.split(_tagSplitter))
.map((value) => value.trim()) .map((value) => value.trim())
.where((value) => value.isNotEmpty && seen.add(value)) .where((value) => value.isNotEmpty && seen.add(value))
.take(2) .take(3)
.toList(growable: false); .toList(growable: false);
} }
} }
+7 -7
View File
@@ -79,14 +79,14 @@ void main() {
); );
expect(views.style?.fontSize, AppDimens.cardViewsFont); expect(views.style?.fontSize, AppDimens.cardViewsFont);
expect(views.style?.color, AppColors.textPrimary); expect(views.style?.color, AppColors.textPrimary);
final tag = find.byKey(const Key('following-tag-the-signal-demo-0')); final tags = tester.widget<Text>(
expect(tag, findsOneWidget); find.byKey(const Key('following-tags-the-signal-demo')),
expect(tester.getSize(tag).height, AppDimens.cardTagHeight);
final tagText = tester.widget<Text>(
find.descendant(of: tag, matching: find.byType(Text)),
); );
expect(tagText.style?.fontSize, AppDimens.cardTagFont); expect(tags.data, 'Suspense | Mystery');
expect(tagText.style?.color, AppColors.textTertiary); expect(tags.style?.fontSize, AppDimens.cardTagFont);
expect(tags.style?.color, AppColors.textTertiary);
expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx);
expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx);
await tester.tap(find.byKey(const Key('following-edit'))); await tester.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+16 -23
View File
@@ -26,16 +26,17 @@ void main() {
); );
final card = find.byKey(const Key('home-feed-card-anime-card')); final card = find.byKey(const Key('home-feed-card-anime-card'));
final container = tester.widget<Container>(card); expect(tester.widget<Material>(card).color, AppColors.transparent);
final decoration = container.decoration! as BoxDecoration;
expect(decoration.color, AppColors.contentCard);
expect(decoration.boxShadow, isNull);
expect( expect(
decoration.borderRadius, tester
BorderRadius.circular(AppDimens.homeCardRadius), .widgetList<Container>(
find.descendant(of: card, matching: find.byType(Container)),
)
.map((container) => container.decoration)
.whereType<BoxDecoration>()
.every((decoration) => decoration.border == null),
isTrue,
); );
expect((decoration.border! as Border).top.width, 1);
expect((decoration.border! as Border).top.color, AppColors.contentBorder);
final cover = find.byKey(const Key('home-feed-cover-anime-card')); final cover = find.byKey(const Key('home-feed-cover-anime-card'));
final coverSize = tester.getSize(cover); final coverSize = tester.getSize(cover);
@@ -70,22 +71,14 @@ void main() {
expect(title.style?.fontSize, AppDimens.cardTitleFont); expect(title.style?.fontSize, AppDimens.cardTitleFont);
expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.fontWeight, FontWeight.w500);
expect(title.style?.height, AppDimens.cardTitleLineHeight); expect(title.style?.height, AppDimens.cardTitleLineHeight);
final firstTag = find.byKey(const Key('home-feed-tag-anime-card-0')); final tags = tester.widget<Text>(
expect(firstTag, findsOneWidget); find.byKey(const Key('home-feed-tags-anime-card')),
expect(find.byKey(const Key('home-feed-tag-anime-card-1')), findsOneWidget);
expect(find.text('Romance'), findsOneWidget);
expect(find.text('Urban'), findsOneWidget);
final tagContainer = tester.widget<Container>(firstTag);
expect(tester.getSize(firstTag).height, AppDimens.cardTagHeight);
expect(
(tagContainer.decoration! as BoxDecoration).borderRadius,
BorderRadius.circular(AppDimens.cardTagRadius),
); );
final tagText = tester.widget<Text>( expect(tags.data, 'Romance | Urban');
find.descendant(of: firstTag, matching: find.byType(Text)), expect(tags.style?.fontSize, AppDimens.cardTagFont);
); expect(tags.style?.color, AppColors.textTertiary);
expect(tagText.style?.fontSize, AppDimens.cardTagFont); expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx);
expect(tagText.style?.color, AppColors.textTertiary); expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx);
await tester.tap(card); await tester.tap(card);
expect(taps, 1); expect(taps, 1);
+70 -14
View File
@@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.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/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart';
import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
@@ -88,29 +89,82 @@ void main() {
expect(find.byKey(const Key('home-tag-uplifting')), findsNothing); expect(find.byKey(const Key('home-tag-uplifting')), findsNothing);
}); });
testWidgets('should_open_existing_pages_from_home_sections', (tester) async { testWidgets('should_switch_home_sections_without_pushing_pages', (
tester,
) async {
final repository = _PagedCatalogRepository(); final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository); await _pumpApp(tester, repository);
String firstDramaId() {
return tester
.widget<HomeFeedCard>(find.byType(HomeFeedCard).first)
.drama
.id;
}
expect(find.byKey(const Key('home-grid')), findsOneWidget);
expect(firstDramaId(), 'page1-0');
await tester.tap(find.byKey(const Key('home-section-new'))); await tester.tap(find.byKey(const Key('home-section-new')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('new-releases-page')), findsOneWidget); expect(find.byKey(const Key('new-releases-page')), findsNothing);
await tester.pageBack(); expect(firstDramaId(), 'page1-9');
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('home-section-ranking')));
await tester.tap(find.byKey(const Key('home-section-ranking'))); await tester.tap(find.byKey(const Key('home-section-ranking')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('ranking-page')), findsOneWidget); expect(find.byKey(const Key('ranking-page')), findsNothing);
await tester.pageBack(); expect(firstDramaId(), 'page1-0');
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('home-section-categories')),
);
await tester.tap(find.byKey(const Key('home-section-categories'))); await tester.tap(find.byKey(const Key('home-section-categories')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('filter-page')), findsOneWidget); expect(find.byKey(const Key('filter-page')), findsNothing);
expect(find.byKey(const Key('home-grid-cards')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-section-category-古装')));
await tester.pumpAndSettle();
expect(firstDramaId(), 'page1-0');
await tester.tap(find.byKey(const Key('home-section-category-社群')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-grid-empty')), findsOneWidget);
expect(find.byKey(const Key('filter-page')), findsNothing);
});
testWidgets('should_keep_scroll_position_when_switching_sections', (
tester,
) async {
final repository = _PagedCatalogRepository();
await _pumpApp(tester, repository);
await tester.drag(
find.byKey(const Key('home-grid-scroll')),
const Offset(0, -320),
);
await tester.pumpAndSettle();
final scrollable = find
.descendant(
of: find.byKey(const Key('home-grid-scroll')),
matching: find.byType(Scrollable),
)
.first;
final before = tester.state<ScrollableState>(scrollable).position.pixels;
expect(before, greaterThan(0));
await tester.tap(find.byKey(const Key('home-section-new')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('home-section-featured')));
await tester.pumpAndSettle();
final afterScrollable = find
.descendant(
of: find.byKey(const Key('home-grid-scroll')),
matching: find.byType(Scrollable),
)
.first;
expect(
tester.state<ScrollableState>(afterScrollable).position.pixels,
closeTo(before, 0.1),
);
}); });
testWidgets('should_load_next_page_without_expanding_page_size', ( testWidgets('should_load_next_page_without_expanding_page_size', (
@@ -232,14 +286,16 @@ class _PagedCatalogRepository implements CatalogRepository {
} }
Drama _drama(String id) { Drama _drama(String id) {
final index = int.tryParse(id.split('-').last) ?? 0;
return Drama( return Drama(
id: id, id: id,
title: 'Grid Drama $id', title: 'Grid Drama $id',
synopsis: 'synopsis', synopsis: 'synopsis',
posterUrl: '', posterUrl: '',
genre: 'Romance', genre: 'Romance',
category: 'Urban', category: index.isEven ? '古装' : 'Urban',
viewCount: 200000, viewCount: (10 - index) * 10000,
createdAt: DateTime(2026, 10, 1).add(Duration(days: index)),
detailLoaded: true, detailLoaded: true,
episodes: [ episodes: [
Episode( Episode(
+44 -18
View File
@@ -109,23 +109,29 @@ void main() {
); );
expect(placeholder.style?.fontSize, AppDimens.searchPlaceholderFont); expect(placeholder.style?.fontSize, AppDimens.searchPlaceholderFont);
expect(placeholder.style?.color, AppColors.textSecondary); expect(placeholder.style?.color, AppColors.textSecondary);
final selectedPrimary = tester.widget<Text>( final selectedPrimary = tester.widget<AnimatedDefaultTextStyle>(
find.byKey(const Key('home-primary-label-featured')),
);
final unselectedPrimary = tester.widget<AnimatedDefaultTextStyle>(
find.byKey(const Key('home-primary-label-newReleases')),
);
expect(
selectedPrimary.style.fontSize,
AppDimens.homePrimaryTabSelectedFont,
);
expect(selectedPrimary.style.fontWeight, FontWeight.w700);
expect(selectedPrimary.style.color, AppColors.navSelected);
expect(selectedPrimary.duration, AppDimens.homePrimaryTabAnimationDuration);
expect(unselectedPrimary.style.fontSize, AppDimens.homePrimaryTabFont);
expect(unselectedPrimary.style.fontWeight, FontWeight.w400);
expect(unselectedPrimary.style.color, AppColors.textTertiary);
expect(
find.descendant( find.descendant(
of: find.byKey(const Key('home-section-featured')), of: find.byKey(const Key('home-section-featured')),
matching: find.text('Featured'), matching: find.byType(Container),
), ),
findsNothing,
); );
final unselectedPrimary = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('home-section-new')),
matching: find.text('New'),
),
);
expect(selectedPrimary.style?.fontSize, AppDimens.homePrimaryTabFont);
expect(selectedPrimary.style?.fontWeight, FontWeight.w700);
expect(selectedPrimary.style?.color, AppColors.navSelected);
expect(unselectedPrimary.style?.fontWeight, FontWeight.w400);
expect(unselectedPrimary.style?.color, AppColors.textTertiary);
final secondaryTag = tester.widget<Text>( final secondaryTag = tester.widget<Text>(
find.descendant( find.descendant(
of: find.byKey(const Key('home-tag-hot')), of: find.byKey(const Key('home-tag-hot')),
@@ -135,18 +141,38 @@ void main() {
expect(secondaryTag.style?.fontSize, AppDimens.homeSecondaryTagFont); expect(secondaryTag.style?.fontSize, AppDimens.homeSecondaryTagFont);
expect(secondaryTag.style?.fontWeight, FontWeight.w400); expect(secondaryTag.style?.fontWeight, FontWeight.w400);
expect(secondaryTag.style?.color, AppColors.textTertiary); expect(secondaryTag.style?.color, AppColors.textTertiary);
final unselectedTagChip = tester.widget<Container>( expect(
find.descendant(
of: find.byKey(const Key('home-tag-hot')),
matching: find.byType(Container),
),
findsNothing,
);
final primaryStart = tester.getTopLeft(find.text('Featured'));
final pageStart = tester.getTopLeft(searchBox);
expect(primaryStart.dx, pageStart.dx);
final primaryGap =
tester.getTopLeft(find.text('New')).dx -
tester.getBottomRight(find.text('Featured')).dx;
expect(primaryGap, closeTo(AppDimens.homePrimaryCategoryGap, 0.1));
final tagStart = tester.getTopLeft(find.text('Hot'));
expect(tagStart.dx, pageStart.dx);
final tagGap =
tester.getTopLeft(find.text('Uplifting')).dx -
tester.getBottomRight(find.text('Hot')).dx;
expect(tagGap, closeTo(AppDimens.homeSecondaryTagGap, 0.1));
await tester.tap(find.byKey(const Key('home-tag-hot')));
await tester.pump();
final selectedTagChip = tester.widget<Container>(
find.descendant( find.descendant(
of: find.byKey(const Key('home-tag-hot')), of: find.byKey(const Key('home-tag-hot')),
matching: find.byType(Container), matching: find.byType(Container),
), ),
); );
expect( expect(
(unselectedTagChip.decoration! as BoxDecoration).color, (selectedTagChip.decoration! as BoxDecoration).color,
AppColors.transparent, AppColors.contentSurface,
); );
await tester.tap(find.byKey(const Key('home-tag-hot')));
await tester.pump();
final selectedTag = tester.widget<Text>( final selectedTag = tester.widget<Text>(
find.descendant( find.descendant(
of: find.byKey(const Key('home-tag-hot')), of: find.byKey(const Key('home-tag-hot')),
+14 -13
View File
@@ -103,18 +103,14 @@ void main() {
); );
expect(views.style?.fontSize, AppDimens.cardViewsFont); expect(views.style?.fontSize, AppDimens.cardViewsFont);
expect(views.style?.color, AppColors.textPrimary); expect(views.style?.color, AppColors.textPrimary);
final tag = find.byKey(const Key('recommend-tag-hits-the-signal-demo-0')); final tags = tester.widget<Text>(
expect(tag, findsOneWidget); find.byKey(const Key('recommend-tags-hits-the-signal-demo')),
expect(tester.getSize(tag).height, AppDimens.cardTagHeight);
final tagText = tester.widget<Text>(
find.descendant(of: tag, matching: find.byType(Text)),
);
expect(tagText.style?.fontSize, AppDimens.cardTagFont);
expect(tagText.style?.color, AppColors.textTertiary);
expect(
(tester.widget<Container>(tag).decoration! as BoxDecoration).borderRadius,
BorderRadius.circular(AppDimens.cardTagRadius),
); );
expect(tags.data, 'Suspense | Mystery');
expect(tags.style?.fontSize, AppDimens.cardTagFont);
expect(tags.style?.color, AppColors.textTertiary);
expect(tester.getTopLeft(cardTitle).dx, tester.getTopLeft(cover).dx);
expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx);
}); });
testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', (
@@ -132,8 +128,13 @@ void main() {
for (var row = 0; row < 4; row++) { for (var row = 0; row < 4; row++) {
expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget);
} }
expect(find.byKey(const Key('all-filter-local')), findsNothing); expect(find.byKey(const Key('all-filter-local')), findsOneWidget);
expect(find.byKey(const Key('all-filter-subtitles')), findsNothing); expect(find.byKey(const Key('all-filter-subtitles')), findsOneWidget);
await tester.tap(find.byKey(const Key('all-filter-local')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('all-dramas-empty')), findsOneWidget);
await tester.tap(find.byKey(const Key('all-filter-all')).first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('all-sort-new'))); await tester.tap(find.byKey(const Key('all-sort-new')));
await tester.pump(); await tester.pump();
expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700);