Files

549 lines
18 KiB
Dart

// Author: fruit.com.cn
// 职责:追剧收藏与观看历史切换、数据刷新、管理和续播入口。
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/watch_record.dart';
import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/watch_records_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/app_press_scale.dart';
import '../../l10n/app_localizations.dart';
import '../discover/search_page.dart';
import '../player/open_player.dart';
import '../recommend/widgets/search_gift_glyphs.dart';
import 'following_edit_bar.dart';
import 'following_favorites_grid.dart';
import 'following_history_card.dart';
import 'following_keep_alive_tab.dart';
import 'following_paged_grid.dart';
class FollowingPage extends StatefulWidget {
const FollowingPage({
super.key,
required this.onGoHome,
required this.favoriteController,
this.historyRequest = 0,
this.visible = true,
});
final VoidCallback onGoHome;
final FavoriteController favoriteController;
final int historyRequest;
/// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证记录是最新进度。
final bool visible;
@override
State<FollowingPage> createState() => _FollowingPageState();
}
class _FollowingPageState extends State<FollowingPage> {
final PageController _pageController = PageController();
final ScrollController _favoriteScroll = ScrollController();
final ScrollController _historyScroll = ScrollController();
final ValueNotifier<FollowingTab> _tab = ValueNotifier<FollowingTab>(
FollowingTab.favorites,
);
final ValueNotifier<bool> _editing = ValueNotifier(false);
final ValueNotifier<Set<String>> _selected = ValueNotifier({});
late final WatchRecordsController _history;
late final AuthController _auth;
bool _wasSignedIn = false;
@override
void initState() {
super.initState();
_auth = context.read<AuthController>();
_wasSignedIn = _auth.isSignedIn;
_auth.addListener(_onAuthChanged);
_history = WatchRecordsController(
history: context.read<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(),
);
// IndexedStack 启动时就会建好本页:不可见时先不拉数据,等切到本 tab 再加载,
// 免得冷启动和首页 feed 抢网络。
if (widget.visible) {
unawaited(_history.load());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
}
void _onAuthChanged() {
final signedIn = _auth.isSignedIn;
if (signedIn == _wasSignedIn) return;
_wasSignedIn = signedIn;
_leaveEditing();
_history.reset();
widget.favoriteController.clear();
unawaited(_reloadForIdentity());
}
Future<void> _reloadForIdentity() async {
await Future.wait([_history.load(), widget.favoriteController.load()]);
}
@override
void dispose() {
_pageController.dispose();
_favoriteScroll.dispose();
_historyScroll.dispose();
_tab.dispose();
_editing.dispose();
_selected.dispose();
_auth.removeListener(_onAuthChanged);
_history.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant FollowingPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (!oldWidget.visible && widget.visible) {
unawaited(_history.load());
// 启动时本页不可见,收藏没在这里拉过;首次可见时补齐(已有数据就不重复请求)。
// 放在帧后,避免构建期间触发 controller 通知。
if (widget.favoriteController.cards.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
}
if (oldWidget.historyRequest == widget.historyRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _selectTab(FollowingTab.history);
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DecoratedBox(
key: const Key('following-page'),
// 纯黑底 + 顶部暗红光晕,与设计稿一致。
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
child: SafeArea(
bottom: false,
child: Column(
children: [
_header(l10n),
Expanded(
child: PageView(
key: const Key('following-tab-pages'),
controller: _pageController,
onPageChanged: _onPageChanged,
children: [
FollowingKeepAliveTab(
child: FollowingFavoritesGrid(
controller: widget.favoriteController,
selection: FollowingFavoriteSelection(
editing: _editing,
selected: _selected,
onToggle: _toggleDrama,
),
scrollController: _favoriteScroll,
onGoHome: widget.onGoHome,
),
),
FollowingKeepAliveTab(
child: _HistoryList(
controller: _history,
editing: _editing,
scrollController: _historyScroll,
onListEmptied: _exitEditingIfEmpty,
),
),
],
),
),
FollowingEditSlot(
tab: _tab,
editing: _editing,
favorites: widget.favoriteController,
selected: _selected,
actions: FollowingEditActions(
onToggleAll: _toggleAll,
onRemove: () => unawaited(_cancelSelected()),
),
),
],
),
),
);
}
Widget _header(AppLocalizations l10n) {
return Padding(
key: const Key('following-header'),
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.followingTitleTopGap,
AppDimens.feedPageHorizontal,
0,
),
child: Column(
children: [
SizedBox(
height: AppDimens.followingTitleRowHeight,
child: Row(
children: [
Expanded(
child: Text(
l10n.followingTab,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingTitleFont,
fontWeight: FontWeight.w400,
height: 1.0,
),
),
),
_searchButton(l10n),
const SizedBox(width: AppDimens.followingHeaderActionGap),
_manageButton(l10n),
],
),
),
SizedBox(
height: AppDimens.followingSegmentRowHeight,
child: ValueListenableBuilder<FollowingTab>(
valueListenable: _tab,
builder: (context, selectedTab, _) {
return Row(
children: [
ListenableBuilder(
listenable: widget.favoriteController,
builder: (context, _) {
return _segment(
FollowingTab.favorites,
Text(
'${l10n.myFavorites} ${widget.favoriteController.total}',
maxLines: 1,
),
'favorites',
selectedTab == FollowingTab.favorites,
);
},
),
const SizedBox(width: AppDimens.followingSegmentGap),
_segment(
FollowingTab.history,
Text(l10n.watchHistory, maxLines: 1),
'history',
selectedTab == FollowingTab.history,
),
],
);
},
),
),
],
),
);
}
Widget _searchButton(AppLocalizations l10n) {
return Semantics(
button: true,
label: l10n.search,
child: InkWell(
key: const Key('following-search'),
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const SearchPage()));
},
child: Padding(
padding: EdgeInsets.all(4),
child: SearchGlyph(
size: AppDimens.followingSearchIcon,
color: AppColors.textPrimary,
),
),
),
);
}
Widget _manageButton(AppLocalizations l10n) {
return ValueListenableBuilder<bool>(
valueListenable: _editing,
builder: (context, editing, _) {
return AppPressScale(
child: InkWell(
key: const Key('following-edit'),
borderRadius: BorderRadius.circular(8),
onTap: _toggleEditing,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
child: Text(
editing ? l10n.done : l10n.manage,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingManageFont,
fontWeight: FontWeight.w400,
height: 1.0,
),
),
),
),
);
},
);
}
Widget _segment(
FollowingTab tab,
Widget label,
String keyName,
bool selected,
) {
return AppPressScale(
child: InkWell(
key: Key('following-tab-$keyName'),
onTap: () => _selectTab(tab),
borderRadius: BorderRadius.circular(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: AppDimens.followingTabAnimationDuration,
style: TextStyle(
color: selected
? AppColors.textPrimary
: AppColors.textTabInactive,
fontSize: AppDimens.followingSegmentFont,
fontWeight: FontWeight.w400,
),
child: label,
),
const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap),
AnimatedContainer(
duration: AppDimens.followingTabAnimationDuration,
curve: Curves.easeOut,
width: selected ? AppDimens.followingSegmentIndicatorWidth : 0,
height: AppDimens.followingSegmentIndicatorHeight,
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingSegmentIndicatorRadius,
),
),
),
],
),
),
);
}
void _selectTab(FollowingTab tab) {
_leaveEditing();
_tab.value = tab;
if (_pageController.hasClients) {
unawaited(
_pageController.animateToPage(
tab.index,
duration: AppDimens.followingPageAnimationDuration,
curve: Curves.easeOutCubic,
),
);
}
}
void _onPageChanged(int index) {
final tab = FollowingTab.values[index];
_leaveEditing();
_tab.value = tab;
if (tab == FollowingTab.history) {
unawaited(_history.load());
}
}
void _toggleEditing() {
_editing.value = !_editing.value;
if (!_editing.value) _selected.value = {};
}
void _leaveEditing() {
_editing.value = false;
_selected.value = {};
}
void _toggleDrama(String id) {
final next = {..._selected.value};
if (!next.add(id)) next.remove(id);
_selected.value = next;
}
void _toggleAll(Set<String> ids) {
final picked = _selected.value;
final all = ids.isNotEmpty && ids.every(picked.contains);
_selected.value = all ? {} : ids;
}
Future<void> _cancelSelected() async {
final ids = _selected.value.toList();
if (ids.isEmpty || !mounted) return;
final removed = await widget.favoriteController.removeMany(ids);
if (!removed || !mounted) return;
_selected.value = {};
if (widget.favoriteController.cards.isEmpty) _editing.value = false;
}
void _exitEditingIfEmpty() => _editing.value = false;
}
class _HistoryList extends StatelessWidget {
const _HistoryList({
required this.controller,
required this.editing,
required this.scrollController,
required this.onListEmptied,
});
final WatchRecordsController controller;
final ValueListenable<bool> editing;
final ScrollController scrollController;
final VoidCallback onListEmptied;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: editing,
builder: (context, isEditing, _) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
return FollowingPagedGrid(
key: const Key('following-history-grid'),
scrollController: scrollController,
loading: controller.loading,
loadingMore: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.records.isNotEmpty,
singleColumn: true,
errorMessage: controller.error == null
? null
: errorText(l10n, controller.error, l10n.progressError),
loadMoreError: controller.loadMoreError == null
? null
: errorText(
l10n,
controller.loadMoreError,
l10n.progressError,
),
onRefresh: controller.load,
onLoadMore: controller.loadMore,
itemCount: controller.records.length,
empty: EmptyState(
key: const Key('following-history-empty'),
icon: Icons.history_rounded,
title: l10n.noWatchRecords,
subtitle: l10n.emptyHint,
),
itemBuilder: (context, index) {
final record = controller.records[index];
final progress = record.progress.fraction;
final hasProgress = record.progress.duration > Duration.zero;
final watchedPercent = (progress * 100)
.round()
.clamp(0, 100)
.toInt();
void openRecord() {
unawaited(_openHistory(context, controller, record));
}
return FollowingHistoryCard(
dramaId: record.dramaId,
title: _historyTitle(
l10n,
record,
Localizations.localeOf(context).languageCode,
),
posterUrl: record.displayCover,
viewCount:
record.progress.viewCount ??
record.drama?.viewCount ??
record.progress.playCount,
progress: hasProgress ? progress : null,
progressLabel: hasProgress
? l10n.watchedPercent(watchedPercent)
: null,
editing: isEditing,
subtitle: _historySubtitle(l10n, record),
onTap: openRecord,
onContinue: openRecord,
onRemove: () async {
if (await controller.remove(record.dramaId) &&
controller.records.isEmpty) {
onListEmptied();
}
},
);
},
);
},
);
},
);
}
}
String _historyTitle(
AppLocalizations l10n,
WatchRecord record,
String languageCode,
) {
final title = record.localizedDisplayTitle(languageCode);
return title.isEmpty ? l10n.episodeLabel(record.episodeNumber) : title;
}
String _historySubtitle(AppLocalizations l10n, WatchRecord record) {
final total =
record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0;
if (record.episodeNumber <= 0 || total <= 0) {
return l10n.watchedToEpisode(record.episodeNumber);
}
return l10n.favoriteEpisodeProgress(record.episodeNumber, total);
}
Future<void> _openHistory(
BuildContext context,
WatchRecordsController controller,
WatchRecord record,
) async {
final drama = record.drama ?? await controller.detailForOpen(record.dramaId);
if (!context.mounted || drama == null) return;
await playDramaFromCatalog(
context,
drama,
episodeId: record.progress.episodeId,
);
}