merge: 合并 K38-A 短剧详情页重做

This commit is contained in:
Zhou Weidong committed 2026-10-20 15:47:00 +08:00
commit 7b2970d5c5
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);
}
}
+223 -129
View File
@@ -3,11 +3,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/download_license.dart';
import 'package:fruitshorts/core/data/models/download_policy.dart';
import 'package:fruitshorts/core/data/models/wallet.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
@@ -15,13 +21,16 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
@@ -29,94 +38,133 @@ import 'package:fruitshorts/pages/player/player_view.dart';
import '../support/fake_catalog.dart';
import '../support/fake_haptics.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', (
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_render_new_detail_layout_and_existing_menu', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpDetail(tester);
await tester.pumpWidget(_host(_detailDrama()));
await tester.pumpAndSettle();
expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190);
expect(find.byKey(const Key('detail-hero')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('detail-back'))),
const Size(36, 36),
tester.getSize(find.byKey(const Key('detail-poster'))),
const Size(96, 128),
);
expect(
tester
.widget<Icon>(
find.descendant(
of: find.byKey(const Key('detail-back')),
matching: find.byType(Icon),
),
)
.size,
18,
.widget<Text>(find.byKey(const Key('detail-title')))
.style
?.fontSize,
20,
);
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40);
expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
final synopsis = tester.widget<Text>(
find.byKey(const Key('detail-synopsis')),
expect(find.byKey(const Key('detail-episode-count')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-favorite'))).height, 34);
final stats = tester.widget<Container>(
find.byKey(const Key('detail-stats')),
);
expect(synopsis.style?.fontSize, 11);
expect(synopsis.style?.height, 1.6);
final statsDecoration = stats.decoration! as BoxDecoration;
expect(statsDecoration.color, AppColors.card);
expect(statsDecoration.borderRadius, BorderRadius.circular(14));
expect(find.byKey(const Key('detail-synopsis-section')), findsOneWidget);
expect(find.byKey(const Key('detail-tags')), findsOneWidget);
expect(find.byKey(const Key('detail-episodes-section')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 46);
expect(find.byKey(const Key('detail-share')), findsOneWidget);
expect(find.byKey(const Key('detail-more')), findsOneWidget);
await tester.tap(find.byKey(const Key('detail-more')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
expect(find.byKey(const Key('detail-menu-favorite')), findsOneWidget);
expect(find.byKey(const Key('detail-download')), findsOneWidget);
expect(find.byKey(const Key('detail-share')), findsOneWidget);
expect(find.byKey(const Key('detail-menu-share')), findsOneWidget);
expect(find.byKey(const Key('detail-report')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('should_fit_compact_viewport_when_detail_scrolls', (
testWidgets('should_switch_favorite_button_style_when_tapped', (
tester,
) async {
tester.view.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpDetail(tester);
BoxDecoration decoration() =>
tester
.widget<Container>(find.byKey(const Key('detail-favorite')))
.decoration!
as BoxDecoration;
await tester.pumpWidget(_host(_detailDrama()));
expect(decoration().color, AppColors.card);
expect((decoration().border! as Border).top.color, AppColors.blue);
await tester.tap(find.byKey(const Key('detail-favorite')));
await tester.pumpAndSettle();
final grid = tester.widget<GridView>(
find.byKey(const Key('detail-episode-chips')),
);
expect(decoration().color, AppColors.blue);
expect(
(grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount)
.crossAxisCount,
6,
find.descendant(
of: find.byKey(const Key('detail-favorite')),
matching: find.byIcon(Icons.star_rounded),
),
findsOneWidget,
);
await tester.drag(
find.byKey(const Key('detail-scroll')),
const Offset(0, -520),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('should_render_sorted_six_column_grid_and_open_original_index', (
testWidgets('should_expand_synopsis_when_it_exceeds_three_lines', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpDetail(tester, drama: _detailDrama(longSynopsis: true));
Text synopsis() =>
tester.widget<Text>(find.byKey(const Key('detail-synopsis')));
await tester.pumpWidget(_host(_detailDrama(count: 80)));
expect(synopsis().maxLines, 3);
final toggle = find.byKey(const Key('detail-synopsis-toggle'));
expect(toggle, findsOneWidget);
await tester.ensureVisible(toggle);
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(synopsis().maxLines, isNull);
expect(
tester.widget<Text>(find.byKey(const Key('detail-synopsis'))).data,
contains('final secret'),
);
});
testWidgets('should_render_stats_and_honest_empty_values', (tester) async {
await _pumpDetail(tester);
expect(find.text('480.0万 heat'), findsOneWidget);
expect(find.text('3200 Favorite'), findsOneWidget);
expect(find.text('1.2万 Like'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await _pumpDetail(tester, drama: _detailDrama(withCounts: false));
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'8 episodes',
);
expect(
tester.widget<Text>(find.byKey(const Key('detail-stat-like-value'))).data,
'—',
);
});
testWidgets('should_render_six_column_grid_and_open_original_index', (
tester,
) async {
await _pumpDetail(tester, drama: _detailDrama(count: 80, descending: true));
await tester.ensureVisible(
find.byKey(const Key('detail-episodes-section')),
);
await tester.pumpAndSettle();
final grid = tester.widget<GridView>(
@@ -127,7 +175,7 @@ void main() {
expect(delegate.crossAxisCount, 6);
expect(delegate.mainAxisSpacing, 8);
expect(delegate.crossAxisSpacing, 8);
expect(delegate.childAspectRatio, closeTo(1.35, 0.001));
expect(delegate.mainAxisExtent, 44);
final chip1 = find.byKey(const Key('detail-episode-chip-1'));
final chip2 = find.byKey(const Key('detail-episode-chip-2'));
@@ -139,81 +187,114 @@ void main() {
greaterThan(tester.getTopLeft(chip1).dy),
);
expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx));
expect(tester.getSize(chip1), tester.getSize(chip2));
expect(tester.getSize(chip1).height, 44);
await tester.drag(
find.byKey(const Key('detail-scroll')),
const Offset(0, -420),
);
await tester.pumpAndSettle();
final chip12 = find.byKey(const Key('detail-episode-chip-12'));
expect(chip12, findsOneWidget);
await tester.tap(chip12);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-episode-lock-2')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-lock-1')), findsNothing);
await tester.tap(chip1);
await tester.pumpAndSettle();
final player = tester.widget<PlayerPage>(find.byType(PlayerPage));
expect(player.initialIndex, 11);
expect(player.initialIndex, 79);
});
testWidgets('should_sort_episode_chips_ascending_by_number', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host(_detailDrama(count: 80, descending: true)));
await tester.pumpAndSettle();
final chip1 = find.byKey(const Key('detail-episode-chip-1'));
final chip2 = find.byKey(const Key('detail-episode-chip-2'));
final chip6 = find.byKey(const Key('detail-episode-chip-6'));
final chip7 = find.byKey(const Key('detail-episode-chip-7'));
expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy);
expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx));
expect(
tester.getTopLeft(chip7).dy,
greaterThan(tester.getTopLeft(chip1).dy),
testWidgets('should_fit_compact_viewport_and_keep_cta_visible', (
tester,
) async {
await _pumpDetail(
tester,
drama: _detailDrama(count: 80),
size: const Size(320, 568),
);
expect(tester.getSize(chip1), tester.getSize(chip2));
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
await tester.ensureVisible(
find.byKey(const Key('detail-episodes-section')),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
Widget _host(Drama drama) {
final catalog = FakeCatalogRepository(dramas: [drama]);
Future<void> _pumpDetail(
WidgetTester tester, {
Drama? drama,
Size size = const Size(390, 844),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final target = drama ?? _detailDrama();
final catalog = FakeCatalogRepository(dramas: [target]);
final interactions = FakeInteractionRepository();
return MultiProvider(
providers: [
Provider<WatchHistoryRepository>.value(
value: InMemoryWatchHistoryRepository(),
final auth = await _signedInAuth();
final favorites = FavoriteController(
InMemoryFavoriteRepository(cards: [target]),
);
await favorites.load();
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<WatchHistoryRepository>.value(
value: InMemoryWatchHistoryRepository(),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<PlayerFactory>.value(value: createWidgetPlayer),
Provider(create: (_) => PlaybackCoordinator()),
Provider<InteractionRepository>.value(value: interactions),
Provider<CommentRepository>.value(value: FakeCommentRepository()),
Provider<Haptics>.value(value: FakeHaptics()),
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
ChangeNotifierProvider(
create: (_) => InteractionController(interactions),
),
ChangeNotifierProvider(
create: (_) => WalletController(_FakeCommerceRepository()),
),
ChangeNotifierProvider(
create: (_) => DownloadController(_FakeDownloadRepository()),
),
ChangeNotifierProvider(
create: (_) => CatalogController(catalog)..load(),
),
],
child: MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: DramaDetailPage(drama: target),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<PlayerFactory>.value(value: createWidgetPlayer),
Provider(create: (_) => PlaybackCoordinator()),
Provider<InteractionRepository>.value(value: interactions),
Provider<CommentRepository>.value(value: FakeCommentRepository()),
Provider<Haptics>.value(value: FakeHaptics()),
ChangeNotifierProvider(
create: (_) => FavoriteController(InMemoryFavoriteRepository()),
),
ChangeNotifierProvider(
create: (_) => InteractionController(interactions),
),
ChangeNotifierProvider(
create: (_) => WalletController(_FakeCommerceRepository()),
),
ChangeNotifierProvider(
create: (_) => DownloadController(_FakeDownloadRepository()),
),
ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()),
],
child: MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: DramaDetailPage(drama: drama),
),
);
await tester.pumpAndSettle();
}
Future<AuthController> _signedInAuth() async {
final preferences = SharedPreferencesAsync();
final store = AuthSessionStore(preferences: preferences);
await store.save(
const AuthSession(
accessToken: 'access',
refreshToken: 'refresh',
user: AuthUser(uid: '9', nickname: 'Viewer'),
),
);
final auth = AuthController(
AuthApi(
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
),
),
store,
);
await auth.load();
return auth;
}
class _FakeCommerceRepository implements CommerceRepository {
@@ -281,33 +362,46 @@ class _FakeDownloadRepository implements DownloadRepository {
Future<int> clear() async => 0;
}
Drama _detailDrama({int count = 8, bool descending = false}) {
Drama _detailDrama({
int count = 8,
bool descending = false,
bool withCounts = true,
bool longSynopsis = false,
}) {
final numbers = List<int>.generate(
count,
(index) => descending ? count - index : index + 1,
);
final synopsis = longSynopsis
? 'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls. '
'Every choice pulls her deeper into a dangerous game, and the '
'final secret changes everything she thought she knew about him.'
: 'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls.';
return Drama(
id: 'detail-k25',
title: 'Detail K25',
synopsis:
'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls.',
posterUrl: '/files/detail-k25.png',
id: 'detail-k38',
title: 'Detail K38',
synopsis: synopsis,
posterUrl: '/files/detail-k38.png',
genre: 'Urban Romance',
category: 'Romance',
language: 'id',
totalEpisodes: count,
viewCount: 4800000,
favoriteCount: withCounts ? 3200 : null,
likeCount: withCounts ? 12400 : null,
detailLoaded: true,
episodes: [
for (final number in numbers)
Episode(
id: 'detail-k25-$number',
id: 'detail-k38-$number',
number: number,
title: 'Episode $number',
isFree: number == 1,
durationSec: 135,
locked: number > 1,
videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '',
videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '',
),
],
);