628 lines
19 KiB
Dart
628 lines
19 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/catalog_repository.dart';
|
|
import '../../core/data/repositories/search_repository.dart';
|
|
import '../../core/data/services/catalog_retry.dart';
|
|
import '../../core/data/services/category_controller.dart';
|
|
import '../../core/data/services/theater_feed_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_pull_to_refresh.dart';
|
|
import '../../core/widgets/app_root_tab.dart';
|
|
import '../../core/widgets/catalog_debug_info.dart';
|
|
import '../../core/widgets/catalog_endpoint_editor.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 = AppColors.blueSoft;
|
|
const Color _newIconColor = AppColors.blueSoft;
|
|
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.callbacks = const DiscoverPageCallbacks(),
|
|
});
|
|
|
|
final bool active;
|
|
final DiscoverPageCallbacks callbacks;
|
|
|
|
@override
|
|
State<DiscoverPage> createState() => _DiscoverPageState();
|
|
}
|
|
|
|
class _DiscoverPageState extends State<DiscoverPage> {
|
|
late final DiscoverLogic logic;
|
|
late final TheaterFeedController theater;
|
|
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>());
|
|
theater = TheaterFeedController(context.read<CatalogRepository>());
|
|
logic.scroll.addListener(_onScroll);
|
|
theater.addListener(_onTheaterChanged);
|
|
unawaited(theater.select(null));
|
|
}
|
|
|
|
void _onScroll() {
|
|
final pinned = logic.scroll.hasClients && logic.scroll.offset > 0;
|
|
if (pinned != _headerPinned.value) _headerPinned.value = pinned;
|
|
if (logic.scroll.hasClients &&
|
|
!logic.hasSearchQuery &&
|
|
logic.scroll.position.extentAfter < 400) {
|
|
unawaited(theater.loadMore());
|
|
}
|
|
}
|
|
|
|
void _onTheaterChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
logic.scroll.removeListener(_onScroll);
|
|
theater.removeListener(_onTheaterChanged);
|
|
theater.dispose();
|
|
_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: false,
|
|
child: Column(
|
|
children: [
|
|
DiscoverHeader(
|
|
controller: logic.search,
|
|
focusNode: logic.searchFocus,
|
|
onChanged: (_) => logic.onSearchChanged(),
|
|
loading: logic.searchLoading,
|
|
),
|
|
Expanded(child: _feed(l10n, categories)),
|
|
if (!embedded) _bottomNav(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
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) {
|
|
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: () =>
|
|
Future.wait([categories.load(), theater.refreshCurrent()]),
|
|
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.categories.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 : AppColors.transparent,
|
|
),
|
|
),
|
|
boxShadow: pinned
|
|
? const [
|
|
BoxShadow(
|
|
color: AppColors.shadowSubtle,
|
|
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.categories.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,
|
|
onEditEndpoint: () =>
|
|
unawaited(_editCategoriesEndpoint(categories)),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
if (theater.loadingFirstPage) {
|
|
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
|
|
}
|
|
final theaterError = theater.lastError;
|
|
if (theaterError != null && theater.dramas.isEmpty) {
|
|
return [
|
|
_block(
|
|
EmptyState(
|
|
key: const Key('home-categories-error'),
|
|
icon: Icons.cloud_off_rounded,
|
|
title: errorText(l10n, theaterError, l10n.catalogError),
|
|
subtitle: l10n.emptyHint,
|
|
actionLabel: l10n.retry,
|
|
onAction: () => unawaited(_retryTheater()),
|
|
footer: kDebugMode
|
|
? CatalogDebugInfo(
|
|
error: theaterError,
|
|
onEditEndpoint: () => unawaited(_editTheaterEndpoint()),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
return _theaterBlocks(l10n);
|
|
}
|
|
|
|
List<Widget> _theaterBlocks(AppLocalizations l10n) {
|
|
final dramas = theater.dramas;
|
|
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),
|
|
if (theater.loadingMore)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 20),
|
|
child: Center(
|
|
child: SizedBox.square(
|
|
key: Key('theater-load-more'),
|
|
dimension: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
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 : AppColors.transparent,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
GlobalKey _anchorFor(String id) {
|
|
return _categoryAnchors.putIfAbsent(id, GlobalKey.new);
|
|
}
|
|
|
|
void _selectCategory(String? categoryId) {
|
|
logic.selectCategory(categoryId);
|
|
unawaited(theater.select(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);
|
|
}
|
|
|
|
Future<void> _retryTheater() async {
|
|
await retryCatalogLoad(
|
|
reload: () async {
|
|
await theater.refreshCurrent();
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _editTheaterEndpoint() {
|
|
return showCatalogEndpointEditor(
|
|
context,
|
|
reload: () async {
|
|
await theater.refreshCurrent();
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _editCategoriesEndpoint(CategoryController categories) {
|
|
return showCatalogEndpointEditor(context, 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() => AppRootBottomNav(
|
|
selected: AppRootTab.recommend,
|
|
onSelected: (tab) => _act(_actionOf(tab)),
|
|
);
|
|
|
|
DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) {
|
|
AppRootTab.home => DiscoverPageAction.home,
|
|
AppRootTab.recommend => DiscoverPageAction.recommend,
|
|
AppRootTab.following => DiscoverPageAction.following,
|
|
AppRootTab.mine => DiscoverPageAction.mine,
|
|
};
|
|
}
|
|
|
|
/// 分类 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;
|
|
}
|