merge: 合并 K38-A 短剧详情页重做
This commit is contained in:
commit
7b2970d5c5
12 files changed
+1136
-470
No files matched your search
@@ -276,6 +276,7 @@
|
||||
}
|
||||
},
|
||||
"episodes": "Episodes",
|
||||
"synopsisTitle": "Synopsis",
|
||||
"watchEpisodeOne": "Watch episode 1",
|
||||
"continueEpisode": "Continue episode {number}",
|
||||
"@continueEpisode": {
|
||||
|
||||
@@ -250,6 +250,7 @@
|
||||
}
|
||||
},
|
||||
"episodes": "Episode",
|
||||
"synopsisTitle": "Sinopsis",
|
||||
"watchEpisodeOne": "Tonton episode 1",
|
||||
"continueEpisode": "Lanjut episode {number}",
|
||||
"@continueEpisode": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -576,6 +576,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get episodes => '选集';
|
||||
|
||||
@override
|
||||
String get synopsisTitle => '简介';
|
||||
|
||||
@override
|
||||
String get watchEpisodeOne => '观看第 1 集';
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@
|
||||
"episodeCount": "{count} 集",
|
||||
"technicalDemoCount": "免费试看 · {count} 集",
|
||||
"episodes": "选集",
|
||||
"synopsisTitle": "简介",
|
||||
"watchEpisodeOne": "观看第 1 集",
|
||||
"continueEpisode": "续看第 {number} 集",
|
||||
"freeTestMedia": "复用同一演示视频 · 免费",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' : '',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user