merge: 修正首页分类与卡片布局
This commit is contained in:
commit
7446431511
14 files changed
+573
-459
No files matched your search
@@ -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 已占位;以下接口待补:地区、字幕/配音筛选数据。
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
maxLines: 1,
|
||||||
child: Padding(
|
overflow: TextOverflow.ellipsis,
|
||||||
padding: EdgeInsets.only(
|
textAlign: TextAlign.left,
|
||||||
right: index == visible.length - 1 ? 0 : AppDimens.spaceXs,
|
style: const TextStyle(
|
||||||
),
|
color: AppColors.textTertiary,
|
||||||
child: Container(
|
fontSize: AppDimens.cardTagFont,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -29,49 +29,46 @@ 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.transparent,
|
||||||
color: AppColors.contentCard,
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
|
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
|
||||||
border: Border.all(color: AppColors.contentBorder),
|
onTap: onTap,
|
||||||
),
|
child: Column(
|
||||||
child: Material(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: AppColors.transparent,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
|
children: [
|
||||||
clipBehavior: Clip.antiAlias,
|
_cover(l10n),
|
||||||
child: InkWell(
|
Expanded(
|
||||||
onTap: onTap,
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.only(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
top: AppDimens.cardCoverTitleGap,
|
||||||
children: [
|
),
|
||||||
_cover(l10n),
|
child: Column(
|
||||||
Expanded(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Padding(
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(AppDimens.homeCardPadding),
|
children: [
|
||||||
child: Column(
|
Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
drama.title,
|
||||||
children: [
|
key: Key('home-feed-title-${drama.id}'),
|
||||||
Text(
|
maxLines: 2,
|
||||||
drama.title,
|
overflow: TextOverflow.ellipsis,
|
||||||
key: Key('home-feed-title-${drama.id}'),
|
textAlign: TextAlign.left,
|
||||||
maxLines: 2,
|
style: const TextStyle(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: AppColors.navSelected,
|
||||||
style: const TextStyle(
|
fontSize: AppDimens.cardTitleFont,
|
||||||
color: AppColors.navSelected,
|
fontWeight: FontWeight.w500,
|
||||||
fontSize: AppDimens.cardTitleFont,
|
height: AppDimens.cardTitleLineHeight,
|
||||||
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() {
|
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(
|
maxLines: 1,
|
||||||
child: Padding(
|
overflow: TextOverflow.ellipsis,
|
||||||
padding: EdgeInsets.only(
|
textAlign: TextAlign.left,
|
||||||
right: index == tags.length - 1 ? 0 : AppDimens.spaceXs,
|
style: const TextStyle(
|
||||||
),
|
color: AppColors.textTertiary,
|
||||||
child: Container(
|
fontSize: AppDimens.cardTagFont,
|
||||||
key: Key('home-feed-tag-${drama.id}-$index'),
|
letterSpacing: 0,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,102 +84,122 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
|||||||
color: AppColors.appBackground,
|
color: AppColors.appBackground,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: LayoutBuilder(
|
child: Column(
|
||||||
builder: (context, constraints) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
final itemWidth =
|
children: [
|
||||||
(constraints.maxWidth -
|
HomeGridHeader(
|
||||||
AppDimens.feedPageHorizontal * 2 -
|
callbacks: widget.callbacks,
|
||||||
AppDimens.homeGridGap * 2) /
|
categories: shortcuts,
|
||||||
3;
|
tags: tags,
|
||||||
final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent;
|
selectedPrimary: _selectedPrimary,
|
||||||
return NotificationListener<ScrollNotification>(
|
selectedTag: activeTag,
|
||||||
onNotification: _onScroll,
|
onPrimarySelected: (tab) =>
|
||||||
child: AppPullToRefresh(
|
setState(() => _selectedPrimary = tab),
|
||||||
key: const Key('home-grid-refresh'),
|
onTagSelected: (tag) =>
|
||||||
onRefresh: _refresh,
|
setState(() => _selectedTags[_selectedPrimary] = tag),
|
||||||
child: CustomScrollView(
|
walletBadge: walletBadge,
|
||||||
key: const Key('home-grid-scroll'),
|
),
|
||||||
physics: AppPullToRefresh.physics,
|
Expanded(
|
||||||
slivers: [
|
child: IndexedStack(
|
||||||
SliverToBoxAdapter(
|
index: _selectedPrimary.index,
|
||||||
child: HomeGridHeader(
|
children: [
|
||||||
callbacks: widget.callbacks,
|
for (final tab in HomePrimaryTab.values)
|
||||||
categories: shortcuts,
|
_tabGrid(context, l10n, tab: tab, shortcuts: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<void> _refresh() async {
|
||||||
await widget.feedController.refreshCurrent();
|
await widget.feedController.refreshCurrent();
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
HomeGridCategoryShortcut(
|
||||||
}
|
label: l10n.homeSectionCommunity,
|
||||||
if (community != null) {
|
categoryName: community?.name ?? '',
|
||||||
shortcuts.add(
|
tab: HomePrimaryTab.community,
|
||||||
HomeGridCategoryShortcut(
|
),
|
||||||
label: l10n.homeSectionCommunity,
|
];
|
||||||
categoryName: community.name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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(
|
|
||||||
(drama) => matchesHomeTag(drama, option.id),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.toList(growable: false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Drama> _filteredDramas(String? category, String? tag) {
|
List<Drama> _dramasFor(
|
||||||
final normalizedCategory = category?.toLowerCase();
|
HomePrimaryTab tab,
|
||||||
return widget.feedController.dramas
|
String? tag,
|
||||||
.where((drama) {
|
List<HomeGridCategoryShortcut> shortcuts,
|
||||||
final categoryMatches =
|
) {
|
||||||
normalizedCategory == null ||
|
Iterable<Drama> source = widget.feedController.dramas;
|
||||||
drama.category.toLowerCase().contains(normalizedCategory) ||
|
if (tab == HomePrimaryTab.costume || tab == HomePrimaryTab.community) {
|
||||||
drama.genre.toLowerCase().contains(normalizedCategory);
|
final categoryName = shortcuts
|
||||||
final tagMatches = tag == null || matchesHomeTag(drama, tag);
|
.firstWhere((shortcut) => shortcut.tab == tab)
|
||||||
return categoryMatches && tagMatches;
|
.categoryName
|
||||||
})
|
.toLowerCase();
|
||||||
.toList(growable: false);
|
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) {
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 +350,35 @@ class HomeGridHeader extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _tagChip(HomeGridTagOption tag) {
|
Widget _tagChip(HomeGridTagOption tag) {
|
||||||
final selected = selectedTag == tag.id;
|
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(
|
return InkWell(
|
||||||
key: Key('home-tag-${tag.id}'),
|
key: Key('home-tag-${tag.id}'),
|
||||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||||
onTap: () => onTagSelected(selected ? null : tag.id),
|
onTap: () => onTagSelected(selected ? null : tag.id),
|
||||||
child: Container(
|
child: selected
|
||||||
padding: const EdgeInsets.symmetric(
|
? Container(
|
||||||
horizontal: AppDimens.spaceMd,
|
padding: const EdgeInsets.symmetric(
|
||||||
vertical: AppDimens.spaceSm,
|
horizontal: AppDimens.spaceMd,
|
||||||
),
|
vertical: AppDimens.spaceSm,
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: selected ? AppColors.contentSurface : AppColors.transparent,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
color: AppColors.contentSurface,
|
||||||
),
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||||
child: Text(
|
),
|
||||||
tag.label,
|
child: label,
|
||||||
style: TextStyle(
|
)
|
||||||
color: selected ? AppColors.navSelected : AppColors.textTertiary,
|
: Padding(
|
||||||
fontSize: AppDimens.homeSecondaryTagFont,
|
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm),
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
child: label,
|
||||||
letterSpacing: 0,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,14 +209,12 @@ 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(
|
||||||
_chip(label: option, selected: true, onTap: () {}),
|
key: Key('all-filter-${option.id}'),
|
||||||
if (option is ({String? id, String label}))
|
label: option.label,
|
||||||
_chip(
|
selected: selected == option.id,
|
||||||
label: option.label,
|
onTap: () => onSelected(option.id),
|
||||||
selected: _genre == option.id,
|
),
|
||||||
onTap: () => setState(() => _genre = 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(
|
maxLines: 1,
|
||||||
child: Padding(
|
overflow: TextOverflow.ellipsis,
|
||||||
padding: EdgeInsets.only(
|
textAlign: TextAlign.left,
|
||||||
right: index == tags.length - 1 ? 0 : AppDimens.spaceXs,
|
style: const TextStyle(
|
||||||
),
|
color: AppColors.textTertiary,
|
||||||
child: Container(
|
fontSize: AppDimens.cardTagFont,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user