feat(home): 首页改为三列探索网格

This commit is contained in:
Zhou Weidong committed 2026-10-22 17:03:45 +08:00
1 parent 6dea9d479e
commit dd96792e33
29 files changed
+1912 -256

No files matched your search

+57 -46
View File
@@ -13,24 +13,32 @@ import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/catalog_retry.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/catalog_debug_info.dart';
import '../../core/widgets/catalog_endpoint_editor.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
import '../favorites/favorites_page.dart';
import '../filter/filter_page.dart';
import '../mine/mine_types.dart';
import '../new_releases/new_releases_page.dart';
import '../player/widgets/home_feed_header.dart';
import '../player/player_view.dart';
import '../ranking/ranking_page.dart';
import '../mine/mine_view.dart';
import '../store/vip_page.dart';
import '../wallet/wallet_page.dart';
import 'home_feed_channel.dart';
import 'home_binding.dart';
import 'home_logic.dart';
import 'widgets/home_feed_grid.dart';
import 'widgets/home_failure_view.dart';
import 'widgets/home_grid_header.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -189,34 +197,6 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
}
}
/// 首页目录加载失败与重试入口。
class HomeFailureView extends StatelessWidget {
const HomeFailureView({
super.key,
this.error,
required this.onRetry,
this.onEditEndpoint,
});
final String? error;
final VoidCallback onRetry;
final VoidCallback? onEditEndpoint;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return EmptyState(
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: onRetry,
footer: kDebugMode
? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint)
: null,
);
}
}
/// 四个根页面共享同一路由并保持各自状态。
class _HomeTabs extends StatefulWidget {
const _HomeTabs({super.key, required this.feedController});
@@ -261,7 +241,7 @@ class _HomeTabsState extends State<_HomeTabs> {
}
Widget _scaffold(BuildContext context) {
final feed = logic.tab == 0;
final mediaTab = logic.tab == 0 || logic.tab == 1;
final content = PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
@@ -278,7 +258,7 @@ class _HomeTabsState extends State<_HomeTabs> {
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: feed
child: mediaTab
? content
: DecoratedBox(
key: const Key('home-page-gradient'),
@@ -292,15 +272,31 @@ class _HomeTabsState extends State<_HomeTabs> {
return IndexedStack(
index: logic.tab,
children: [
_home(context),
_discover(context),
_homeGrid(context),
_recommend(context),
const FavoritesContent(key: Key('favorites-page'), showBack: false),
_my(context),
],
);
}
Widget _home(BuildContext context) {
Widget _homeGrid(BuildContext context) {
return HomeFeedGrid(
feedController: widget.feedController,
callbacks: HomeGridCallbacks(
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)),
);
}
Widget _recommend(BuildContext context) {
final currentDramas = widget.feedController.dramas;
final currentChannel = widget.feedController.channel;
if (currentDramas.isNotEmpty) {
@@ -329,7 +325,7 @@ class _HomeTabsState extends State<_HomeTabs> {
PlayerPage(
homeMode: true,
embedded: true,
active: logic.tab == 0,
active: logic.tab == 1,
drama: playerDramas.first,
initialIndex: 0,
feedDramas: playerDramas,
@@ -342,7 +338,7 @@ class _HomeTabsState extends State<_HomeTabs> {
onChannelSelected: _selectFeedChannel,
onDramaChanged: logic.onDramaChanged,
onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => logic.select(1, search: true),
onSearch: () => _openSearch(context),
onDiscover: () => logic.select(1),
onMy: () => logic.select(3),
),
@@ -383,7 +379,7 @@ class _HomeTabsState extends State<_HomeTabs> {
channel: widget.feedController.channel,
onChannelSelected: _selectFeedChannel,
onRefresh: () => _refreshPlaceholder(context),
onSearch: () => logic.select(1, search: true),
onSearch: () => _openSearch(context),
onMore: () {},
),
Expanded(
@@ -427,18 +423,33 @@ class _HomeTabsState extends State<_HomeTabs> {
unawaited(widget.feedController.selectChannel(channel));
}
Widget _discover(BuildContext context) {
return DiscoverPage(
active: logic.tab == 1,
searchRequest: logic.searchRequest,
callbacks: DiscoverPageCallbacks(
onAction: logic.discoverAction,
onDramaSelected: (drama) => logic.playDrama(context, drama),
onProfile: logic.openProfile,
void _openSearch(BuildContext context) {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)),
);
}
void _pushPage(BuildContext context, Widget page) {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
}
Future<void> _openVip(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
final wallet = context.read<WalletController>();
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
),
);
}
Future<void> _openWallet(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
}
Widget _my(BuildContext context) {
return MyPage(
active: logic.tab == 3,
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
import '../../../core/data/models/drama.dart';
const int homeNewReleaseDays = 7;
const int homeHotViewThreshold = 100000;
const int _snowflakeEpochMillis = 1288834974657;
const int _snowflakeTimestampShift = 22;
const Map<String, List<String>> _tagAliases = {
'uplifting': ['爽剧', '逆袭', '复仇', 'revenge', 'balas'],
'sweet': ['甜宠', '甜', 'sweet', 'romantis'],
'faceSlap': ['打脸', '虐渣', 'comeuppance', 'comeback'],
'angst': ['虐恋', '虐心', 'angst', 'heartbreak'],
};
DateTime? publishedAtForDrama(Drama drama) {
if (drama.createdAt != null) return drama.createdAt;
final snowflake = BigInt.tryParse(drama.id);
if (snowflake == null) return null;
final timestamp =
(snowflake >> _snowflakeTimestampShift).toInt() + _snowflakeEpochMillis;
final publishedAt = DateTime.fromMillisecondsSinceEpoch(timestamp);
return publishedAt.year >= 2015 ? publishedAt : null;
}
bool isNewDrama(Drama drama, {DateTime? now}) {
final publishedAt = publishedAtForDrama(drama);
if (publishedAt == null) return false;
final age = (now ?? DateTime.now()).difference(publishedAt);
return !age.isNegative && age <= const Duration(days: homeNewReleaseDays);
}
bool isHotDrama(Drama drama) {
return drama.isHot || drama.viewCount >= homeHotViewThreshold;
}
bool matchesHomeTag(Drama drama, String tagId) {
if (tagId == 'hot') return isHotDrama(drama);
return tagMatchesText(tagId, drama.genre) ||
tagMatchesText(tagId, drama.category);
}
bool tagMatchesText(String tagId, String text) {
if (tagId == 'hot') return false;
final normalized = text.toLowerCase();
final aliases = _tagAliases[tagId] ?? const [];
return aliases.any(normalized.contains);
}
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../core/widgets/catalog_debug_info.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
/// 首页目录加载失败与重试入口。
class HomeFailureView extends StatelessWidget {
const HomeFailureView({
super.key,
this.error,
required this.onRetry,
this.onEditEndpoint,
});
final String? error;
final VoidCallback onRetry;
final VoidCallback? onEditEndpoint;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return EmptyState(
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: onRetry,
footer: kDebugMode
? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint)
: null,
);
}
}
+225
View File
@@ -0,0 +1,225 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import 'home_drama_classification.dart';
const double _coverAspectRatio = 5 / 6;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
enum HomeDramaBadge { hot, newRelease, anime }
class HomeFeedCard extends StatelessWidget {
const HomeFeedCard({
super.key,
required this.drama,
required this.onTap,
this.now,
});
final Drama drama;
final VoidCallback onTap;
final DateTime? now;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: Key('home-feed-card-${drama.id}'),
decoration: BoxDecoration(
color: AppColors.contentCard,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
border: Border.all(color: AppColors.contentBorder),
),
child: Material(
color: AppColors.transparent,
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.homeCardTitle,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
const SizedBox(height: AppDimens.spaceXs),
_tags(),
],
),
),
),
],
),
),
),
);
}
Widget _cover(AppLocalizations l10n) {
final badge = _badge();
return AspectRatio(
aspectRatio: _coverAspectRatio,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
key: Key('home-feed-cover-${drama.id}'),
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
if (badge != null)
Positioned(
top: AppDimens.spaceXs,
left: AppDimens.spaceXs,
child: _badgeChip(l10n, badge),
),
Positioned(
left: AppDimens.spaceXs,
bottom: AppDimens.spaceXs,
child: _views(),
),
],
),
);
}
HomeDramaBadge? _badge() {
if (drama.dramaType.toUpperCase() == 'ANIME') {
return HomeDramaBadge.anime;
}
if (isNewDrama(drama, now: now)) return HomeDramaBadge.newRelease;
if (isHotDrama(drama)) {
return HomeDramaBadge.hot;
}
return null;
}
Widget _badgeChip(AppLocalizations l10n, HomeDramaBadge badge) {
final (label, color) = switch (badge) {
HomeDramaBadge.hot => (l10n.homeBadgeHot, AppColors.badgeHot),
HomeDramaBadge.newRelease => (l10n.homeBadgeNew, AppColors.badgeNew),
HomeDramaBadge.anime => (l10n.homeBadgeAnime, AppColors.badgeAnime),
};
return Container(
key: Key('home-feed-badge-${badge.name}-${drama.id}'),
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceSm,
vertical: 2,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppDimens.chipRadius),
),
child: Text(
label,
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.homeCardBadge,
fontWeight: FontWeight.w700,
letterSpacing: 0,
),
),
);
}
Widget _views() {
return Container(
key: Key('home-feed-views-${drama.id}'),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: AppColors.playerOverlayPill,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
size: AppDimens.homeCardMeta,
color: AppColors.navSelected,
),
const SizedBox(width: 1),
Text(
formatInteractionCount(drama.viewCount),
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.homeCardMeta,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
),
],
),
);
}
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'),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.chipRadius),
),
child: Text(
tag,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.homeTagFont,
letterSpacing: 0,
),
),
),
),
),
],
);
}
List<String> _cardTags() {
final seen = <String>{};
return [drama.genre, drama.category]
.expand((value) => value.split(_tagSplitter))
.map((value) => value.trim())
.where((value) => value.isNotEmpty && seen.add(value))
.take(2)
.toList(growable: false);
}
}
+294
View File
@@ -0,0 +1,294 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/content_category.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/data/services/category_controller.dart';
import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/data/services/wallet_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/paged_list_footer.dart';
import '../../../l10n/app_localizations.dart';
import 'home_drama_classification.dart';
import 'home_feed_card.dart';
import 'home_grid_header.dart';
const double _loadMoreThreshold = 240;
const List<String> _costumeAliases = ['古装', 'costume', 'period'];
const List<String> _communityAliases = ['社群', 'community', 'komunitas'];
class HomeFeedGrid extends StatefulWidget {
const HomeFeedGrid({
super.key,
required this.feedController,
required this.callbacks,
required this.onOpenDrama,
});
final HomeFeedController feedController;
final HomeGridCallbacks callbacks;
final ValueChanged<Drama> onOpenDrama;
@override
State<HomeFeedGrid> createState() => _HomeFeedGridState();
}
class _HomeFeedGridState extends State<HomeFeedGrid> {
String? _selectedCategory;
String? _selectedTag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>().categories;
final auth = context.watch<AuthController>();
final wallet = context.watch<WalletController>();
final shortcuts = _categoryShortcuts(categories, l10n);
final tags = _availableTags(l10n);
final activeCategory =
shortcuts.any((shortcut) => shortcut.categoryName == _selectedCategory)
? _selectedCategory
: 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;
return ColoredBox(
key: const Key('home-grid'),
color: AppColors.appBackground,
child: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.maxWidth -
AppDimens.pageHorizontal * 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.pageHorizontal,
),
sliver: SliverGrid(
key: const Key('home-grid-cards'),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridGap,
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();
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(widget.feedController.loadMore());
}
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;
}
ContentCategory? _findCategory(
List<ContentCategory> categories,
List<String> aliases,
) {
for (final category in categories) {
final name = category.name.toLowerCase();
if (aliases.any(name.contains)) return category;
}
return null;
}
List<HomeGridTagOption> _availableTags(AppLocalizations l10n) {
final definitions = [
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);
}
Widget _emptyState(AppLocalizations l10n, String? category, String? tag) {
if (widget.feedController.lastError != null) {
return EmptyState(
key: const Key('home-grid-error'),
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: () => unawaited(widget.feedController.refreshCurrent()),
);
}
final filtered = category != null || tag != null;
return EmptyState(
key: const Key('home-grid-empty'),
icon: Icons.movie_filter_outlined,
title: filtered ? l10n.homeGridFilterEmpty : l10n.noStories,
subtitle: filtered ? null : l10n.emptyHint,
actionLabel: filtered ? l10n.homeGridClearFilter : null,
onAction: filtered
? () => setState(() {
_selectedCategory = null;
_selectedTag = null;
})
: null,
);
}
}
@@ -0,0 +1,365 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
class HomeGridCategoryShortcut {
const HomeGridCategoryShortcut({
required this.label,
required this.categoryName,
});
final String label;
final String categoryName;
}
class HomeGridTagOption {
const HomeGridTagOption({required this.id, required this.label});
final String id;
final String label;
}
class HomeGridCallbacks {
const 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;
}
class HomeGridHeader extends StatelessWidget {
const HomeGridHeader({
super.key,
required this.callbacks,
required this.categories,
required this.tags,
required this.selectedCategory,
required this.selectedTag,
required this.onCategorySelected,
required this.onTagSelected,
this.walletBadge,
});
final HomeGridCallbacks callbacks;
final List<HomeGridCategoryShortcut> categories;
final List<HomeGridTagOption> tags;
final String? selectedCategory;
final String? selectedTag;
final ValueChanged<String?> onCategorySelected;
final ValueChanged<String?> onTagSelected;
final int? walletBadge;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
key: const Key('home-grid-header'),
children: [_searchRow(l10n), _primaryRow(l10n), _tagRow(l10n)],
);
}
Widget _searchRow(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceSm,
AppDimens.pageHorizontal,
AppDimens.spaceSm,
),
child: Row(
children: [
Expanded(
child: InkWell(
key: const Key('home-search-entry'),
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
onTap: callbacks.onSearch,
child: Container(
height: AppDimens.searchHeight,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceMd,
),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
),
child: Row(
children: [
const Icon(
Icons.search_rounded,
size: AppDimens.homeHeaderIcon,
color: AppColors.textSecondary,
),
const SizedBox(width: AppDimens.spaceSm),
Expanded(
child: Text(
l10n.homeSearchHint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.body,
),
),
),
],
),
),
),
),
const SizedBox(width: AppDimens.spaceSm),
_headerAction(
key: const Key('home-vip-entry'),
icon: Icons.workspace_premium_outlined,
tooltip: l10n.vipMembership,
onTap: callbacks.onOpenVip,
),
const SizedBox(width: AppDimens.spaceXs),
_headerAction(
key: const Key('home-wallet-entry'),
icon: Icons.card_giftcard_rounded,
tooltip: l10n.wallet,
onTap: callbacks.onOpenWallet,
badge: walletBadge,
),
],
),
);
}
Widget _headerAction({
required Key key,
required IconData icon,
required String tooltip,
required VoidCallback onTap,
int? badge,
}) {
return SizedBox.square(
dimension: AppDimens.homeIconSlot,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Tooltip(
message: tooltip,
child: Material(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
child: InkWell(
key: key,
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
onTap: onTap,
child: Icon(
icon,
size: AppDimens.homeHeaderIcon,
color: AppColors.navSelected,
),
),
),
),
),
if (badge != null && badge > 0)
Positioned(
key: const Key('home-wallet-badge'),
top: -5,
right: -6,
child: Container(
constraints: const BoxConstraints(minWidth: 20, minHeight: 16),
padding: const EdgeInsets.symmetric(horizontal: 5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.badgeHot,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
formatInteractionCount(badge),
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.homeCardBadge,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
Widget _primaryRow(AppLocalizations l10n) {
return SingleChildScrollView(
key: const Key('home-primary-sections'),
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
child: Row(
children: [
_primaryItem(
key: const Key('home-section-featured'),
label: l10n.homeSectionFeatured,
selected: selectedCategory == null,
onTap: () => onCategorySelected(null),
),
_primaryItem(
key: const Key('home-section-new'),
label: l10n.homeSectionNew,
selected: false,
onTap: callbacks.onOpenNewReleases,
),
_primaryItem(
key: const Key('home-section-categories'),
label: l10n.homeSectionCategories,
selected: false,
onTap: callbacks.onOpenCategories,
),
_primaryItem(
key: const Key('home-section-ranking'),
label: l10n.homeSectionRanking,
selected: false,
onTap: callbacks.onOpenRanking,
),
for (final category in categories)
_primaryItem(
key: Key('home-section-category-${category.categoryName}'),
label: category.label,
selected: selectedCategory == category.categoryName,
onTap: () => onCategorySelected(category.categoryName),
),
],
),
);
}
Widget _primaryItem({
required Key key,
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return InkWell(
key: key,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(right: AppDimens.spaceXl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: selected
? AppColors.navSelected
: AppColors.textSecondary,
fontSize: AppDimens.homeSectionFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: 0,
),
),
const SizedBox(height: AppDimens.spaceXs),
Container(
height: AppDimens.homeSectionIndicator,
width: selected ? 20 : 0,
decoration: BoxDecoration(
color: AppColors.navSelected,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
),
],
),
),
);
}
Widget _tagRow(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceMd,
AppDimens.pageHorizontal,
AppDimens.spaceSm,
),
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
key: const Key('home-secondary-tags'),
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final tag in tags) ...[
_tagChip(tag),
const SizedBox(width: AppDimens.spaceSm),
],
],
),
),
),
const SizedBox(width: AppDimens.spaceXs),
Tooltip(
message: l10n.filter,
child: SizedBox.square(
dimension: AppDimens.tapTarget,
child: Material(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
child: InkWell(
key: const Key('home-grid-filter'),
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
onTap: callbacks.onOpenFilter,
child: const Icon(
Icons.filter_list_rounded,
color: AppColors.navSelected,
size: AppDimens.homeHeaderIcon,
),
),
),
),
),
],
),
);
}
Widget _tagChip(HomeGridTagOption tag) {
final selected = selectedTag == tag.id;
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.contentChip : AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
tag.label,
style: TextStyle(
color: selected ? AppColors.navSelected : AppColors.textSecondary,
fontSize: AppDimens.homeSectionFont - 2,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
letterSpacing: 0,
),
),
),
);
}
}