360 lines
11 KiB
Dart
360 lines
11 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../core/data/models/content_category.dart';
|
|
import '../../../core/data/models/drama.dart';
|
|
import '../../../core/data/models/playlist_detail.dart';
|
|
import '../../../core/data/services/category_controller.dart';
|
|
import '../../../core/data/services/home_feed_controller.dart';
|
|
import '../../../core/data/services/playlist_controller.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/theme/app_dimens.dart';
|
|
import '../../../core/widgets/app_pull_to_refresh.dart';
|
|
import '../../../core/widgets/empty_state.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
import '../../home/widgets/home_drama_classification.dart';
|
|
import 'recommend_section_card.dart';
|
|
|
|
const List<String> _costumeNames = ['古装', 'costume', 'kostum'];
|
|
const int _sectionPreviewCount = 10;
|
|
const double _loadMoreThreshold = 200;
|
|
|
|
enum RecommendSectionKind { hits, newReleases, costume }
|
|
|
|
class RecommendSectionsView extends StatelessWidget {
|
|
const RecommendSectionsView({
|
|
super.key,
|
|
required this.feedController,
|
|
required this.categories,
|
|
required this.onOpenDrama,
|
|
required this.onMore,
|
|
this.playlists,
|
|
this.onOpenPlaylist,
|
|
});
|
|
|
|
final HomeFeedController feedController;
|
|
final CategoryController categories;
|
|
final ValueChanged<Drama> onOpenDrama;
|
|
final ValueChanged<RecommendSectionKind> onMore;
|
|
final PlaylistController? playlists;
|
|
final ValueChanged<PlaylistDetail>? onOpenPlaylist;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final source = feedController.dramas;
|
|
final remote = playlists;
|
|
return AppPullToRefresh(
|
|
key: const Key('recommend-sections-refresh'),
|
|
onRefresh: () async {
|
|
await Future.wait([
|
|
feedController.refreshCurrent(),
|
|
categories.load(),
|
|
if (remote != null) remote.load(),
|
|
]);
|
|
},
|
|
child: NotificationListener<ScrollNotification>(
|
|
onNotification: (notification) =>
|
|
remote == null ? false : _onPlaylistScroll(notification, remote),
|
|
child: ListView(
|
|
key: const Key('recommend-sections-scroll'),
|
|
physics: AppPullToRefresh.physics,
|
|
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
|
|
children: remote == null
|
|
? _localSections(context, l10n, source)
|
|
: _remoteSections(context, l10n, remote),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _localSections(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
List<Drama> source,
|
|
) {
|
|
final hits = recommendSectionDramas(
|
|
kind: RecommendSectionKind.hits,
|
|
source: source,
|
|
);
|
|
final newest = recommendSectionDramas(
|
|
kind: RecommendSectionKind.newReleases,
|
|
source: source,
|
|
);
|
|
return [
|
|
_section(
|
|
context,
|
|
l10n,
|
|
key: 'hits',
|
|
title: l10n.weeklyHitsTitle,
|
|
subtitle: l10n.weeklyHitsSubtitle,
|
|
dramas: hits.take(_sectionPreviewCount).toList(),
|
|
onMore: () => onMore(RecommendSectionKind.hits),
|
|
),
|
|
const SizedBox(height: AppDimens.recommendSectionGap),
|
|
_section(
|
|
context,
|
|
l10n,
|
|
key: 'new',
|
|
title: l10n.weeklyNewTitle,
|
|
subtitle: l10n.weeklyNewSubtitle,
|
|
dramas: newest.take(_sectionPreviewCount).toList(),
|
|
onMore: () => onMore(RecommendSectionKind.newReleases),
|
|
),
|
|
const SizedBox(height: AppDimens.recommendSectionGap),
|
|
_section(
|
|
context,
|
|
l10n,
|
|
key: 'costume',
|
|
title: l10n.costumeAwardsTitle,
|
|
subtitle: l10n.costumeAwardsSubtitle,
|
|
dramas: recommendSectionDramas(
|
|
kind: RecommendSectionKind.costume,
|
|
source: source,
|
|
categories: categories,
|
|
).take(_sectionPreviewCount).toList(),
|
|
onMore: () => onMore(RecommendSectionKind.costume),
|
|
),
|
|
];
|
|
}
|
|
|
|
List<Widget> _remoteSections(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
PlaylistController remote,
|
|
) {
|
|
if (!remote.loaded && remote.loading) {
|
|
return const [
|
|
SizedBox(
|
|
height: 240,
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
key: Key('recommend-playlists-loading'),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
if (remote.items.isEmpty) {
|
|
return [
|
|
EmptyState(
|
|
key: const Key('recommend-playlists-empty'),
|
|
icon: Icons.video_library_outlined,
|
|
title: remote.error == null ? l10n.emptyContent : l10n.networkError,
|
|
actionLabel: l10n.retry,
|
|
onAction: remote.load,
|
|
),
|
|
];
|
|
}
|
|
final sections = <Widget>[];
|
|
for (final playlist in remote.items) {
|
|
if (sections.isNotEmpty) {
|
|
sections.add(const SizedBox(height: AppDimens.recommendSectionGap));
|
|
}
|
|
sections.add(
|
|
_section(
|
|
context,
|
|
l10n,
|
|
key: playlist.id,
|
|
title: playlist.localizedName(
|
|
Localizations.localeOf(context).languageCode,
|
|
),
|
|
subtitle: playlist.localizedDescription(
|
|
Localizations.localeOf(context).languageCode,
|
|
),
|
|
dramas: playlist.dramas.take(_sectionPreviewCount).toList(),
|
|
onMore: () => onOpenPlaylist?.call(playlist),
|
|
),
|
|
);
|
|
}
|
|
if (remote.loadingMore) {
|
|
sections.add(
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: AppDimens.spaceMd),
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
key: Key('recommend-playlists-more'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} else if (!remote.hasMore) {
|
|
sections.add(
|
|
Padding(
|
|
key: const Key('paged-list-no-more'),
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Text(
|
|
l10n.noMoreItems,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
bool _onPlaylistScroll(
|
|
ScrollNotification notification,
|
|
PlaylistController remote,
|
|
) {
|
|
if (notification.depth != 0) return false;
|
|
if (notification.metrics.extentAfter > _loadMoreThreshold) return false;
|
|
if (notification is! ScrollUpdateNotification &&
|
|
notification is! ScrollEndNotification) {
|
|
return false;
|
|
}
|
|
remote.loadMore();
|
|
return false;
|
|
}
|
|
|
|
Widget _section(
|
|
BuildContext context,
|
|
AppLocalizations l10n, {
|
|
required String key,
|
|
required String title,
|
|
required String subtitle,
|
|
required List<Drama> dramas,
|
|
required VoidCallback onMore,
|
|
}) {
|
|
return Column(
|
|
key: Key('recommend-section-$key'),
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimens.feedPageHorizontal,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: AppDimens.recommendSectionTitle,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
style: const TextStyle(
|
|
color: AppColors.textSecondary,
|
|
fontSize: AppDimens.recommendSectionSubtitle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
TextButton(
|
|
key: Key('recommend-more-$key'),
|
|
onPressed: onMore,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.textSecondary,
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
minimumSize: const Size(0, 30),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
l10n.more,
|
|
style: const TextStyle(
|
|
color: AppColors.textSecondary,
|
|
fontSize: AppDimens.recommendSectionMore,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right_rounded, size: 16),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: AppDimens.spaceXs),
|
|
SizedBox(
|
|
height: AppDimens.recommendRowHeight,
|
|
child: ListView.separated(
|
|
key: Key('recommend-row-$key'),
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimens.feedPageHorizontal,
|
|
),
|
|
itemCount: dramas.length,
|
|
separatorBuilder: (_, _) =>
|
|
const SizedBox(width: AppDimens.homeGridGap),
|
|
itemBuilder: (context, index) => RecommendSectionCard(
|
|
sectionKey: key,
|
|
drama: dramas[index],
|
|
onTap: () => onOpenDrama(dramas[index]),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 剧单分区的完整片单,横滑预览和更多页共用同一排序。
|
|
List<Drama> recommendSectionDramas({
|
|
required RecommendSectionKind kind,
|
|
required List<Drama> source,
|
|
CategoryController? categories,
|
|
}) {
|
|
switch (kind) {
|
|
case RecommendSectionKind.hits:
|
|
return [...source]..sort((a, b) => b.viewCount.compareTo(a.viewCount));
|
|
case RecommendSectionKind.newReleases:
|
|
return [...source]..sort(_newestFirst);
|
|
case RecommendSectionKind.costume:
|
|
return _costumeDramas(source, categories);
|
|
}
|
|
}
|
|
|
|
int _newestFirst(Drama leftDrama, Drama rightDrama) {
|
|
final left = publishedAtForDrama(leftDrama);
|
|
final right = publishedAtForDrama(rightDrama);
|
|
if (left == null && right == null) {
|
|
return rightDrama.id.compareTo(leftDrama.id);
|
|
}
|
|
if (left == null) {
|
|
return 1;
|
|
}
|
|
if (right == null) {
|
|
return -1;
|
|
}
|
|
return right.compareTo(left);
|
|
}
|
|
|
|
List<Drama> _costumeDramas(List<Drama> source, CategoryController? categories) {
|
|
final category = _costumeCategory(categories);
|
|
if (category != null) {
|
|
final fromCategory = categories!.dramasOf(category.id);
|
|
if (fromCategory.isNotEmpty) return fromCategory;
|
|
}
|
|
return source.where(_isCostumeDrama).toList(growable: false);
|
|
}
|
|
|
|
ContentCategory? _costumeCategory(CategoryController? categories) {
|
|
if (categories == null) return null;
|
|
for (final category in categories.categories) {
|
|
if (_containsCostume(category.name)) return category;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _isCostumeDrama(Drama drama) {
|
|
return _containsCostume(drama.category) || _containsCostume(drama.genre);
|
|
}
|
|
|
|
bool _containsCostume(String value) {
|
|
final normalized = value.toLowerCase();
|
|
return _costumeNames.any(normalized.contains);
|
|
}
|