418 lines
12 KiB
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);
|
|
}
|
|
}
|