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

597 lines
18 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
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/catalog_retry.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/app_pull_to_refresh.dart';
import '../../core/widgets/catalog_debug_info.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../filter/filter_page.dart';
import '../new_releases/new_releases_page.dart';
import '../player/open_player.dart';
import '../ranking/ranking_page.dart';
import '../reserve/reserve_page.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';
const Color _filterIconColor = Color(0xFF7B61FF);
const Color _newIconColor = Color(0xFF14B8A6);
const double _toolbarHeight = 36;
const double _toolbarGap = 10;
const double _toolbarRadius = 10;
const double _categoryTabHeight = 44;
const double _categoryTabGap = 20;
const double _categorySelectedFontSize = 16;
const double _categoryFontSize = 15;
const double _categoryUnderlineWidth = 20;
const double _categoryUnderlineHeight = 3;
const double _categoryUnderlineStrokeWidth = 2;
const double _stickyHeaderHeight = 44;
const Duration _stickyHeaderTransition = Duration(milliseconds: 160);
const Duration _categoryScrollDuration = Duration(milliseconds: 200);
/// 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;
final ValueNotifier<bool> _headerPinned = ValueNotifier<bool>(false);
final ScrollController _categoryScroll = ScrollController();
final Map<String, GlobalKey> _categoryAnchors = {};
String? _ensuredCategoryId;
bool _categoryRevealScheduled = false;
bool get embedded => widget.callbacks.embedded;
@override
void initState() {
super.initState();
logic = DiscoverBinding().create(search: context.read<SearchRepository>());
logic.scroll.addListener(_onScroll);
if (widget.focusSearch) _focusOnFirstFrame();
}
void _onScroll() {
final pinned = logic.scroll.hasClients && logic.scroll.offset > 0;
if (pinned != _headerPinned.value) _headerPinned.value = pinned;
}
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.scroll.removeListener(_onScroll);
_categoryScroll.dispose();
_headerPinned.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 = ColoredBox(
key: const Key('theater-page-surface'),
color: AppColors.ink,
child: SafeArea(
bottom: !embedded,
child: Column(
children: [
DiscoverHeader(
controller: logic.search,
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
loading: logic.searchLoading,
),
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) {
final showHeader = _showStickyHeader(categories);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: logic.searchFocus.unfocus,
child: Stack(
children: [
Positioned.fill(
child: AppPullToRefresh(
key: const Key('discover-refresh'),
edgeOffset: _stickyHeaderHeight + 8,
onRefresh: categories.load,
child: ListView(
controller: logic.scroll,
physics: AppPullToRefresh.physics,
padding: EdgeInsets.only(
top: showHeader ? _stickyHeaderHeight : 0,
bottom: AppDimens.spaceXl,
),
children: _content(l10n, categories),
),
),
),
if (showHeader)
Positioned(
top: 0,
left: 0,
right: 0,
child: _stickyHeader(l10n, categories),
),
],
),
);
}
bool _showStickyHeader(CategoryController categories) {
if (logic.hasSearchQuery || categories.error != null) return false;
return !(categories.loading && categories.feed.sections.isEmpty);
}
Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) {
// 背景常驻 ink,只有分隔线/阴影随吸顶状态过渡,避免透明→实色的闪动。
return ValueListenableBuilder<bool>(
valueListenable: _headerPinned,
builder: (context, pinned, child) => AnimatedContainer(
key: const Key('theater-sticky-header'),
duration: _stickyHeaderTransition,
curve: Curves.easeOut,
decoration: BoxDecoration(
color: AppColors.ink,
border: Border(
bottom: BorderSide(
color: pinned ? AppColors.border : Colors.transparent,
),
),
boxShadow: pinned
? const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 8,
offset: Offset(0, 2),
),
]
: const [],
),
child: child,
),
child: KeyedSubtree(
key: const Key('theater-sticky-content'),
child: _categoryTabs(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: () => unawaited(_retryCategories(categories)),
footer: kDebugMode
? CatalogDebugInfo(error: categories.debugError ?? error)
: null,
),
),
];
}
return _theaterBlocks(l10n, categories);
}
List<Widget> _theaterBlocks(
AppLocalizations l10n,
CategoryController categories,
) {
final dramas = logic.selectedCategoryId == null
? categories.feed.dramas
: categories.dramasOf(logic.selectedCategoryId!);
return [
_toolbar(l10n),
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 _categoryTabs(AppLocalizations l10n, CategoryController categories) {
_scheduleSelectedCategoryReveal();
return SizedBox(
key: const Key('theater-category-chips'),
height: _categoryTabHeight,
child: SingleChildScrollView(
key: const Key('theater-category-scroll'),
controller: _categoryScroll,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_categoryTab(
key: const Key('theater-category-all'),
anchorKey: _anchorFor('all'),
id: 'all',
label: l10n.all,
selected: logic.selectedCategoryId == null,
onTap: () => _selectCategory(null),
),
for (final category in categories.categories) ...[
const SizedBox(width: _categoryTabGap),
_categoryTab(
key: Key('theater-category-${category.id}'),
anchorKey: _anchorFor(category.id),
id: category.id,
label: category.name,
selected: logic.selectedCategoryId == category.id,
onTap: () => _selectCategory(category.id),
),
],
],
),
),
);
}
Widget _categoryTab({
required Key key,
required GlobalKey anchorKey,
required String id,
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return KeyedSubtree(
key: anchorKey,
child: GestureDetector(
key: key,
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onSurface : AppColors.muted,
fontSize: selected
? _categorySelectedFontSize
: _categoryFontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
),
),
const SizedBox(height: 4),
CustomPaint(
key: Key('theater-category-underline-$id'),
size: const Size(
_categoryUnderlineWidth,
_categoryUnderlineHeight,
),
painter: CategoryUnderlinePainter(
color: selected ? AppColors.blue : Colors.transparent,
),
),
],
),
),
);
}
GlobalKey _anchorFor(String id) {
return _categoryAnchors.putIfAbsent(id, GlobalKey.new);
}
void _selectCategory(String? categoryId) {
logic.selectCategory(categoryId);
final id = categoryId ?? 'all';
_ensuredCategoryId = id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_revealCategory(id));
});
}
void _scheduleSelectedCategoryReveal() {
final selectedId = logic.selectedCategoryId ?? 'all';
if (_categoryRevealScheduled || _ensuredCategoryId == selectedId) return;
_categoryRevealScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
_categoryRevealScheduled = false;
if (!mounted || (logic.selectedCategoryId ?? 'all') != selectedId) return;
final revealed = await _revealCategory(selectedId);
if (revealed) _ensuredCategoryId = selectedId;
});
}
Future<bool> _revealCategory(String id) async {
final target = _categoryAnchors[id]?.currentContext;
if (target == null) return false;
await Scrollable.ensureVisible(
target,
alignment: 0.5,
duration: _categoryScrollDuration,
curve: Curves.easeOutCubic,
);
return true;
}
Widget _toolbar(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 12),
child: Row(
children: [
Expanded(
child: _toolButton(
key: const Key('theater-toolbar-filter'),
icon: Icons.tune_rounded,
color: _filterIconColor,
label: l10n.filter,
onTap: () => _openPage(context, const FilterPage()),
),
),
const SizedBox(width: _toolbarGap),
Expanded(
child: _toolButton(
key: const Key('theater-toolbar-ranking'),
icon: Icons.local_fire_department_rounded,
color: AppColors.blue,
label: l10n.rankingTab,
onTap: () => _openPage(context, const RankingPage()),
),
),
const SizedBox(width: _toolbarGap),
Expanded(
child: _toolButton(
key: const Key('theater-toolbar-new'),
icon: Icons.play_circle_fill_rounded,
color: _newIconColor,
label: l10n.newDramas,
onTap: () => _openPage(context, const NewReleasesPage()),
),
),
const SizedBox(width: _toolbarGap),
Expanded(
child: _toolButton(
key: const Key('theater-toolbar-reserve'),
icon: Icons.calendar_month_rounded,
color: AppColors.blue,
label: l10n.reserve,
onTap: () => _openPage(context, const ReservePage()),
),
),
],
),
);
}
Widget _toolButton({
required Key key,
required IconData icon,
required Color color,
required String label,
required VoidCallback onTap,
}) {
return Material(
color: AppColors.card,
borderRadius: BorderRadius.circular(_toolbarRadius),
child: InkWell(
key: key,
onTap: onTap,
borderRadius: BorderRadius.circular(_toolbarRadius),
child: Container(
height: _toolbarHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_toolbarRadius),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
void _openPage(BuildContext context, Widget page) {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
}
Widget _block(Widget child) => ConstrainedBox(
constraints: const BoxConstraints(minHeight: 280),
child: Center(child: child),
);
Future<void> _retryCategories(CategoryController categories) {
return retryCatalogLoad(reload: categories.load);
}
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;
}
unawaited(playDramaFromCatalog(context, 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),
),
],
);
}
/// 分类 tab 的弧形选中线。
class CategoryUnderlinePainter extends CustomPainter {
const CategoryUnderlinePainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = _categoryUnderlineStrokeWidth
..strokeCap = StrokeCap.round;
final path = Path()
..moveTo(1, 1)
..quadraticBezierTo(size.width / 2, size.height + 1, size.width - 1, 1);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CategoryUnderlinePainter oldDelegate) =>
color != oldDelegate.color;
}