// 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 createState() => _HomeFeedInfoPanelState(); } class _HomeFeedInfoPanelState extends State { @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 _tagChips(List types) { return [ for (final type in types) _TagChip(key: Key('home-feed-tag-$type'), label: type), ]; } Widget _metadata(AppLocalizations l10n, List 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 _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, ), ], ); } }