295 lines
11 KiB
Dart
295 lines
11 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/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,
|
|
);
|
|
}
|
|
}
|