206 lines
7.0 KiB
Dart
206 lines
7.0 KiB
Dart
// Author: fruit.com.cn
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../core/data/models/watch_record.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 '../../l10n/app_localizations.dart';
|
||
import '../player/open_player.dart';
|
||
import 'following_grid_card.dart';
|
||
import 'following_paged_grid.dart';
|
||
import 'widgets/continue_watching_banner.dart';
|
||
|
||
/// 追剧编辑中的选中状态。
|
||
class FollowingFavoriteSelection {
|
||
const FollowingFavoriteSelection({
|
||
required this.editing,
|
||
required this.selected,
|
||
required this.onToggle,
|
||
});
|
||
|
||
final ValueListenable<bool> editing;
|
||
final ValueListenable<Set<String>> selected;
|
||
final ValueChanged<String> onToggle;
|
||
}
|
||
|
||
/// 追剧列表。编辑时点卡片只切换选中,不进入播放。
|
||
class FollowingFavoritesGrid extends StatelessWidget {
|
||
const FollowingFavoritesGrid({
|
||
super.key,
|
||
required this.controller,
|
||
required this.history,
|
||
required this.selection,
|
||
required this.scrollController,
|
||
required this.onGoHome,
|
||
required this.onContinueRecord,
|
||
});
|
||
|
||
final FavoriteController controller;
|
||
final WatchRecordsController history;
|
||
final FollowingFavoriteSelection selection;
|
||
final ScrollController scrollController;
|
||
final VoidCallback onGoHome;
|
||
final ValueChanged<WatchRecord> onContinueRecord;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ValueListenableBuilder<bool>(
|
||
valueListenable: selection.editing,
|
||
builder: (context, isEditing, _) {
|
||
return ValueListenableBuilder<Set<String>>(
|
||
valueListenable: selection.selected,
|
||
builder: (context, picked, _) {
|
||
return ListenableBuilder(
|
||
listenable: Listenable.merge([controller, history]),
|
||
builder: (context, _) => _grid(context, isEditing, picked),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _grid(BuildContext context, bool isEditing, Set<String> picked) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final hasFavorites = controller.cards.isNotEmpty;
|
||
final extra = !isEditing && hasFavorites ? 1 : 0;
|
||
return FollowingPagedGrid(
|
||
key: const Key('following-favorites-grid'),
|
||
scrollController: scrollController,
|
||
loading: controller.loading,
|
||
loadingMore: controller.loadingMore,
|
||
hasMore: controller.hasMore,
|
||
hasItems: hasFavorites,
|
||
showInitialLoading: false,
|
||
errorMessage: controller.error == null
|
||
? null
|
||
: errorText(l10n, controller.error, l10n.favoritesError),
|
||
loadMoreError: controller.loadMoreError == null
|
||
? null
|
||
: errorText(l10n, controller.loadMoreError, l10n.favoritesError),
|
||
onRefresh: controller.load,
|
||
onLoadMore: controller.loadMore,
|
||
itemCount: controller.cards.length + extra,
|
||
topSlivers: _topSlivers(context),
|
||
empty: EmptyState(
|
||
key: const Key('following-favorites-empty'),
|
||
icon: Icons.bookmark_border_rounded,
|
||
title: l10n.noFavoritesYet,
|
||
),
|
||
itemBuilder: (context, index) => _item(context, index, isEditing, picked),
|
||
);
|
||
}
|
||
|
||
List<Widget> _topSlivers(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final continueRecord = _continueRecord();
|
||
return [
|
||
if (continueRecord != null)
|
||
SliverToBoxAdapter(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppDimens.feedPageHorizontal,
|
||
4,
|
||
AppDimens.feedPageHorizontal,
|
||
0,
|
||
),
|
||
child: ContinueWatchingBanner(
|
||
record: continueRecord,
|
||
languageCode: Localizations.localeOf(context).languageCode,
|
||
onTap: () => onContinueRecord(continueRecord),
|
||
),
|
||
),
|
||
),
|
||
SliverToBoxAdapter(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppDimens.feedPageHorizontal,
|
||
AppDimens.followingSectionTopGap,
|
||
AppDimens.feedPageHorizontal,
|
||
AppDimens.followingSectionBottomGap,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
l10n.favoriteDramas,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary,
|
||
fontSize: AppDimens.followingSectionTitleFont,
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 2),
|
||
child: Text(
|
||
l10n.totalDramasCount(controller.total),
|
||
style: const TextStyle(
|
||
color: AppColors.textTabInactive,
|
||
fontSize: AppDimens.followingSectionCountFont,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
];
|
||
}
|
||
|
||
/// 最近一条观看记录(records 已按最近观看排序,与观看历史页首条一致)。
|
||
WatchRecord? _continueRecord() {
|
||
return history.records.isEmpty ? null : history.records.first;
|
||
}
|
||
|
||
Widget _item(
|
||
BuildContext context,
|
||
int index,
|
||
bool isEditing,
|
||
Set<String> picked,
|
||
) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
if (index == controller.cards.length) {
|
||
return FollowingAddCard(onTap: onGoHome);
|
||
}
|
||
final drama = controller.cards[index];
|
||
final record = history.recordForDrama(drama.id);
|
||
final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number;
|
||
final watchedEpisode = record?.episodeNumber ?? 0;
|
||
final total = drama.episodeCount;
|
||
final subtitle = watchedEpisode > 0 && total > 0
|
||
? l10n.watchedEpisodeTotal(watchedEpisode, total)
|
||
: l10n.favoriteEpisodeProgress(episode, total);
|
||
return FollowingGridCard(
|
||
dramaId: drama.id,
|
||
title: drama.localizedTitle(Localizations.localeOf(context).languageCode),
|
||
posterUrl: drama.posterUrl,
|
||
viewCount: drama.viewCount,
|
||
editing: isEditing,
|
||
selected: isEditing ? picked.contains(drama.id) : null,
|
||
subtitle: subtitle,
|
||
// TODO(backend): Drama 增加 hasUpdate / latestEpisodeNumber / isFinished
|
||
// 字段后,在此传 updateBadge(l10n.hasUpdateBadge)、红色更新副标题
|
||
// (l10n.updatedToEpisode)或灰色完结副标题(l10n.finishedAllEpisodes)。
|
||
onTap: () => _openOrToggle(context, drama.id, isEditing),
|
||
);
|
||
}
|
||
|
||
void _openOrToggle(BuildContext context, String dramaId, bool isEditing) {
|
||
if (isEditing) {
|
||
selection.onToggle(dramaId);
|
||
return;
|
||
}
|
||
final drama = controller.cards.firstWhere((item) => item.id == dramaId);
|
||
unawaited(playDramaFromCatalog(context, drama));
|
||
}
|
||
}
|