// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; import 'interaction_rail.dart'; const double _seriesFont = 17; const double _tagFont = 12; const double _synopsisFont = 14; const double _watchAllHeight = 38; /// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。 const double _watchAllInset = 12; const double _watchAllRadius = 12; /// 标题、标签、简介之间的行间距:底部信息本来就挤,留 4pt 够分层。 const double _blockGap = 4; const List _textShadows = [ Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), ]; final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 通栏「观看完整剧集」半透明白底条高度,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 { bool _synopsisExpanded = false; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final dramaTitle = localizedDramaTitle(l10n, widget.drama); final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); final genres = _genreValues(Localizations.localeOf(context).languageCode); final tags = _tagChips(l10n, genres); 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: 8), ], 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, shadows: _textShadows, ), ), ), const SizedBox(width: 2), const Icon( Icons.chevron_right_rounded, size: 18, color: AppColors.textPrimary, ), ], ), ), // 没有标签就整行跳过,不给标题和简介之间留空档。 if (tags.isNotEmpty) ...[ const SizedBox(height: _blockGap), _tagRow(tags), ], if (synopsis.isNotEmpty) ...[ const SizedBox(height: _blockGap), _synopsis(l10n, synopsis), ], ], ); } List _tagChips(AppLocalizations l10n, List genres) { return [ if (isHotDrama(widget.drama)) _TagChip( key: const Key('home-feed-hot-badge'), label: l10n.homeBadgeHot, filled: true, ), for (final genre in genres) _TagChip(key: Key('home-feed-tag-$genre'), label: genre), ]; } Widget _tagRow(List chips) { return SizedBox( key: const Key('home-feed-tags'), height: 22, child: ListView.separated( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), shrinkWrap: true, itemCount: chips.length, separatorBuilder: (_, _) => const SizedBox(width: 6), itemBuilder: (_, index) => chips[index], ), ); } Widget _synopsis(AppLocalizations l10n, String synopsis) { final prefix = l10n.feedEpisodePrefix(widget.episode.number); final baseStyle = const TextStyle( color: AppColors.textPrimary, fontSize: _synopsisFont, height: 1.3, shadows: _textShadows, ); final span = TextSpan( children: [ TextSpan(text: prefix, style: baseStyle), TextSpan( text: ' | ', style: baseStyle.copyWith( color: AppColors.textPrimary.withValues(alpha: 0.45), fontSize: _synopsisFont - 2, fontWeight: FontWeight.w400, ), ), TextSpan(text: synopsis, style: baseStyle), ], ); if (_synopsisExpanded) { return _expandedSynopsis(span, baseStyle, l10n); } return LayoutBuilder( builder: (context, constraints) { final toggle = _synopsisToggle( key: const Key('home-feed-synopsis-more'), label: l10n.expandSynopsis, padding: const EdgeInsets.only(left: 6), ); final painter = TextPainter( text: span, maxLines: 1, textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(maxWidth: constraints.maxWidth); if (!painter.didExceedMaxLines) { return Text.rich( span, key: const Key('home-feed-synopsis'), maxLines: 1, style: baseStyle, ); } // 设计稿:单行省略号 + 行内固定「展开」。 // 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。 return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _synopsisExpanded = true), child: Row( key: const Key('home-feed-synopsis-collapsed'), // 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。 crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text.rich( span, key: const Key('home-feed-synopsis'), maxLines: 1, overflow: TextOverflow.ellipsis, style: baseStyle, ), ), const SizedBox(width: 2), toggle, ], ), ); }, ); } Widget _expandedSynopsis( InlineSpan span, TextStyle style, AppLocalizations l10n, ) { // 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间 // 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。 final expandedSpan = TextSpan( style: style, children: [ span, WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: _synopsisToggle( key: const Key('home-feed-synopsis-more'), label: l10n.showLess, padding: const EdgeInsets.only(left: 8), ), ), ], ); return Text.rich( expandedSpan, key: const Key('home-feed-synopsis-expanded'), ); } Widget _synopsisToggle({ required Key key, required String label, required EdgeInsets padding, }) { return GestureDetector( key: key, // opaque + 留白扩大命中区;留白方向由调用方决定, // 展开态不能加 top padding,否则「收起」距简介正文过远。 behavior: HitTestBehavior.opaque, onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded), child: Padding( padding: padding, child: Text( label, style: const TextStyle( color: AppColors.textPrimary, fontSize: _synopsisFont, fontWeight: FontWeight.w600, shadows: _textShadows, ), ), ), ); } List _genreValues(String languageCode) { final localized = widget.drama.localizedTypeLabels(languageCode); if (localized.isNotEmpty) return localized.take(3).toList(growable: false); final result = []; for (final value in [widget.drama.genre, widget.drama.category]) { for (final part in value.split(_tagSplitter)) { final tag = part.trim(); if (tag.isEmpty || result.contains(tag)) continue; result.add(tag); } } return result.take(3).toList(growable: false); } } /// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。 class _HotSearchPill extends StatelessWidget { const _HotSearchPill({required this.label, required this.onTap}); final String label; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( key: const Key('home-feed-hot-search-pill'), behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: AppColors.textPrimary.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.search_rounded, size: 15, color: AppColors.textPrimary, ), const SizedBox(width: 5), Text( label, maxLines: 1, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, fontWeight: FontWeight.w500, ), ), const SizedBox(width: 2), const Icon( Icons.chevron_right_rounded, size: 16, color: AppColors.textPrimary, ), ], ), ), ); } } /// 热剧红胶囊 / 类型深色半透明填充胶囊。 class _TagChip extends StatelessWidget { const _TagChip({super.key, required this.label, this.filled = false}); final String label; final bool filled; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), alignment: Alignment.center, decoration: BoxDecoration( color: filled ? AppColors.brandRed : AppColors.textPrimary.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: Text( label, maxLines: 1, style: const TextStyle( color: AppColors.textPrimary, fontSize: _tagFont, fontWeight: FontWeight.w500, height: 1.2, shadows: _textShadows, ), ), ); } } /// 底部「观看完整剧集」条:浮在底部,不再铺满整屏宽度。 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: Material( key: const Key('home-feed-full-series-bg'), color: AppColors.textPrimary.withValues(alpha: 0.15), child: InkWell( key: const Key('home-feed-full-series'), onTap: onTap, child: SizedBox( height: _watchAllHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: _watchAllInset), child: Row( children: [ const Icon( Icons.movie_rounded, size: 22, color: AppColors.onPrimary, ), const SizedBox(width: 10), Expanded( child: Text( l10n.watchFullSeries('${drama.episodeCount}'), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textPrimary, fontSize: 15, fontWeight: FontWeight.w500, ), ), ), const Icon( Icons.chevron_right_rounded, size: 22, color: AppColors.textPrimary, ), ], ), ), ), ), ), ); } } 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: 12, 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, 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, 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, ), ], ); } }