feat(app): 客户端展示多语言并支持筛选分页
This commit is contained in:
1 parent
8481611372
commit
8b50b5bd06
68 files changed
+2742
-1120
No files matched your search
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/remote/catalog_api.dart';
|
||||
import '../../core/data/services/home_feed_controller.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
@@ -209,6 +210,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
Widget _recommend(BuildContext context) {
|
||||
return RecommendPage(
|
||||
feedController: widget.feedController,
|
||||
catalogApi: _catalogApi(context),
|
||||
active: logic.tab == 1,
|
||||
videoOnly: logic.videoImmersive,
|
||||
onImmersiveChanged: logic.onImmersiveChanged,
|
||||
@@ -216,6 +218,14 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
);
|
||||
}
|
||||
|
||||
CatalogApi? _catalogApi(BuildContext context) {
|
||||
try {
|
||||
return context.read<CatalogApi>();
|
||||
} on ProviderNotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _openSearch(BuildContext context) {
|
||||
Navigator.of(
|
||||
context,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/category_controller.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/app_pull_to_refresh.dart';
|
||||
import '../../../core/widgets/drama_grid_skeleton.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../../../core/widgets/paged_list_footer.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'home_feed_card.dart';
|
||||
|
||||
const double _loadMoreThreshold = 240;
|
||||
|
||||
/// 首页分类下的短剧:按分类分页,滑到底加载下一页。
|
||||
class HomeCategoryDramas extends StatefulWidget {
|
||||
const HomeCategoryDramas({
|
||||
super.key,
|
||||
required this.onOpenDrama,
|
||||
this.lockedCategoryId,
|
||||
});
|
||||
|
||||
final ValueChanged<Drama> onOpenDrama;
|
||||
final String? lockedCategoryId;
|
||||
|
||||
@override
|
||||
State<HomeCategoryDramas> createState() => _HomeCategoryDramasState();
|
||||
}
|
||||
|
||||
class _HomeCategoryDramasState extends State<HomeCategoryDramas> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _ensureOpened());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(HomeCategoryDramas oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.lockedCategoryId != widget.lockedCategoryId) {
|
||||
_ensureOpened();
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureOpened() {
|
||||
if (!mounted) return;
|
||||
final controller = context.read<CategoryController>();
|
||||
final locked = widget.lockedCategoryId;
|
||||
if (locked == null || locked.isEmpty) return;
|
||||
final slot = controller.slotOf(locked);
|
||||
if (slot.loading || slot.pageIndex > 0) return;
|
||||
unawaited(controller.openCategory(locked));
|
||||
}
|
||||
|
||||
bool _onScroll(ScrollNotification notification) {
|
||||
final categoryId = widget.lockedCategoryId;
|
||||
if (categoryId == null || categoryId.isEmpty) return false;
|
||||
if (notification.depth != 0) return false;
|
||||
if (notification.metrics.extentAfter > _loadMoreThreshold) return false;
|
||||
if (notification is! ScrollUpdateNotification &&
|
||||
notification is! ScrollEndNotification &&
|
||||
notification is! OverscrollNotification) {
|
||||
return false;
|
||||
}
|
||||
unawaited(context.read<CategoryController>().loadMore(categoryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final controller = context.watch<CategoryController>();
|
||||
final categoryId = widget.lockedCategoryId ?? '';
|
||||
final slot = categoryId.isEmpty ? null : controller.slotOf(categoryId);
|
||||
final dramas = slot?.dramas ?? const <Drama>[];
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: AppPullToRefresh(
|
||||
onRefresh: () async {
|
||||
if (categoryId.isNotEmpty) {
|
||||
await controller.openCategory(categoryId);
|
||||
}
|
||||
},
|
||||
child: _body(context, l10n, controller, categoryId, dramas),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
CategoryController controller,
|
||||
String categoryId,
|
||||
List<Drama> dramas,
|
||||
) {
|
||||
final slot = categoryId.isEmpty ? null : controller.slotOf(categoryId);
|
||||
if (slot != null && slot.loading && dramas.isEmpty) {
|
||||
return const DramaGridSkeleton(
|
||||
key: Key('home-grid-skeleton'),
|
||||
columns: 3,
|
||||
horizontalPadding: AppDimens.feedPageHorizontal,
|
||||
crossAxisSpacing: AppDimens.homeGridGap,
|
||||
mainAxisSpacing: AppDimens.homeGridRowGap,
|
||||
);
|
||||
}
|
||||
if (dramas.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('home-grid-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.emptyContent,
|
||||
);
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final itemWidth =
|
||||
(constraints.maxWidth -
|
||||
AppDimens.feedPageHorizontal * 2 -
|
||||
AppDimens.homeGridGap * 2) /
|
||||
3;
|
||||
final itemExtent =
|
||||
itemWidth / AppDimens.cardCoverAspectRatio +
|
||||
AppDimens.homeCardBodyExtent;
|
||||
return CustomScrollView(
|
||||
key: const Key('home-grid-scroll'),
|
||||
physics: AppPullToRefresh.physics,
|
||||
slivers: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
|
||||
child: PagedListFooter(
|
||||
loading: slot != null && slot.loadingMore,
|
||||
hasMore: slot?.hasMore ?? false,
|
||||
hasItems: dramas.isNotEmpty,
|
||||
errorMessage: null,
|
||||
onRetry: () => context.read<CategoryController>().loadMore(
|
||||
categoryId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,9 +99,9 @@ class HomeDramaCard extends StatelessWidget {
|
||||
children: [
|
||||
_cover(),
|
||||
const SizedBox(height: _coverTitleGap),
|
||||
_title(),
|
||||
_title(context),
|
||||
const SizedBox(height: _lineGap),
|
||||
_genre(),
|
||||
_genre(context),
|
||||
const SizedBox(height: _lineGap),
|
||||
_metadata(),
|
||||
],
|
||||
@@ -211,9 +211,9 @@ class HomeDramaCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _title() {
|
||||
Widget _title(BuildContext context) {
|
||||
return Text(
|
||||
drama.title,
|
||||
drama.localizedTitle(Localizations.localeOf(context).languageCode),
|
||||
key: Key('home-drama-title-${drama.id}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -225,8 +225,11 @@ class HomeDramaCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _genre() {
|
||||
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||
Widget _genre(BuildContext context) {
|
||||
final labels = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
|
||||
final genre = labels.isEmpty
|
||||
? (drama.genre.isEmpty ? drama.category : drama.genre)
|
||||
: labels.join(' / ');
|
||||
return Text(
|
||||
genre.isEmpty ? l10n.videoPending : genre,
|
||||
key: Key('home-drama-genre-${drama.id}'),
|
||||
|
||||
@@ -8,28 +8,21 @@ import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/cover_play_count.dart';
|
||||
import '../../../core/widgets/drama_cover_badge_ribbon.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'home_drama_classification.dart';
|
||||
|
||||
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 Material(
|
||||
key: Key('home-feed-card-${drama.id}'),
|
||||
color: AppColors.transparent,
|
||||
@@ -40,7 +33,7 @@ class HomeFeedCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
_cover(l10n),
|
||||
_cover(context),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
@@ -51,7 +44,7 @@ class HomeFeedCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
drama.localizedTitle(Localizations.localeOf(context).languageCode),
|
||||
key: Key('home-feed-title-${drama.id}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -64,7 +57,7 @@ class HomeFeedCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.cardTitleTagGap),
|
||||
_tags(),
|
||||
_tags(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -75,8 +68,8 @@ class HomeFeedCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cover(AppLocalizations l10n) {
|
||||
final badge = _badge();
|
||||
Widget _cover(BuildContext context) {
|
||||
final label = drama.localizedTag(Localizations.localeOf(context).languageCode);
|
||||
return ClipRRect(
|
||||
key: Key('home-feed-cover-clip-${drama.id}'),
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
||||
@@ -92,14 +85,14 @@ class HomeFeedCard extends StatelessWidget {
|
||||
showFallbackIcon: true,
|
||||
fallbackColor: AppColors.contentSurface,
|
||||
),
|
||||
if (badge != null)
|
||||
if (label.isNotEmpty)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: DramaCoverBadgeRibbon(
|
||||
label: _badgeLabel(l10n, badge),
|
||||
color: _badgeColor(badge),
|
||||
keyPrefix: '${drama.id}-${badge.name}',
|
||||
label: label,
|
||||
color: drama.tagRibbonColor(AppColors.badgeHot),
|
||||
keyPrefix: '${drama.id}-tag',
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
@@ -122,35 +115,8 @@ class HomeFeedCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
String _badgeLabel(AppLocalizations l10n, HomeDramaBadge badge) {
|
||||
return switch (badge) {
|
||||
HomeDramaBadge.hot => l10n.homeBadgeHot,
|
||||
HomeDramaBadge.newRelease => l10n.homeBadgeNew,
|
||||
HomeDramaBadge.anime => l10n.homeBadgeAnime,
|
||||
};
|
||||
}
|
||||
|
||||
Color _badgeColor(HomeDramaBadge badge) {
|
||||
return switch (badge) {
|
||||
HomeDramaBadge.hot => AppColors.badgeHot,
|
||||
HomeDramaBadge.newRelease => AppColors.badgeNew,
|
||||
HomeDramaBadge.anime => AppColors.badgeAnime,
|
||||
};
|
||||
}
|
||||
|
||||
Widget _tags() {
|
||||
final tags = _cardTags();
|
||||
Widget _tags(BuildContext context) {
|
||||
final tags = _cardTags(context);
|
||||
if (tags.isEmpty) return const SizedBox.shrink();
|
||||
return Text(
|
||||
tags.join(' | '),
|
||||
@@ -166,7 +132,9 @@ class HomeFeedCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _cardTags() {
|
||||
List<String> _cardTags(BuildContext context) {
|
||||
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
|
||||
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
||||
final seen = <String>{};
|
||||
return [drama.genre, drama.category]
|
||||
.expand((value) => value.split(_tagSplitter))
|
||||
|
||||
@@ -14,20 +14,16 @@ 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/theme/app_theme.dart';
|
||||
import '../../../core/widgets/app_pull_to_refresh.dart';
|
||||
import '../../../core/widgets/drama_grid_skeleton.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../../../core/widgets/paged_list_footer.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../ranking/ranking_page.dart';
|
||||
import 'home_drama_classification.dart';
|
||||
import 'home_category_dramas.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({
|
||||
@@ -46,21 +42,15 @@ class HomeFeedGrid extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured;
|
||||
int _sectionIndex = 0;
|
||||
final PageController _pageController = PageController();
|
||||
final Map<HomePrimaryTab, ScrollController> _scrollControllers = {
|
||||
for (final tab in HomePrimaryTab.values) tab: ScrollController(),
|
||||
};
|
||||
final Map<HomePrimaryTab, String?> _selectedTags = {
|
||||
for (final tab in HomePrimaryTab.values) tab: null,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _pageController.hasClients) {
|
||||
_pageController.jumpToPage(_selectedPrimary.index);
|
||||
_pageController.jumpToPage(_sectionIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -68,9 +58,6 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
for (final controller in _scrollControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -81,17 +68,16 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
final catalog = context.watch<CatalogController>();
|
||||
final auth = context.watch<AuthController>();
|
||||
final wallet = context.watch<WalletController>();
|
||||
final shortcuts = _categoryShortcuts(categories, l10n);
|
||||
final shortcuts = _categoryShortcuts(
|
||||
categories,
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
final walletBadge = auth.isSignedIn && wallet.snapshot != null
|
||||
? wallet.balance
|
||||
: null;
|
||||
final ranking = _selectedPrimary == HomePrimaryTab.ranking;
|
||||
return DecoratedBox(
|
||||
key: const Key('home-grid'),
|
||||
decoration: BoxDecoration(
|
||||
color: ranking ? null : AppColors.appBackground,
|
||||
gradient: ranking ? AppTheme.pageGradient : null,
|
||||
),
|
||||
decoration: const BoxDecoration(color: AppColors.appBackground),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
@@ -100,8 +86,8 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
HomeGridHeader(
|
||||
callbacks: widget.callbacks,
|
||||
categories: shortcuts,
|
||||
selectedPrimary: _selectedPrimary,
|
||||
onPrimarySelected: _selectPrimary,
|
||||
selectedIndex: _sectionIndex,
|
||||
onSectionSelected: _selectSection,
|
||||
walletBadge: walletBadge,
|
||||
),
|
||||
Expanded(
|
||||
@@ -110,22 +96,29 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
builder: (context, _) => PageView.builder(
|
||||
key: const Key('home-primary-page-view'),
|
||||
controller: _pageController,
|
||||
itemCount: HomePrimaryTab.values.length,
|
||||
onPageChanged: (index) => setState(
|
||||
() => _selectedPrimary = HomePrimaryTab.values[index],
|
||||
),
|
||||
itemBuilder: (context, index) => _KeepAlivePage(
|
||||
key: PageStorageKey(
|
||||
'home-primary-page-${HomePrimaryTab.values[index].name}',
|
||||
),
|
||||
child: _tabGrid(
|
||||
context,
|
||||
l10n,
|
||||
tab: HomePrimaryTab.values[index],
|
||||
catalog: catalog,
|
||||
shortcuts: shortcuts,
|
||||
),
|
||||
),
|
||||
itemCount: shortcuts.isEmpty ? 1 : shortcuts.length,
|
||||
onPageChanged: (index) =>
|
||||
setState(() => _sectionIndex = index),
|
||||
itemBuilder: (context, index) {
|
||||
if (shortcuts.isEmpty) {
|
||||
return _categoryPlaceholder(
|
||||
l10n,
|
||||
context.watch<CategoryController>(),
|
||||
);
|
||||
}
|
||||
final category = shortcuts[index];
|
||||
return _KeepAlivePage(
|
||||
key: PageStorageKey(
|
||||
'home-primary-page-${category.categoryId}',
|
||||
),
|
||||
child: _tabGrid(
|
||||
context,
|
||||
l10n,
|
||||
category: category,
|
||||
catalog: catalog,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -135,28 +128,54 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
);
|
||||
}
|
||||
|
||||
void _selectPrimary(HomePrimaryTab tab) {
|
||||
if (tab == _selectedPrimary) return;
|
||||
void _selectSection(int index) {
|
||||
if (index == _sectionIndex) return;
|
||||
if (!_pageController.hasClients) {
|
||||
setState(() => _selectedPrimary = tab);
|
||||
setState(() => _sectionIndex = index);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
_pageController.animateToPage(
|
||||
tab.index,
|
||||
index,
|
||||
duration: AppDimens.homePrimaryTabAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _categoryPlaceholder(
|
||||
AppLocalizations l10n,
|
||||
CategoryController controller,
|
||||
) {
|
||||
if (controller.loading) {
|
||||
return const DramaGridSkeleton(
|
||||
key: Key('home-grid-skeleton'),
|
||||
columns: 3,
|
||||
horizontalPadding: AppDimens.feedPageHorizontal,
|
||||
crossAxisSpacing: AppDimens.homeGridGap,
|
||||
mainAxisSpacing: AppDimens.homeGridRowGap,
|
||||
);
|
||||
}
|
||||
return EmptyState(
|
||||
key: const Key('home-grid-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.emptyContent,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabGrid(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n, {
|
||||
required HomePrimaryTab tab,
|
||||
required HomeGridCategoryShortcut category,
|
||||
required CatalogController catalog,
|
||||
required List<HomeGridCategoryShortcut> shortcuts,
|
||||
}) {
|
||||
final controller = context.read<CategoryController>();
|
||||
if (controller.dramaPageLoader != null && category.categoryId.isNotEmpty) {
|
||||
return HomeCategoryDramas(
|
||||
lockedCategoryId: category.categoryId,
|
||||
onOpenDrama: widget.onOpenDrama,
|
||||
);
|
||||
}
|
||||
if (catalog.error != null && widget.feedController.dramas.isEmpty) {
|
||||
return _catalogErrorState(l10n, catalog);
|
||||
}
|
||||
@@ -170,11 +189,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
mainAxisSpacing: AppDimens.homeGridRowGap,
|
||||
);
|
||||
}
|
||||
if (tab == HomePrimaryTab.ranking) {
|
||||
return const RankingPage(embedded: true);
|
||||
}
|
||||
final tag = _selectedTags[tab];
|
||||
final dramas = _dramasFor(tab, tag, shortcuts);
|
||||
final dramas = _dramasFor(category);
|
||||
if (widget.feedController.loadingFirstPage && dramas.isEmpty) {
|
||||
return const DramaGridSkeleton(
|
||||
key: Key('home-grid-skeleton'),
|
||||
@@ -201,7 +216,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
onRefresh: _refresh,
|
||||
child: CustomScrollView(
|
||||
key: const Key('home-grid-scroll'),
|
||||
controller: _scrollControllers[tab],
|
||||
controller: null,
|
||||
physics: AppPullToRefresh.physics,
|
||||
slivers: [
|
||||
if (dramas.isEmpty)
|
||||
@@ -237,6 +252,8 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
|
||||
child: PagedListFooter(
|
||||
loading: widget.feedController.loadingMore,
|
||||
hasMore: widget.feedController.hasMore,
|
||||
hasItems: true,
|
||||
errorMessage: widget.feedController.lastError == null
|
||||
? null
|
||||
: l10n.catalogError,
|
||||
@@ -289,75 +306,31 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
|
||||
List<HomeGridCategoryShortcut> _categoryShortcuts(
|
||||
List<ContentCategory> categories,
|
||||
AppLocalizations l10n,
|
||||
String languageCode,
|
||||
) {
|
||||
final costume = _findCategory(categories, _costumeAliases);
|
||||
final community = _findCategory(categories, _communityAliases);
|
||||
return [
|
||||
HomeGridCategoryShortcut(
|
||||
label: l10n.homeSectionCostume,
|
||||
categoryName: costume?.name ?? '',
|
||||
tab: HomePrimaryTab.costume,
|
||||
),
|
||||
HomeGridCategoryShortcut(
|
||||
label: l10n.homeSectionCommunity,
|
||||
categoryName: community?.name ?? '',
|
||||
tab: HomePrimaryTab.community,
|
||||
),
|
||||
for (final category in categories)
|
||||
HomeGridCategoryShortcut(
|
||||
label: category.localizedName(languageCode),
|
||||
categoryName: category.name,
|
||||
categoryId: category.id,
|
||||
tab: HomePrimaryTab.categories,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
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<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;
|
||||
}
|
||||
List<Drama> _dramasFor(HomeGridCategoryShortcut category) {
|
||||
final dramas = widget.feedController.dramas;
|
||||
final categoryName = category.categoryName.toLowerCase();
|
||||
if (categoryName.isEmpty) return dramas;
|
||||
final matched = dramas
|
||||
.where(
|
||||
(drama) =>
|
||||
drama.category.toLowerCase().contains(categoryName) ||
|
||||
drama.genre.toLowerCase().contains(categoryName),
|
||||
)
|
||||
.toList(growable: false);
|
||||
return matched.isEmpty ? dramas : matched;
|
||||
}
|
||||
|
||||
Widget _emptyState(AppLocalizations l10n) {
|
||||
|
||||
@@ -20,11 +20,13 @@ class HomeGridCategoryShortcut {
|
||||
const HomeGridCategoryShortcut({
|
||||
required this.label,
|
||||
required this.categoryName,
|
||||
required this.categoryId,
|
||||
required this.tab,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String categoryName;
|
||||
final String categoryId;
|
||||
final HomePrimaryTab tab;
|
||||
}
|
||||
|
||||
@@ -47,15 +49,15 @@ class HomeGridHeader extends StatelessWidget {
|
||||
super.key,
|
||||
required this.callbacks,
|
||||
required this.categories,
|
||||
required this.selectedPrimary,
|
||||
required this.onPrimarySelected,
|
||||
required this.selectedIndex,
|
||||
required this.onSectionSelected,
|
||||
this.walletBadge,
|
||||
});
|
||||
|
||||
final HomeGridCallbacks callbacks;
|
||||
final List<HomeGridCategoryShortcut> categories;
|
||||
final HomePrimaryTab selectedPrimary;
|
||||
final ValueChanged<HomePrimaryTab> onPrimarySelected;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSectionSelected;
|
||||
final int? walletBadge;
|
||||
|
||||
@override
|
||||
@@ -67,7 +69,7 @@ class HomeGridHeader extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
_searchRow(l10n),
|
||||
_primaryRow(l10n),
|
||||
_primaryRow(),
|
||||
const SizedBox(height: AppDimens.homeSecondaryGridGap),
|
||||
],
|
||||
);
|
||||
@@ -225,7 +227,73 @@ class HomeGridHeader extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _primaryRow(AppLocalizations l10n) {
|
||||
Widget _primaryRow() {
|
||||
return _CategoryBar(
|
||||
categories: categories,
|
||||
selectedIndex: selectedIndex,
|
||||
onSectionSelected: onSectionSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryBar extends StatefulWidget {
|
||||
const _CategoryBar({
|
||||
required this.categories,
|
||||
required this.selectedIndex,
|
||||
required this.onSectionSelected,
|
||||
});
|
||||
|
||||
final List<HomeGridCategoryShortcut> categories;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSectionSelected;
|
||||
|
||||
@override
|
||||
State<_CategoryBar> createState() => _CategoryBarState();
|
||||
}
|
||||
|
||||
class _CategoryBarState extends State<_CategoryBar> {
|
||||
final List<GlobalKey> _itemKeys = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncKeys();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_CategoryBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.categories.length != widget.categories.length) {
|
||||
_syncKeys();
|
||||
}
|
||||
if (oldWidget.selectedIndex != widget.selectedIndex ||
|
||||
oldWidget.categories.length != widget.categories.length) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected());
|
||||
}
|
||||
}
|
||||
|
||||
void _syncKeys() {
|
||||
_itemKeys
|
||||
..clear()
|
||||
..addAll(List.generate(widget.categories.length, (_) => GlobalKey()));
|
||||
}
|
||||
|
||||
void _revealSelected() {
|
||||
final index = widget.selectedIndex;
|
||||
if (!mounted || index < 0 || index >= _itemKeys.length) return;
|
||||
final target = _itemKeys[index].currentContext;
|
||||
if (target == null) return;
|
||||
Scrollable.ensureVisible(
|
||||
target,
|
||||
alignment: 0.5,
|
||||
duration: AppDimens.homePrimaryTabAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
key: const Key('home-primary-sections'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
@@ -234,60 +302,29 @@ class HomeGridHeader extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
_primaryItem(
|
||||
key: const Key('home-section-featured'),
|
||||
label: l10n.homeSectionFeatured,
|
||||
tab: HomePrimaryTab.featured,
|
||||
),
|
||||
_primaryItem(
|
||||
key: const Key('home-section-new'),
|
||||
label: l10n.homeSectionNew,
|
||||
tab: HomePrimaryTab.newReleases,
|
||||
),
|
||||
_primaryItem(
|
||||
key: const Key('home-section-categories'),
|
||||
label: l10n.homeSectionCategories,
|
||||
tab: HomePrimaryTab.categories,
|
||||
),
|
||||
_primaryItem(
|
||||
key: const Key('home-section-ranking'),
|
||||
label: l10n.homeSectionRanking,
|
||||
tab: HomePrimaryTab.ranking,
|
||||
),
|
||||
for (final category in categories)
|
||||
_primaryItem(
|
||||
key: Key(
|
||||
category.categoryName.isEmpty
|
||||
? 'home-section-${category.tab.name}'
|
||||
: 'home-section-category-${category.categoryName}',
|
||||
),
|
||||
label: category.label,
|
||||
tab: category.tab,
|
||||
),
|
||||
for (var index = 0; index < widget.categories.length; index++)
|
||||
_item(index),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _primaryItem({
|
||||
required Key key,
|
||||
required String label,
|
||||
required HomePrimaryTab tab,
|
||||
}) {
|
||||
final selected = selectedPrimary == tab;
|
||||
Widget _item(int index) {
|
||||
final category = widget.categories[index];
|
||||
final selected = widget.selectedIndex == index;
|
||||
return InkWell(
|
||||
key: key,
|
||||
onTap: () => onPrimarySelected(tab),
|
||||
key: _itemKeys[index],
|
||||
onTap: () => widget.onSectionSelected(index),
|
||||
child: Padding(
|
||||
key: Key('home-section-category-${category.categoryName}'),
|
||||
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}'),
|
||||
key: Key('home-primary-label-${category.categoryId}'),
|
||||
duration: AppDimens.homePrimaryTabAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
style: TextStyle(
|
||||
@@ -300,7 +337,7 @@ class HomeGridHeader extends StatelessWidget {
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
child: Text(label, textAlign: TextAlign.left),
|
||||
child: Text(category.label, textAlign: TextAlign.left),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user