Files
ReelShort/lib/pages/player/widgets/home_feed_recommend_panel.dart

329 lines
9.8 KiB
Dart

// Author: fruit.com.cn
// 职责:推荐视频底部标题、标签、集数、简介与观看全集入口。
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/locale_text.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/app_press_scale.dart';
import '../../../l10n/app_localizations.dart';
import 'expandable_synopsis.dart';
import 'interaction_rail.dart';
part 'home_feed_info_tags.dart';
const double _seriesFont = 18;
const double _tagFont = 13;
const double _watchAllHeight = 38;
const double _titleMetadataGap = 2;
const double _metadataSynopsisGap = 2;
const double _hotSearchBottomGap = 2;
const double _metadataGap = 8;
const int _maximumTags = 2;
const double _episodeFont = 14;
const double _episodeWidthFraction = 0.55;
const double _metadataLineHeight = 1.4;
const _metadataStrutStyle = StrutStyle(
fontSize: _episodeFont,
height: _metadataLineHeight,
forceStrutHeight: true,
);
/// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。
const double _watchAllInset = 12;
const double _watchAllRadius = 6;
const double _watchAllFont = 16;
/// 「观看完整剧集」按钮高度,overlay 用它安排底部避让。
const double homeFeedWatchAllHeight = _watchAllHeight;
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
String _compactViewers(int count, Locale locale) =>
NumberFormat.compact(locale: locale.languageCode).format(count);
class HomeFeedInfoPanel extends StatefulWidget {
const HomeFeedInfoPanel({
super.key,
required this.drama,
required this.episode,
required this.onDetail,
required this.onOpenFullPlayer,
this.onOpenSearch,
});
final Drama drama;
final Episode episode;
final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer;
/// 热搜胶囊点击;为空或播放量为 0 时不显示胶囊。
final VoidCallback? onOpenSearch;
@override
State<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState();
}
class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
final types = _typeLabels(Localizations.localeOf(context).languageCode);
final tags = _tagChips(types);
return Column(
key: const Key('home-feed-info-panel'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.onOpenSearch != null && widget.drama.viewCount > 0) ...[
_HotSearchPill(
label: l10n.feedWatchingCount(
_compactViewers(
widget.drama.viewCount,
Localizations.localeOf(context),
),
),
onTap: widget.onOpenSearch!,
),
const SizedBox(height: _hotSearchBottomGap),
],
_title(dramaTitle),
const SizedBox(height: _titleMetadataGap),
_metadata(l10n, tags),
if (synopsis.isNotEmpty) ...[
const SizedBox(height: _metadataSynopsisGap),
ExpandableSynopsis(
key: ValueKey(widget.drama.id),
synopsis: synopsis,
textColor: AppColors.homeFeedVideoCopy,
),
],
],
);
}
Widget _title(String dramaTitle) => GestureDetector(
key: const Key('home-feed-series-name'),
behavior: HitTestBehavior.opaque,
onTap: widget.onOpenFullPlayer,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
dramaTitle,
key: const Key('home-feed-series-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _seriesFont,
fontWeight: FontWeight.w400,
),
),
),
const SizedBox(width: 2),
const Icon(
Icons.chevron_right_rounded,
size: 18,
color: AppColors.textPrimary,
),
],
),
);
List<Widget> _tagChips(List<String> types) {
return [
for (final type in types)
_TagChip(key: Key('home-feed-tag-$type'), label: type),
];
}
Widget _metadata(AppLocalizations l10n, List<Widget> tags) {
return LayoutBuilder(
builder: (context, constraints) {
final episode = Text(
widget.drama.episodeCount > 0
? l10n.episodeOfTotal(
widget.episode.number,
widget.drama.episodeCount,
)
: l10n.feedEpisodePrefix(widget.episode.number),
key: const Key('home-feed-episode-meta'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
strutStyle: _metadataStrutStyle,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _episodeFont,
height: _metadataLineHeight,
),
);
return Row(
key: const Key('home-feed-metadata'),
children: [
if (tags.isNotEmpty) ...[
Flexible(child: _TagStrip(chips: tags)),
const SizedBox(width: _metadataGap),
],
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: tags.isEmpty
? constraints.maxWidth
: constraints.maxWidth * _episodeWidthFraction,
),
child: episode,
),
],
);
},
);
}
List<String> _typeLabels(String languageCode) => widget.drama.types
.take(_maximumTags)
.map((type) => localizedText(languageCode, '', type).trim())
.where((label) => label.isNotEmpty)
.toList(growable: false);
}
/// 左侧「观看全集」条:与信息区等宽,居中文字避免与右侧互动栏争抢。
class HomeFeedWatchAllBar extends StatelessWidget {
const HomeFeedWatchAllBar({
super.key,
required this.drama,
required this.onTap,
});
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ClipRRect(
borderRadius: BorderRadius.circular(_watchAllRadius),
child: DecoratedBox(
key: const Key('home-feed-full-series-surface'),
decoration: const BoxDecoration(
color: AppColors.watchAllSurface,
border: Border.fromBorderSide(
BorderSide(color: AppColors.watchAllBorder),
),
),
child: Material(
key: const Key('home-feed-full-series-bg'),
color: AppColors.transparent,
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: onTap,
child: AppPressScale(child: _content(l10n)),
),
),
),
);
}
Widget _content(AppLocalizations l10n) => SizedBox(
width: double.infinity,
height: _watchAllHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
child: Center(
child: Text(
l10n.homeWatchAll,
semanticsLabel: l10n.watchFullSeries('${drama.episodeCount}'),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _watchAllFont,
fontWeight: FontWeight.w400,
),
),
),
),
);
}
class HomeFeedActionRail extends StatelessWidget {
const HomeFeedActionRail({
super.key,
required this.saved,
required this.liked,
required this.onSave,
required this.onLike,
required this.onComments,
required this.onShare,
this.favoriteCount,
this.likeCount,
this.commentCount,
});
final bool saved;
final bool liked;
final int? favoriteCount;
final int? likeCount;
final int? commentCount;
final VoidCallback onSave;
final VoidCallback onLike;
final VoidCallback onComments;
final VoidCallback onShare;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return InteractionRail(
itemGap: interactionRailCompactGap,
buttonSize: 48,
iconSize: 36,
countFontSize: 15,
iconCountGap: 2,
items: [
InteractionRailItem(
key: const Key('rail-save'),
asset: 'assets/icon/rail/rail_save.png',
label: l10n.favorite,
count: favoriteCount,
countKey: const Key('rail-save-count'),
selected: saved,
animateTap: true,
selectedColor: AppColors.brandRed,
onTap: onSave,
),
InteractionRailItem(
key: const Key('rail-comments'),
asset: 'assets/icon/rail/rail_comment.png',
label: l10n.comments,
count: commentCount,
countKey: const Key('rail-comments-count'),
onTap: onComments,
),
InteractionRailItem(
key: const Key('rail-like'),
asset: 'assets/icon/rail/rail_like.png',
label: l10n.like,
count: likeCount,
countKey: const Key('rail-like-count'),
selected: liked,
animateTap: true,
selectedColor: AppColors.brandRed,
onTap: onLike,
),
InteractionRailItem(
key: const Key('rail-share'),
asset: 'assets/icon/rail/rail_share.png',
label: l10n.share,
countKey: const Key('rail-share-label'),
onTap: onShare,
),
],
);
}
}