387 lines
12 KiB
Dart
387 lines
12 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/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_category_dramas.dart';
|
|
import 'home_feed_card.dart';
|
|
import 'home_grid_header.dart';
|
|
import 'home_ranking_dramas.dart';
|
|
|
|
const double _loadMoreThreshold = 240;
|
|
|
|
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> {
|
|
int _sectionIndex = 0;
|
|
final PageController _pageController = PageController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _pageController.hasClients) {
|
|
_pageController.jumpToPage(_sectionIndex);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pageController.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,
|
|
Localizations.localeOf(context).languageCode,
|
|
);
|
|
final walletBadge = auth.isSignedIn && wallet.snapshot != null
|
|
? wallet.balance
|
|
: null;
|
|
return DecoratedBox(
|
|
key: const Key('home-grid'),
|
|
decoration: const BoxDecoration(color: AppColors.appBackground),
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
HomeGridHeader(
|
|
callbacks: widget.callbacks,
|
|
categories: shortcuts,
|
|
selectedIndex: _sectionIndex,
|
|
onSectionSelected: _selectSection,
|
|
walletBadge: walletBadge,
|
|
),
|
|
Expanded(
|
|
child: ListenableBuilder(
|
|
listenable: widget.feedController,
|
|
builder: (context, _) => PageView.builder(
|
|
key: const Key('home-primary-page-view'),
|
|
controller: _pageController,
|
|
itemCount: shortcuts.isEmpty ? 1 : shortcuts.length,
|
|
onPageChanged: (index) =>
|
|
setState(() => _sectionIndex = index),
|
|
itemBuilder: (context, index) {
|
|
if (shortcuts.isEmpty) {
|
|
return _categoryPlaceholder(
|
|
l10n,
|
|
context.watch<CategoryController>(),
|
|
catalog,
|
|
);
|
|
}
|
|
final category = shortcuts[index];
|
|
return _KeepAlivePage(
|
|
key: PageStorageKey(
|
|
'home-primary-page-${category.categoryId}',
|
|
),
|
|
child: _tabGrid(
|
|
context,
|
|
l10n,
|
|
category: category,
|
|
catalog: catalog,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _selectSection(int index) {
|
|
if (index == _sectionIndex) return;
|
|
if (!_pageController.hasClients) {
|
|
setState(() => _sectionIndex = index);
|
|
return;
|
|
}
|
|
unawaited(
|
|
_pageController.animateToPage(
|
|
index,
|
|
duration: AppDimens.homePrimaryTabAnimationDuration,
|
|
curve: Curves.easeOut,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _categoryPlaceholder(
|
|
AppLocalizations l10n,
|
|
CategoryController controller,
|
|
CatalogController catalog,
|
|
) {
|
|
if (catalog.error != null) {
|
|
return _catalogErrorState(l10n, catalog);
|
|
}
|
|
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 HomeGridCategoryShortcut category,
|
|
required CatalogController catalog,
|
|
}) {
|
|
final controller = context.read<CategoryController>();
|
|
if (category.isRanking) {
|
|
return HomeRankingDramas(
|
|
categoryId: category.categoryId,
|
|
onOpenDrama: widget.onOpenDrama,
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
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,
|
|
);
|
|
}
|
|
final dramas = _dramasFor(category);
|
|
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: null,
|
|
physics: AppPullToRefresh.physics,
|
|
slivers: [
|
|
if (dramas.isEmpty)
|
|
SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: _emptyState(l10n),
|
|
)
|
|
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: true,
|
|
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,
|
|
String languageCode,
|
|
) {
|
|
return [
|
|
for (final category in categories)
|
|
HomeGridCategoryShortcut(
|
|
label: category.localizedName(languageCode),
|
|
categoryName: category.name,
|
|
categoryId: category.id,
|
|
tab: HomePrimaryTab.categories,
|
|
layoutType: category.layoutType,
|
|
),
|
|
];
|
|
}
|
|
|
|
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) {
|
|
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()),
|
|
);
|
|
}
|
|
return EmptyState(
|
|
key: const Key('home-grid-empty'),
|
|
icon: Icons.movie_filter_outlined,
|
|
title: l10n.emptyContent,
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|