fix(home): 修正首页分类与卡片布局
This commit is contained in:
1 parent
10477562d4
commit
aa4870cce6
14 files changed
+573
-459
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user