314 lines
8.9 KiB
Dart
314 lines
8.9 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/drama.dart';
|
|
import '../../core/data/repositories/search_repository.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 'discover_binding.dart';
|
|
import 'discover_logic.dart';
|
|
import 'discover_types.dart';
|
|
import 'widgets/discover_header.dart';
|
|
import 'widgets/discover_search_results.dart';
|
|
import 'widgets/drama_grid.dart';
|
|
|
|
/// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。
|
|
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 content = SafeArea(
|
|
bottom: !embedded,
|
|
child: Column(
|
|
children: [
|
|
DiscoverHeader(
|
|
controller: logic.search,
|
|
focusNode: logic.searchFocus,
|
|
onChanged: (_) => logic.onSearchChanged(),
|
|
loading: logic.searchLoading,
|
|
onProfile: _openProfile,
|
|
),
|
|
Expanded(child: _feed(l10n, categories)),
|
|
if (!embedded) _bottomNav(l10n),
|
|
],
|
|
),
|
|
);
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: SystemUiOverlayStyle.dark,
|
|
child: Theme(
|
|
data: AppTheme.light(),
|
|
child: embedded
|
|
? content
|
|
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _feed(AppLocalizations l10n, CategoryController categories) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap: logic.searchFocus.unfocus,
|
|
child: 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: AppDimens.spaceXl),
|
|
children: _content(l10n, categories),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _content(AppLocalizations l10n, CategoryController categories) {
|
|
if (logic.hasSearchQuery) {
|
|
return [
|
|
DiscoverSearchResults(
|
|
loading: logic.searchLoading,
|
|
error: logic.searchError,
|
|
results: logic.searchResults,
|
|
onRetry: logic.retrySearch,
|
|
onOpen: _openDrama,
|
|
),
|
|
];
|
|
}
|
|
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);
|
|
}
|
|
|
|
List<Widget> _theaterBlocks(
|
|
AppLocalizations l10n,
|
|
CategoryController categories,
|
|
) {
|
|
final dramas = logic.selectedCategoryId == null
|
|
? categories.feed.dramas
|
|
: categories.dramasOf(logic.selectedCategoryId!);
|
|
return [
|
|
_categoryChips(l10n, categories),
|
|
if (dramas.isEmpty)
|
|
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,
|
|
),
|
|
)
|
|
else
|
|
DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama),
|
|
];
|
|
}
|
|
|
|
Widget _categoryChips(AppLocalizations l10n, CategoryController categories) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppDimens.pageHorizontal,
|
|
AppDimens.spaceMd,
|
|
AppDimens.pageHorizontal,
|
|
0,
|
|
),
|
|
child: Wrap(
|
|
key: const Key('theater-category-chips'),
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [
|
|
_filterChip(
|
|
key: const Key('theater-category-all'),
|
|
label: l10n.all,
|
|
selected: logic.selectedCategoryId == null,
|
|
onSelected: () => logic.selectCategory(null),
|
|
),
|
|
for (final category in categories.categories)
|
|
_filterChip(
|
|
key: Key('theater-category-${category.id}'),
|
|
label: category.name,
|
|
selected: logic.selectedCategoryId == category.id,
|
|
onSelected: () => logic.selectCategory(category.id),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _filterChip({
|
|
required Key key,
|
|
required String label,
|
|
required bool selected,
|
|
required VoidCallback onSelected,
|
|
}) {
|
|
return ChoiceChip(
|
|
key: key,
|
|
label: Text(label),
|
|
selected: selected,
|
|
showCheckmark: false,
|
|
onSelected: (_) => onSelected(),
|
|
backgroundColor: AppColors.card,
|
|
selectedColor: AppColors.blue,
|
|
side: BorderSide(color: selected ? AppColors.blue : AppColors.border),
|
|
labelStyle: TextStyle(
|
|
color: selected ? AppColors.onPrimary : AppColors.onSurface,
|
|
fontSize: AppDimens.caption,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
shape: const StadiumBorder(),
|
|
);
|
|
}
|
|
|
|
Widget _block(Widget child) =>
|
|
SizedBox(height: 280, child: Center(child: child));
|
|
|
|
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 _openDrama(Drama drama) {
|
|
final callback = widget.callbacks.onDramaSelected;
|
|
if (callback != null) {
|
|
callback(drama);
|
|
return;
|
|
}
|
|
Navigator.of(context).push<void>(
|
|
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
|
);
|
|
}
|
|
|
|
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
|
|
items: [
|
|
AppBottomNavItem(
|
|
tabKey: const Key('discover-nav-home'),
|
|
icon: Icons.home_outlined,
|
|
selectedIcon: Icons.home_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.theaterTab,
|
|
selected: true,
|
|
onTap: () {},
|
|
),
|
|
AppBottomNavItem(
|
|
tabKey: const Key('discover-nav-create'),
|
|
icon: Icons.add_box_outlined,
|
|
selectedIcon: Icons.add_box_rounded,
|
|
label: l10n.createTab,
|
|
onTap: () => _act(DiscoverPageAction.create),
|
|
),
|
|
AppBottomNavItem(
|
|
tabKey: const Key('discover-nav-my'),
|
|
icon: Icons.person_outline_rounded,
|
|
selectedIcon: Icons.person_rounded,
|
|
label: l10n.myTab,
|
|
onTap: () => _act(DiscoverPageAction.my),
|
|
),
|
|
],
|
|
);
|
|
}
|