From 69faa7818c0510da687af8ad52113f1f12161ab0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:46:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E5=81=9A=E7=9F=AD?= =?UTF-8?q?=E5=89=A7=E8=AF=A6=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/pages/drama_detail/drama_detail_view.dart | 485 ++++++------------ .../widgets/drama_detail_bottom_bar.dart | 109 ++++ .../widgets/drama_detail_header.dart | 236 +++++++++ .../widgets/drama_detail_sections.dart | 406 +++++++++++++++ test/drama_detail/drama_detail_view_test.dart | 352 ++++++++----- 12 files changed, 1136 insertions(+), 470 deletions(-) create mode 100644 lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart create mode 100644 lib/pages/drama_detail/widgets/drama_detail_header.dart create mode 100644 lib/pages/drama_detail/widgets/drama_detail_sections.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c4de9e9..bdfda89 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -276,6 +276,7 @@ } }, "episodes": "Episodes", + "synopsisTitle": "Synopsis", "watchEpisodeOne": "Watch episode 1", "continueEpisode": "Continue episode {number}", "@continueEpisode": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 669b9d3..eba5ffb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -250,6 +250,7 @@ } }, "episodes": "Episode", + "synopsisTitle": "Sinopsis", "watchEpisodeOne": "Tonton episode 1", "continueEpisode": "Lanjut episode {number}", "@continueEpisode": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6b99116..a646edf 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 239ce3d..f7bca93 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index c7da015..08a7bbb 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 49f554d..f29c0bd 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -576,6 +576,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get episodes => '选集'; + @override + String get synopsisTitle => '简介'; + @override String get watchEpisodeOne => '观看第 1 集'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8e430b3..00541ed 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -236,6 +236,7 @@ "episodeCount": "{count} 集", "technicalDemoCount": "免费试看 · {count} 集", "episodes": "选集", + "synopsisTitle": "简介", "watchEpisodeOne": "观看第 1 集", "continueEpisode": "续看第 {number} 集", "freeTestMedia": "复用同一演示视频 · 免费", diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 0f9ef45..208ab48 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -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 { if (logic.failed) return _failed(l10n); final drama = logic.drama; if (drama.episodes.isEmpty) return _empty(l10n, drama); - + final saved = context.watch().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 { 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 { 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 { 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 { ), ); - 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().isSaved(logic.drama.id); + Widget _moreMenu(BuildContext context, AppLocalizations l10n, bool saved) { return PopupMenuButton( key: const Key('detail-more'), tooltip: l10n.more, @@ -402,78 +255,64 @@ class _DramaDetailPageState extends State { }, 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 _shareDrama(AppLocalizations l10n, Drama drama) async { try { await const PlatformShareService().shareDrama( @@ -536,6 +375,23 @@ class _DramaDetailPageState extends State { return record.fraction; } + List _tagValues(Drama drama) { + final result = []; + 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 { } } -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 _languageNames = { + 'zh': '简体中文', + 'zh-cn': '简体中文', + 'zh-hans': '简体中文', + 'en': 'English', + 'en-us': 'English', + 'id': 'Bahasa Indonesia', + 'id-id': 'Bahasa Indonesia', +}; diff --git a/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart new file mode 100644 index 0000000..e2ae9ab --- /dev/null +++ b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart @@ -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), + ), + ], + ], + ); + } +} diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart new file mode 100644 index 0000000..e414d8a --- /dev/null +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -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), + ), + ); + } +} diff --git a/lib/pages/drama_detail/widgets/drama_detail_sections.dart b/lib/pages/drama_detail/widgets/drama_detail_sections.dart new file mode 100644 index 0000000..1b8981f --- /dev/null +++ b/lib/pages/drama_detail/widgets/drama_detail_sections.dart @@ -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 createState() => _DramaSynopsisSectionState(); +} + +class _DramaSynopsisSectionState extends State { + 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 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 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); + } +} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index ac4bd22..370b205 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -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( - find.descendant( - of: find.byKey(const Key('detail-back')), - matching: find.byType(Icon), - ), - ) - .size, - 18, + .widget(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( - 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( + 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(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( - 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(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(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(find.byKey(const Key('detail-stat-favorite-value'))) + .data, + '8 episodes', + ); + expect( + tester.widget(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( @@ -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(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 _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.value( - value: InMemoryWatchHistoryRepository(), + final auth = await _signedInAuth(); + final favorites = FavoriteController( + InMemoryFavoriteRepository(cards: [target]), + ); + await favorites.load(); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakePlaybackRepository()), + Provider.value(value: createWidgetPlayer), + Provider(create: (_) => PlaybackCoordinator()), + Provider.value(value: interactions), + Provider.value(value: FakeCommentRepository()), + Provider.value(value: FakeHaptics()), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.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.value(value: FakePlaybackRepository()), - Provider.value(value: createWidgetPlayer), - Provider(create: (_) => PlaybackCoordinator()), - Provider.value(value: interactions), - Provider.value(value: FakeCommentRepository()), - Provider.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 _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 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.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' : '', ), ], );