Files
ReelShort/lib/pages/home/widgets/home_feed_grid.dart
T

410 lines
14 KiB
Dart

// 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/catalog_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/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_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> {
HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured;
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);
}
});
}
@override
void dispose() {
_pageController.dispose();
for (final controller in _scrollControllers.values) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>().categories;
final catalog = context.watch<CatalogController>();
final auth = context.watch<AuthController>();
final wallet = context.watch<WalletController>();
final shortcuts = _categoryShortcuts(categories, l10n);
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,
),
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeGridHeader(
callbacks: widget.callbacks,
categories: shortcuts,
selectedPrimary: _selectedPrimary,
onPrimarySelected: _selectPrimary,
walletBadge: walletBadge,
),
Expanded(
child: ListenableBuilder(
listenable: widget.feedController,
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,
),
),
),
),
),
],
),
),
);
}
void _selectPrimary(HomePrimaryTab tab) {
if (tab == _selectedPrimary) return;
if (!_pageController.hasClients) {
setState(() => _selectedPrimary = tab);
return;
}
unawaited(
_pageController.animateToPage(
tab.index,
duration: AppDimens.homePrimaryTabAnimationDuration,
curve: Curves.easeOut,
),
);
}
Widget _tabGrid(
BuildContext context,
AppLocalizations l10n, {
required HomePrimaryTab tab,
required CatalogController catalog,
required List<HomeGridCategoryShortcut> shortcuts,
}) {
if (catalog.error != null && widget.feedController.dramas.isEmpty) {
return _catalogErrorState(l10n, catalog);
}
if ((!catalog.settled || catalog.loading) &&
widget.feedController.dramas.isEmpty) {
return const DramaGridSkeleton(
key: Key('home-grid-skeleton'),
columns: 3,
horizontalPadding: AppDimens.feedPageHorizontal,
crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridRowGap,
);
}
if (tab == HomePrimaryTab.ranking) {
return const RankingPage(embedded: true);
}
final tag = _selectedTags[tab];
final dramas = _dramasFor(tab, tag, shortcuts);
if (widget.feedController.loadingFirstPage && dramas.isEmpty) {
return const DramaGridSkeleton(
key: Key('home-grid-skeleton'),
columns: 3,
horizontalPadding: AppDimens.feedPageHorizontal,
crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridRowGap,
);
}
return LayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.maxWidth -
AppDimens.feedPageHorizontal * 2 -
AppDimens.homeGridGap * 2) /
3;
final itemExtent =
itemWidth / AppDimens.cardCoverAspectRatio +
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 (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,
errorMessage: widget.feedController.lastError == null
? null
: l10n.catalogError,
onRetry: widget.feedController.loadMore,
),
),
),
],
),
),
);
},
);
}
Widget _catalogErrorState(AppLocalizations l10n, CatalogController catalog) {
final theme = Theme.of(context);
return Theme(
data: theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
onSurface: AppColors.textTertiary,
),
),
child: EmptyState(
key: const Key('home-grid-error'),
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: catalog.load,
),
);
}
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;
}
List<HomeGridCategoryShortcut> _categoryShortcuts(
List<ContentCategory> categories,
AppLocalizations l10n,
) {
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,
),
];
}
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;
}
}
Widget _emptyState(AppLocalizations l10n, HomePrimaryTab tab, 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 =
tab == HomePrimaryTab.costume ||
tab == HomePrimaryTab.community ||
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(() => _selectedTags[tab] = null)
: null,
);
}
}
class _KeepAlivePage extends StatefulWidget {
const _KeepAlivePage({super.key, required this.child});
final Widget child;
@override
State<_KeepAlivePage> createState() => _KeepAlivePageState();
}
class _KeepAlivePageState extends State<_KeepAlivePage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}