549 lines
18 KiB
Dart
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,
|
|
);
|
|
}
|