feat(flutter): 重做短剧详情页

This commit is contained in:
Zhou Weidong committed 2026-10-20 15:46:53 +08:00
1 parent 7250b5f0f9
commit 69faa7818c
12 files changed
+1136 -470

No files matched your search

+1
View File
@@ -276,6 +276,7 @@
}
},
"episodes": "Episodes",
"synopsisTitle": "Synopsis",
"watchEpisodeOne": "Watch episode 1",
"continueEpisode": "Continue episode {number}",
"@continueEpisode": {
+1
View File
@@ -250,6 +250,7 @@
}
},
"episodes": "Episode",
"synopsisTitle": "Sinopsis",
"watchEpisodeOne": "Tonton episode 1",
"continueEpisode": "Lanjut episode {number}",
"@continueEpisode": {
+6
View File
@@ -1168,6 +1168,12 @@ abstract class AppLocalizations {
/// **'Episodes'**
String get episodes;
/// No description provided for @synopsisTitle.
///
/// In en, this message translates to:
/// **'Synopsis'**
String get synopsisTitle;
/// No description provided for @watchEpisodeOne.
///
/// In en, this message translates to:
+3
View File
@@ -583,6 +583,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get episodes => 'Episodes';
@override
String get synopsisTitle => 'Synopsis';
@override
String get watchEpisodeOne => 'Watch episode 1';
+3
View File
@@ -584,6 +584,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get episodes => 'Episode';
@override
String get synopsisTitle => 'Sinopsis';
@override
String get watchEpisodeOne => 'Tonton episode 1';
+3
View File
@@ -576,6 +576,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get episodes => '选集';
@override
String get synopsisTitle => '简介';
@override
String get watchEpisodeOne => '观看第 1 集';
+1
View File
@@ -236,6 +236,7 @@
"episodeCount": "{count} 集",
"technicalDemoCount": "免费试看 · {count} 集",
"episodes": "选集",
"synopsisTitle": "简介",
"watchEpisodeOne": "观看第 1 集",
"continueEpisode": "续看第 {number} 集",
"freeTestMedia": "复用同一演示视频 · 免费",
+144 -341
View File
@@ -16,22 +16,29 @@ import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
import 'widgets/drama_detail_bottom_bar.dart';
import 'widgets/drama_detail_header.dart';
import 'widgets/drama_detail_sections.dart';
const String _menuFavorite = 'favorite';
const String _menuDownload = 'download';
const String _menuShare = 'share';
const String _menuReport = 'report';
const int _maxTags = 4;
const double _moreSize = 36;
const double _moreIconSize = 18;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 短剧详情页:暖色主信息、简介、标签、选集和固定播放入口。
class DramaDetailPage extends StatefulWidget {
const DramaDetailPage({super.key, required this.drama});
@@ -87,15 +94,52 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
if (logic.failed) return _failed(l10n);
final drama = logic.drama;
if (drama.episodes.isEmpty) return _empty(l10n, drama);
final saved = context.watch<FavoriteController>().isSaved(drama.id);
final continueIndex = logic.continueIndex;
return Scaffold(
backgroundColor: AppColors.ink,
bottomNavigationBar: DramaDetailBottomBar(
l10n: l10n,
continueIndex: continueIndex,
onPlay: () => logic.openPlayer(context, continueIndex ?? 0),
resume: _resume(l10n),
),
body: CustomScrollView(
key: const Key('detail-scroll'),
slivers: [
SliverToBoxAdapter(child: _hero(context, l10n, drama)),
SliverToBoxAdapter(child: _content(context, l10n, drama)),
const SliverToBoxAdapter(child: SizedBox(height: 28)),
SliverToBoxAdapter(
child: DramaDetailHeader(
l10n: l10n,
drama: drama,
saved: saved,
actions: DramaDetailHeaderActions(
onBack: () => Navigator.of(context).maybePop(),
onShare: () => unawaited(_shareDrama(l10n, drama)),
onFavorite: () => unawaited(_toggleFavorite(l10n)),
moreMenu: _moreMenu(context, l10n, saved),
),
),
),
SliverToBoxAdapter(
child: DramaStatsCard(l10n: l10n, drama: drama),
),
SliverToBoxAdapter(
child: DramaSynopsisSection(
l10n: l10n,
synopsis: localizedDramaSynopsis(l10n, drama),
),
),
if (_tagValues(drama).isNotEmpty)
SliverToBoxAdapter(child: DramaTags(tags: _tagValues(drama))),
SliverToBoxAdapter(
child: DramaEpisodeGrid(
l10n: l10n,
drama: drama,
continueIndex: continueIndex,
onOpen: (index) => logic.openPlayer(context, index),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
);
@@ -104,8 +148,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget _loading(AppLocalizations l10n) => Scaffold(
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onPrimary,
backgroundColor: AppColors.surfaceTop,
foregroundColor: AppColors.onSurface,
title: Text(localizedDramaTitle(l10n, widget.drama)),
),
body: const Center(child: CircularProgressIndicator()),
@@ -114,8 +158,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget _failed(AppLocalizations l10n) => Scaffold(
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onPrimary,
backgroundColor: AppColors.surfaceTop,
foregroundColor: AppColors.onSurface,
title: Text(localizedDramaTitle(l10n, widget.drama)),
),
body: Center(
@@ -140,8 +184,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold(
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onPrimary,
backgroundColor: AppColors.surfaceTop,
foregroundColor: AppColors.onSurface,
title: Text(localizedDramaTitle(l10n, drama)),
),
body: EmptyState(
@@ -154,236 +198,45 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
),
);
Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) {
return SizedBox(
key: const Key('detail-hero'),
height: 190,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
Widget? _resume(AppLocalizations l10n) {
final fraction = _continueFraction;
final progressError = logic.progressError;
if (fraction == null && progressError == null) return null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (fraction != null)
DramaResumeBar(
fraction: fraction,
label: l10n.resumeProgress(
_clock(logic.progress!.position),
_clock(logic.progress!.duration),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x33000000), AppColors.onSurface],
if (progressError != null) ...[
if (fraction != null) const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
errorText(l10n, progressError, l10n.progressError),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.muted, fontSize: 11),
),
),
),
),
Positioned(
top: MediaQuery.paddingOf(context).top + 14,
left: 14,
child: _RoundAction(
key: const Key('detail-back'),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Icons.chevron_left_rounded,
onPressed: () => Navigator.of(context).maybePop(),
),
),
Positioned(
top: MediaQuery.paddingOf(context).top + 14,
right: 14,
child: _moreMenu(context, l10n),
TextButton(
onPressed: () => unawaited(logic.loadContinue()),
child: Text(l10n.retry),
),
],
),
],
),
],
);
}
Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) {
final continueIndex = logic.continueIndex;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.episodeCount(drama.episodeCount),
key: const Key('detail-episode-count'),
style: const TextStyle(
color: AppColors.secondary,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 1,
),
),
const SizedBox(height: 7),
Text(
localizedDramaTitle(l10n, drama),
key: const Key('detail-title'),
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
const SizedBox(height: 10),
Text(
localizedDramaSynopsis(l10n, drama),
key: const Key('detail-synopsis'),
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
height: 1.6,
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 40,
child: FilledButton.icon(
key: const Key('detail-cta'),
onPressed: () => logic.openPlayer(context, continueIndex ?? 0),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDimens.buttonRadius),
),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: Text(
continueIndex == null
? l10n.startWatching
: l10n.continueEpisode(continueIndex + 1),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
if (_continueFraction != null) ...[
const SizedBox(height: 8),
_ResumeBar(
fraction: _continueFraction!,
label: l10n.resumeProgress(
_clock(logic.progress!.position),
_clock(logic.progress!.duration),
),
),
],
if (logic.progressError != null) ...[
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
errorText(l10n, logic.progressError, l10n.progressError),
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
),
),
),
TextButton(
onPressed: () => unawaited(logic.loadContinue()),
child: Text(l10n.retry),
),
],
),
],
const SizedBox(height: 18),
Text(
l10n.episodes,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
LayoutBuilder(
builder: (context, constraints) {
final sixColumnsWidth = (constraints.maxWidth - 5 * 8) / 6;
final columns = sixColumnsWidth >= 40 ? 6 : 5;
final selectedIndex = continueIndex ?? 0;
final selectedNumber = drama.episodes.isEmpty
? null
: drama.episodes[selectedIndex].number;
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));
return GridView.builder(
key: const Key('detail-episode-chips'),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: ordered.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1.35,
),
itemBuilder: (context, position) {
final item = ordered[position];
return _episodeChip(
context,
item.episode,
item.index,
selectedNumber,
);
},
);
},
),
],
),
);
}
Widget _episodeChip(
BuildContext context,
Episode episode,
int index,
int? selectedNumber,
) {
final selected = episode.number == selectedNumber;
final locked = episode.locked || (!episode.isFree && index > 0);
return InkWell(
key: Key('detail-episode-chip-${episode.number}'),
onTap: () => logic.openPlayer(context, index),
borderRadius: BorderRadius.circular(9),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? AppColors.blue
: locked
? AppColors.secondary.withValues(alpha: 0.16)
: AppColors.raised,
borderRadius: BorderRadius.circular(9),
),
child: Text(
'${episode.number}',
style: TextStyle(
color: selected
? AppColors.onPrimary
: locked
? AppColors.secondary
: AppColors.muted,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
);
}
Widget _moreMenu(BuildContext context, AppLocalizations l10n) {
final saved = context.watch<FavoriteController>().isSaved(logic.drama.id);
Widget _moreMenu(BuildContext context, AppLocalizations l10n, bool saved) {
return PopupMenuButton<String>(
key: const Key('detail-more'),
tooltip: l10n.more,
@@ -402,78 +255,64 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
},
itemBuilder: (_) => [
PopupMenuItem(
key: const Key('detail-favorite'),
key: const Key('detail-menu-favorite'),
value: _menuFavorite,
child: Row(
children: [
Icon(
saved ? Icons.star_rounded : Icons.star_border_rounded,
color: saved ? AppColors.blue : AppColors.muted,
size: 18,
),
const SizedBox(width: 9),
Text(l10n.favorite),
],
child: _menuItem(
saved ? Icons.star_rounded : Icons.star_border_rounded,
l10n.favorite,
saved ? AppColors.blue : AppColors.muted,
),
),
PopupMenuItem(
key: const Key('detail-download'),
value: _menuDownload,
child: Row(
children: [
const Icon(
Icons.download_outlined,
color: AppColors.muted,
size: 18,
),
const SizedBox(width: 9),
Text(l10n.downloads),
],
child: _menuItem(
Icons.download_outlined,
l10n.downloads,
AppColors.muted,
),
),
PopupMenuItem(
key: const Key('detail-share'),
key: const Key('detail-menu-share'),
value: _menuShare,
child: Row(
children: [
const Icon(
Icons.share_outlined,
color: AppColors.muted,
size: 18,
),
const SizedBox(width: 9),
Text(l10n.share),
],
),
child: _menuItem(Icons.share_outlined, l10n.share, AppColors.muted),
),
PopupMenuItem(
key: const Key('detail-report'),
value: _menuReport,
child: Row(
children: [
const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18),
const SizedBox(width: 9),
Text(l10n.reportDrama),
],
child: _menuItem(
Icons.flag_outlined,
l10n.reportDrama,
AppColors.muted,
),
),
],
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.card.withValues(alpha: 0.92),
width: _moreSize,
height: _moreSize,
decoration: const BoxDecoration(
color: AppColors.card,
shape: BoxShape.circle,
),
child: const Icon(
Icons.more_horiz_rounded,
size: 18,
size: _moreIconSize,
color: AppColors.onSurface,
),
),
);
}
Widget _menuItem(IconData icon, String label, Color color) {
return Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 9),
Text(label),
],
);
}
Future<void> _shareDrama(AppLocalizations l10n, Drama drama) async {
try {
await const PlatformShareService().shareDrama(
@@ -536,6 +375,23 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
return record.fraction;
}
List<String> _tagValues(Drama drama) {
final result = <String>[];
for (final source in [drama.category, drama.genre]) {
for (final part in source.split(_tagSplitter)) {
final value = part.trim();
if (value.isNotEmpty && !result.contains(value)) result.add(value);
if (result.length == _maxTags) return result;
}
}
final language = drama.language.trim();
if (language.isNotEmpty) {
final label = _languageNames[language.toLowerCase()] ?? language;
if (!result.contains(label)) result.add(label);
}
return result.take(_maxTags).toList();
}
String _clock(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
@@ -545,65 +401,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
}
}
class _RoundAction extends StatelessWidget {
const _RoundAction({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.card.withValues(alpha: 0.92),
shape: BoxShape.circle,
),
child: IconButton(
tooltip: tooltip.isEmpty ? null : tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
icon: Icon(icon, size: 18, color: AppColors.onSurface),
),
);
}
}
class _ResumeBar extends StatelessWidget {
const _ResumeBar({required this.fraction, this.label});
final double fraction;
final String? label;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: fraction,
minHeight: 3,
backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
if (label != null) ...[
const SizedBox(height: 4),
Text(
label!,
style: const TextStyle(color: AppColors.muted, fontSize: 10),
),
],
],
);
}
}
const Map<String, String> _languageNames = {
'zh': '简体中文',
'zh-cn': '简体中文',
'zh-hans': '简体中文',
'en': 'English',
'en-us': 'English',
'id': 'Bahasa Indonesia',
'id-id': 'Bahasa Indonesia',
};
@@ -0,0 +1,109 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double _ctaHeight = 46;
const double _ctaRadius = 23;
const double _horizontal = 14;
/// 详情页固定底部按钮与可选续播信息。
class DramaDetailBottomBar extends StatelessWidget {
const DramaDetailBottomBar({
super.key,
required this.l10n,
required this.continueIndex,
required this.onPlay,
this.resume,
});
final AppLocalizations l10n;
final int? continueIndex;
final VoidCallback onPlay;
final Widget? resume;
@override
Widget build(BuildContext context) {
return Container(
key: const Key('detail-bottom-bar'),
decoration: const BoxDecoration(
color: AppColors.ink,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(_horizontal, 8, _horizontal, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (resume != null) ...[resume!, const SizedBox(height: 8)],
SizedBox(
width: double.infinity,
height: _ctaHeight,
child: FilledButton.icon(
key: const Key('detail-cta'),
onPressed: onPlay,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_ctaRadius),
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 20),
label: Text(
continueIndex == null
? l10n.startWatching
: l10n.continueEpisode(continueIndex! + 1),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
);
}
}
/// 详情页续播进度条与可选时间说明。
class DramaResumeBar extends StatelessWidget {
const DramaResumeBar({super.key, required this.fraction, this.label});
final double fraction;
final String? label;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: fraction,
minHeight: 3,
backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
if (label != null) ...[
const SizedBox(height: 3),
Text(
label!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.muted, fontSize: 10),
),
],
],
);
}
}
@@ -0,0 +1,236 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
const double _horizontal = 14;
const double _topGap = 8;
const double _posterWidth = 96;
const double _posterHeight = 128;
const double _posterRadius = 12;
const double _infoGap = 14;
const double _favoriteHeight = 34;
const double _favoriteRadius = 17;
const double _favoriteHorizontal = 12;
const double _circleSize = 36;
const double _iconSize = 18;
/// 详情页顶部操作与短剧主信息所需的交互回调。
class DramaDetailHeaderActions {
const DramaDetailHeaderActions({
required this.onBack,
required this.onShare,
required this.onFavorite,
required this.moreMenu,
});
final VoidCallback onBack;
final VoidCallback onShare;
final VoidCallback onFavorite;
final Widget moreMenu;
}
/// 详情页暖色渐变头部:操作行、封面、标题、集数与收藏按钮。
class DramaDetailHeader extends StatelessWidget {
const DramaDetailHeader({
super.key,
required this.l10n,
required this.drama,
required this.saved,
required this.actions,
});
final AppLocalizations l10n;
final Drama drama;
final bool saved;
final DramaDetailHeaderActions actions;
@override
Widget build(BuildContext context) {
return Container(
key: const Key('detail-hero'),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.surfaceTop, AppColors.ink],
),
),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(_horizontal, 8, _horizontal, 10),
child: Column(
children: [
_actions(context),
const SizedBox(height: _topGap),
_identity(context),
],
),
),
),
);
}
Widget _actions(BuildContext context) {
return Row(
children: [
_CircleAction(
actionKey: const Key('detail-back'),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Icons.chevron_left_rounded,
onPressed: actions.onBack,
),
const Spacer(),
_CircleAction(
actionKey: const Key('detail-share'),
tooltip: l10n.share,
icon: Icons.share_outlined,
onPressed: actions.onShare,
),
const SizedBox(width: 8),
actions.moreMenu,
],
);
}
Widget _identity(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
key: const Key('detail-poster'),
width: _posterWidth,
height: _posterHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_posterRadius),
boxShadow: const [
BoxShadow(
color: AppColors.cardShadow,
blurRadius: 16,
offset: Offset(0, 5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(_posterRadius),
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackAspectRatio: 3 / 4,
),
),
),
const SizedBox(width: _infoGap),
Expanded(
child: SizedBox(
height: _posterHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
key: const Key('detail-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
const SizedBox(height: 6),
Text(
l10n.episodeCountLabel(drama.episodeCount),
key: const Key('detail-episode-count'),
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
_favoriteButton(),
],
),
),
),
],
);
}
Widget _favoriteButton() {
return GestureDetector(
onTap: actions.onFavorite,
child: Container(
key: const Key('detail-favorite'),
height: _favoriteHeight,
padding: const EdgeInsets.symmetric(horizontal: _favoriteHorizontal),
decoration: BoxDecoration(
color: saved ? AppColors.blue : AppColors.card,
borderRadius: BorderRadius.circular(_favoriteRadius),
border: Border.all(color: saved ? AppColors.blue : AppColors.blue),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
saved ? Icons.star_rounded : Icons.star_border_rounded,
size: 16,
color: saved ? AppColors.onPrimary : AppColors.blue,
),
const SizedBox(width: 4),
Text(
l10n.favorite,
style: TextStyle(
color: saved ? AppColors.onPrimary : AppColors.blue,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
}
class _CircleAction extends StatelessWidget {
const _CircleAction({
required this.actionKey,
required this.tooltip,
required this.icon,
required this.onPressed,
});
final Key actionKey;
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Container(
key: actionKey,
width: _circleSize,
height: _circleSize,
decoration: const BoxDecoration(
color: AppColors.card,
shape: BoxShape.circle,
),
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
icon: Icon(icon, size: _iconSize, color: AppColors.onSurface),
),
);
}
}
@@ -0,0 +1,406 @@
// 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});
final AppLocalizations l10n;
final Drama drama;
@override
Widget build(BuildContext context) {
final favorite = drama.favoriteCount == null
? l10n.episodeCountLabel(drama.episodeCount)
: '${formatInteractionCount(drama.favoriteCount!)} ${l10n.favorite}';
final like = drama.likeCount == null
? '—'
: '${formatInteractionCount(drama.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<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 ? 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);
}
}