Files
ReelShort/lib/pages/drama_detail/widgets/drama_detail_sections.dart

418 lines
12 KiB
Dart

// 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 '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
const double _horizontal = 14;
const double _sectionTop = 16;
const double _statsVertical = 12;
const double _statsRadius = AppDimens.cardRadius;
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),
border: Border.all(color: AppColors.border),
),
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<DramaSynopsisSection> createState() => _DramaSynopsisSectionState();
}
class _DramaSynopsisSectionState extends State<DramaSynopsisSection> {
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<String> 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<int> 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 ? AppColors.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);
}
}