Files
ReelShort/lib/pages/discover/discover_view.dart
T

369 lines
10 KiB
Dart

// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/content_category.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/search_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
import 'category_dramas_page.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
import 'discover_types.dart';
import 'widgets/category_grid.dart';
import 'widgets/discover_header.dart';
import 'widgets/discover_search_field.dart';
import 'widgets/discover_search_results.dart';
import 'widgets/drama_grid.dart';
import 'widgets/feed_banner_carousel.dart';
import 'widgets/ranking_section.dart';
/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。
class DiscoverPage extends StatefulWidget {
const DiscoverPage({
super.key,
this.active = true,
this.searchRequest = 0,
this.focusSearch = false,
this.callbacks = const DiscoverPageCallbacks(),
});
final bool active;
final int searchRequest;
final bool focusSearch;
final DiscoverPageCallbacks callbacks;
@override
State<DiscoverPage> createState() => _DiscoverPageState();
}
class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic;
bool get embedded => widget.callbacks.embedded;
@override
void initState() {
super.initState();
logic = DiscoverBinding().create(search: context.read<SearchRepository>());
if (widget.focusSearch) _focusOnFirstFrame();
}
void _focusOnFirstFrame() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.openSearch();
});
}
@override
void didUpdateWidget(covariant DiscoverPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.syncSearch(
active: widget.active,
request: widget.searchRequest,
previousRequest: oldWidget.searchRequest,
isActive: () => mounted && widget.active,
);
}
@override
void dispose() {
logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: logic,
builder: (context, _) => _page(context),
);
}
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>();
final banners = context.watch<CatalogController>().banners;
final content = SafeArea(
bottom: !embedded,
child: Column(
children: [
DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile),
Expanded(child: _feed(l10n, categories, banners)),
if (!embedded) _bottomNav(l10n),
],
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.dark(),
child: embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
),
);
}
Widget _feed(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
return RefreshIndicator(
key: const Key('discover-refresh'),
color: AppColors.blue,
backgroundColor: AppColors.card,
onRefresh: categories.load,
child: ListView(
controller: logic.scroll,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
children: [
Padding(
padding: const EdgeInsets.only(top: 8),
child: DiscoverSearchField(
controller: logic.search,
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
loading: logic.searchLoading,
),
),
..._content(l10n, categories, banners),
],
),
);
}
List<Widget> _content(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
if (logic.hasSearchQuery) return [_searchContent(l10n)];
return _theaterContent(l10n, categories, banners);
}
Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults(
loading: logic.searchLoading,
error: logic.searchError,
results: logic.searchResults,
onRetry: logic.retrySearch,
onOpen: _openDrama,
);
List<Widget> _theaterContent(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
if (categories.loading && categories.feed.sections.isEmpty) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
}
final error = categories.error;
if (error != null) {
return [
_block(
EmptyState(
key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, error, l10n.categoriesError),
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: categories.load,
),
),
];
}
return _theaterBlocks(l10n, categories, banners);
}
List<Widget> _theaterBlocks(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
final dramas = categories.feed.dramas;
return [
Padding(
padding: const EdgeInsets.only(top: 12),
child: CategoryGrid(
categories: categories.categories,
onTap: (category) => _openCategory(category, categories),
),
),
if (banners.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: FeedBannerCarousel(
banners: banners,
active: widget.active,
onOpen: _openBanner,
),
),
if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty)
RankingSection(
sections: categories.feed.sections,
fallback: dramas,
onOpen: _openDrama,
),
_sectionTitle(l10n.allDramas),
if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas),
];
}
Widget _block(Widget child) =>
SizedBox(height: 280, child: Center(child: child));
Widget _emptyCatalog(AppLocalizations l10n) => Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: EmptyState(
key: const Key('discover-empty'),
icon: Icons.movie_filter_outlined,
title: l10n.noStories,
subtitle: l10n.emptyHint,
),
);
Widget _dramaGrid(AppLocalizations l10n, List<Drama> dramas) {
return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama);
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 18, 14, 10),
child: Row(
children: [
Container(
width: 3,
height: 18,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.previewSectionTitle,
fontWeight: FontWeight.w800,
),
),
),
],
),
);
}
void _openProfile() {
final callback = widget.callbacks.onProfile;
if (callback != null) {
callback();
return;
}
_act(DiscoverPageAction.my);
}
void _act(DiscoverPageAction action) {
logic.act(
context,
action,
embedded: embedded,
onAction: widget.callbacks.onAction,
);
}
void _openCategory(ContentCategory category, CategoryController categories) {
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => CategoryDramasPage(
title: category.name,
dramas: categories.dramasOf(category.id),
onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama,
),
),
);
}
void _openDrama(Drama drama) {
final callback = widget.callbacks.onDramaSelected;
if (callback != null) {
callback(drama);
return;
}
_pushDetail(drama);
}
void _pushDetail(Drama drama) {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
Future<void> _openBanner(FeedBanner banner) async {
final dramaId = banner.dramaId;
if (dramaId == null) return;
for (final drama in context.read<CatalogController>().dramas) {
if (drama.id == dramaId) {
_pushDetail(drama);
return;
}
}
try {
final drama = await context.read<CatalogRepository>().fetchDrama(dramaId);
if (mounted) _pushDetail(drama);
} catch (_) {
if (!mounted) return;
final message = AppLocalizations.of(context)!.detailLoadFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('discover-nav-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
onTap: () => _act(DiscoverPageAction.home),
),
AppBottomNavItem(
tabKey: const Key('discover-nav-current'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
selected: true,
onTap: () {},
),
AppBottomNavItem(
tabKey: const Key('discover-nav-my'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
onTap: () => _act(DiscoverPageAction.my),
),
],
);
}