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