// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; const double _horizontal = 14; const double _sectionTop = 16; const double _statsVertical = 12; const double _statsRadius = 14; const double _dividerHeight = 28; const int _synopsisLines = 3; const int _gridColumns = 6; const double _gridSpacing = 8; const double _chipHeight = 44; const double _chipRadius = 10; const double _tagRadius = 999; const double _tagHorizontal = 8; const double _tagVertical = 4; const double _lockSize = 10; const TextStyle _synopsisStyle = TextStyle( color: AppColors.muted, fontSize: 13, height: 1.5, ); /// 详情页三项数据卡:热度、收藏与点赞,缺失值不做伪造。 class DramaStatsCard extends StatelessWidget { const DramaStatsCard({ super.key, required this.l10n, required this.drama, this.favoriteCount, this.likeCount, }); final AppLocalizations l10n; final Drama drama; final int? favoriteCount; final int? likeCount; @override Widget build(BuildContext context) { final currentFavoriteCount = favoriteCount ?? drama.favoriteCount; final favorite = currentFavoriteCount == null ? l10n.episodeCountLabel(drama.episodeCount) : '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}'; final like = likeCount == null ? '—' : '${formatInteractionCount(likeCount!)} ${l10n.like}'; return Container( key: const Key('detail-stats'), margin: const EdgeInsets.fromLTRB( _horizontal, _sectionTop, _horizontal, 0, ), padding: const EdgeInsets.symmetric(vertical: _statsVertical), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_statsRadius), ), child: Row( children: [ Expanded( child: _StatCell( valueKey: const Key('detail-stat-heat-value'), icon: Icons.local_fire_department_rounded, color: AppColors.blue, value: l10n.heatLabel(formatInteractionCount(drama.viewCount)), ), ), const _StatDivider(), Expanded( child: _StatCell( valueKey: const Key('detail-stat-favorite-value'), icon: Icons.star_rounded, color: AppColors.gold, value: favorite, ), ), const _StatDivider(), Expanded( child: _StatCell( valueKey: const Key('detail-stat-like-value'), icon: Icons.favorite_rounded, color: AppColors.favorite, value: like, ), ), ], ), ); } } /// 详情简介区:默认三行,超出时允许展开与收起。 class DramaSynopsisSection extends StatefulWidget { const DramaSynopsisSection({ super.key, required this.l10n, required this.synopsis, }); final AppLocalizations l10n; final String synopsis; @override State createState() => _DramaSynopsisSectionState(); } class _DramaSynopsisSectionState extends State { bool _expanded = false; @override Widget build(BuildContext context) { return Padding( key: const Key('detail-synopsis-section'), padding: const EdgeInsets.fromLTRB( _horizontal, _sectionTop, _horizontal, 0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.l10n.synopsisTitle, style: const TextStyle( color: AppColors.onSurface, fontSize: 14, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 6), LayoutBuilder( builder: (context, constraints) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.synopsis, key: const Key('detail-synopsis'), maxLines: _expanded ? null : _synopsisLines, overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, style: _synopsisStyle, ), if (_overflows(context, constraints.maxWidth)) TextButton( key: const Key('detail-synopsis-toggle'), onPressed: () => setState(() => _expanded = !_expanded), style: TextButton.styleFrom( foregroundColor: AppColors.blue, minimumSize: const Size(44, 34), padding: EdgeInsets.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text( _expanded ? widget.l10n.showLess : widget.l10n.expandSynopsis, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, ), ), ), ], ), ), ], ), ); } bool _overflows(BuildContext context, double maxWidth) { final painter = TextPainter( text: TextSpan(text: widget.synopsis, style: _synopsisStyle), maxLines: _synopsisLines, textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(maxWidth: maxWidth); final result = painter.didExceedMaxLines; painter.dispose(); return result; } } /// 详情页分类、题材与语言标签,最多展示四个。 class DramaTags extends StatelessWidget { const DramaTags({super.key, required this.tags}); final List tags; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(_horizontal, 12, _horizontal, 0), child: Wrap( key: const Key('detail-tags'), spacing: 6, runSpacing: 6, children: [ for (final tag in tags) Container( padding: const EdgeInsets.symmetric( horizontal: _tagHorizontal, vertical: _tagVertical, ), decoration: BoxDecoration( color: AppColors.badgeBeige, borderRadius: BorderRadius.circular(_tagRadius), ), child: Text( tag, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.blue, fontSize: 12, fontWeight: FontWeight.w600, ), ), ), ], ), ); } } /// 详情页选集网格:免费、锁定与当前集使用不同状态样式。 class DramaEpisodeGrid extends StatelessWidget { const DramaEpisodeGrid({ super.key, required this.l10n, required this.drama, required this.continueIndex, required this.onOpen, }); final AppLocalizations l10n; final Drama drama; final int? continueIndex; final ValueChanged onOpen; @override Widget build(BuildContext context) { final ordered = [ for (var index = 0; index < drama.episodes.length; index++) (index: index, episode: drama.episodes[index]), ]..sort((a, b) => a.episode.number.compareTo(b.episode.number)); final selectedIndex = (continueIndex ?? 0).clamp( 0, drama.episodes.length - 1, ); final selectedNumber = drama.episodes[selectedIndex].number; return Padding( key: const Key('detail-episodes-section'), padding: const EdgeInsets.fromLTRB( _horizontal, _sectionTop, _horizontal, 0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( l10n.episodes, style: const TextStyle( color: AppColors.onSurface, fontSize: 14, fontWeight: FontWeight.w800, ), ), const Spacer(), Text( l10n.episodeCountLabel(drama.episodeCount), style: const TextStyle(color: AppColors.muted, fontSize: 11), ), ], ), const SizedBox(height: 8), GridView.builder( key: const Key('detail-episode-chips'), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, itemCount: ordered.length, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: _gridColumns, mainAxisSpacing: _gridSpacing, crossAxisSpacing: _gridSpacing, mainAxisExtent: _chipHeight, ), itemBuilder: (context, position) { final item = ordered[position]; return _episodeChip(item.episode, item.index, selectedNumber); }, ), ], ), ); } Widget _episodeChip(Episode episode, int index, int selectedNumber) { final selected = episode.number == selectedNumber; final locked = episode.locked || (!episode.isFree && index > 0); final background = selected ? AppColors.blue : locked ? AppColors.raised : AppColors.card; final foreground = selected ? AppColors.onPrimary : locked ? AppColors.muted : AppColors.onSurface; return Material( key: Key('detail-episode-chip-${episode.number}'), color: background, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_chipRadius), side: BorderSide( color: selected || locked ? Colors.transparent : AppColors.border, ), ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => onOpen(index), child: Stack( fit: StackFit.expand, children: [ Center( child: Text( '${episode.number}', style: TextStyle( color: foreground, fontSize: 12, fontWeight: FontWeight.w700, ), ), ), if (locked) Positioned( top: 4, right: 4, child: Icon( Icons.lock_rounded, key: Key('detail-episode-lock-${episode.number}'), size: _lockSize, color: selected ? AppColors.onPrimary : AppColors.subtle, ), ), ], ), ), ); } } class _StatCell extends StatelessWidget { const _StatCell({ required this.valueKey, required this.icon, required this.color, required this.value, }); final Key valueKey; final IconData icon; final Color color; final String value; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 4), Flexible( child: Text( value, key: valueKey, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, fontSize: 12, fontWeight: FontWeight.w800, ), ), ), ], ); } } class _StatDivider extends StatelessWidget { const _StatDivider(); @override Widget build(BuildContext context) { return Container(width: 1, height: _dividerHeight, color: AppColors.border); } }