From ec163f905dd9361f9f83bb4a8c4609ab18646421 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:16:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(detail):=20=E9=87=8D=E5=81=9A=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E8=A7=86=E8=A7=89=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 7 + lib/l10n/app_id.arb | 7 + lib/l10n/app_localizations.dart | 42 + lib/l10n/app_localizations_en.dart | 21 + lib/l10n/app_localizations_id.dart | 21 + lib/l10n/app_localizations_zh.dart | 21 + lib/l10n/app_zh.arb | 7 + lib/pages/drama_detail/drama_detail_view.dart | 1115 +++++++++++++---- test/drama_detail/drama_detail_view_test.dart | 151 +++ 9 files changed, 1153 insertions(+), 239 deletions(-) create mode 100644 test/drama_detail/drama_detail_view_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 40fa98a..b6db44d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "VERTICAL MINI-SERIES", "followSeries": "Follow Series", "seasonOneVerticalCinema": "Season 1 • Vertical Cinema", + "seasonOneOriginalRelease": "Season 1 • Original Release", + "startWatching": "Start Watching", + "autoNextOn": "Auto-Next On", + "viewAll": "View all", + "showLess": "Less", + "views": "views", + "enterFullscreen": "Fullscreen", "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 459faeb..e839cd1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "SERIAL MINI VERTIKAL", "followSeries": "Ikuti Series", "seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal", + "seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal", + "startWatching": "Mulai Menonton", + "autoNextOn": "Lanjut Otomatis", + "viewAll": "Lihat semua", + "showLess": "Lebih sedikit", + "views": "tayangan", + "enterFullscreen": "Layar penuh", "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3dee2a6..5ae6fac 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -262,6 +262,48 @@ abstract class AppLocalizations { /// **'Season 1 • Vertical Cinema'** String get seasonOneVerticalCinema; + /// No description provided for @seasonOneOriginalRelease. + /// + /// In en, this message translates to: + /// **'Season 1 • Original Release'** + String get seasonOneOriginalRelease; + + /// No description provided for @startWatching. + /// + /// In en, this message translates to: + /// **'Start Watching'** + String get startWatching; + + /// No description provided for @autoNextOn. + /// + /// In en, this message translates to: + /// **'Auto-Next On'** + String get autoNextOn; + + /// No description provided for @viewAll. + /// + /// In en, this message translates to: + /// **'View all'** + String get viewAll; + + /// No description provided for @showLess. + /// + /// In en, this message translates to: + /// **'Less'** + String get showLess; + + /// No description provided for @views. + /// + /// In en, this message translates to: + /// **'views'** + String get views; + + /// No description provided for @enterFullscreen. + /// + /// In en, this message translates to: + /// **'Fullscreen'** + String get enterFullscreen; + /// No description provided for @artworkOnly. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e40f0bd..f93e1e7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -90,6 +90,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema'; + @override + String get seasonOneOriginalRelease => 'Season 1 • Original Release'; + + @override + String get startWatching => 'Start Watching'; + + @override + String get autoNextOn => 'Auto-Next On'; + + @override + String get viewAll => 'View all'; + + @override + String get showLess => 'Less'; + + @override + String get views => 'views'; + + @override + String get enterFullscreen => 'Fullscreen'; + @override String get artworkOnly => 'Artwork'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 66e2973..8cfe7fa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -90,6 +90,27 @@ class AppLocalizationsId extends AppLocalizations { @override String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal'; + @override + String get seasonOneOriginalRelease => 'Musim 1 • Rilis Orisinal'; + + @override + String get startWatching => 'Mulai Menonton'; + + @override + String get autoNextOn => 'Lanjut Otomatis'; + + @override + String get viewAll => 'Lihat semua'; + + @override + String get showLess => 'Lebih sedikit'; + + @override + String get views => 'tayangan'; + + @override + String get enterFullscreen => 'Layar penuh'; + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e3cac4a..ef96d79 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -89,6 +89,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院'; + @override + String get seasonOneOriginalRelease => '第 1 季 · 原创上线'; + + @override + String get startWatching => '开始观看'; + + @override + String get autoNextOn => '自动连播'; + + @override + String get viewAll => '查看全部'; + + @override + String get showLess => '收起'; + + @override + String get views => '次播放'; + + @override + String get enterFullscreen => '全屏'; + @override String get artworkOnly => '海报展示'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ae47bc8..5967360 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "竖屏短剧", "followSeries": "追剧", "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", + "seasonOneOriginalRelease": "第 1 季 · 原创上线", + "startWatching": "开始观看", + "autoNextOn": "自动连播", + "viewAll": "查看全部", + "showLess": "收起", + "views": "次播放", + "enterFullscreen": "全屏", "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index e3b25d2..f358240 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -6,30 +6,33 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/playback_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/platform_share_service.dart'; 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/remote_image.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/glass_panel.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 '../../core/data/repositories/watch_history_repository.dart'; -import '../../core/data/repositories/playback_repository.dart'; const String _reportMenuValue = 'report'; class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); + final Drama drama; @override @@ -38,6 +41,7 @@ class DramaDetailPage extends StatefulWidget { class _DramaDetailPageState extends State { late final DramaDetailLogic logic; + bool _synopsisExpanded = false; @override void initState() { @@ -78,211 +82,261 @@ class _DramaDetailPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - if (logic.loading) { - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: const Center(child: CircularProgressIndicator()), - ); - } - if (logic.failed) { - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - errorText(l10n, logic.failureMessage, l10n.detailError), - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), - FilledButton(onPressed: _load, child: Text(l10n.retry)), - ], - ), - ), - ), - ); - } + if (logic.loading) return _loading(l10n); + if (logic.failed) return _failed(l10n); final drama = logic.drama; - if (drama.episodes.isEmpty) { - // 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏 - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(localizedDramaTitle(l10n, drama)), - ), - body: EmptyState( - key: const Key('drama-detail-empty'), - icon: Icons.video_library_outlined, - title: l10n.noEpisodesYet, - subtitle: l10n.emptyHint, - actionLabel: l10n.retry, - onAction: _load, - ), - ); - } + if (drama.episodes.isEmpty) return _empty(l10n, drama); + return Scaffold( backgroundColor: AppColors.ink, body: CustomScrollView( + key: const Key('detail-scroll'), slivers: [ - SliverAppBar( - expandedHeight: 300, - pinned: true, - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - actions: [ - _reportAction(context, l10n), - _downloadAction(context, l10n), - _favoriteAction(context, l10n), - ], - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - RemoteImage(drama.posterUrl), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.center, - colors: [Color(0x99000000), Color(0x00000000)], - ), - ), + SliverToBoxAdapter(child: _hero(context, l10n, drama)), + SliverToBoxAdapter(child: _metadata(l10n, drama)), + SliverToBoxAdapter(child: _synopsis(l10n, drama)), + SliverToBoxAdapter(child: _primaryActions(context, l10n)), + SliverToBoxAdapter(child: _episodeHeader(l10n, drama)), + SliverToBoxAdapter(child: _episodeChips(drama)), + _episodeGrid(context, l10n, drama), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Widget _loading(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: const Center(child: CircularProgressIndicator()), + ); + + Widget _failed(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + errorText(l10n, logic.failureMessage, l10n.detailError), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted), + ), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: Text(l10n.retry)), + ], + ), + ), + ), + ); + + Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, drama)), + ), + body: EmptyState( + key: const Key('drama-detail-empty'), + icon: Icons.video_library_outlined, + title: l10n.noEpisodesYet, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: _load, + ), + ); + + Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { + return Stack( + children: [ + AspectRatio( + key: const Key('detail-hero'), + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x260B0C10), + Color(0x000B0C10), + AppColors.ink, + ], + stops: [0, 0.5, 1], ), - ], + ), + ), + ], + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 8, + left: 16, + right: 16, + child: Row( + children: [ + _GlassCircleButton( + key: const Key('detail-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + onPressed: () => Navigator.of(context).maybePop(), + ), + const Spacer(), + _GlassCircleButton( + key: const Key('detail-share'), + tooltip: l10n.share, + icon: Icons.share_outlined, + onPressed: () => unawaited(_shareDrama(l10n, drama)), + ), + const SizedBox(width: 8), + _favoriteButton( + context, + l10n, + key: const Key('detail-favorite-top'), + ), + const SizedBox(width: 8), + _reportAction(context, l10n), + ], + ), + ), + ], + ); + } + + Widget _metadata(AppLocalizations l10n, Drama drama) { + final genres = [ + if (drama.genre.isNotEmpty) drama.genre, + if (drama.category.isNotEmpty) drama.category, + ]; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + key: const Key('detail-episode-count'), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.3), ), ), - ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Text( - l10n.technicalDemoCount(drama.episodes.length), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - letterSpacing: 1.4, - color: AppColors.blueSoft, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - localizedDramaTitle(l10n, drama), - style: const TextStyle( - fontSize: AppDimens.detailHeadline, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 10), - Text( - localizedDramaSynopsis(l10n, drama), - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppColors.muted), - ), - const SizedBox(height: 20), - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FilledButton.icon( - onPressed: () => - logic.openPlayer(context, logic.continueIndex ?? 0), - icon: const Icon(Icons.play_arrow_rounded), - label: Text( - logic.continueIndex == null - ? l10n.watchEpisodeOne - : l10n.continueEpisode(logic.continueIndex! + 1), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 6, + height: 6, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.secondary, + shape: BoxShape.circle, ), - 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: Colors.white60, - fontSize: 12, - ), - ), - ), - TextButton( - onPressed: () => unawaited(logic.loadContinue()), - child: Text(l10n.retry), - ), - ], - ), - ], - ], - ), - const SizedBox(height: 30), - Text( - l10n.episodes, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontSize: AppDimens.sectionTitle, - fontWeight: FontWeight.w700, ), ), - ]), + const SizedBox(width: 5), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: AppColors.secondary, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], ), ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 40), - sliver: SliverList.builder( - itemCount: drama.episodes.length, - itemBuilder: (context, index) { - final episode = drama.episodes[index]; - final fraction = logic.progressFractionFor(episode.id); - return Card( - margin: const EdgeInsets.only(bottom: 10), - child: ListTile( - minTileHeight: 76, - leading: CircleAvatar( - backgroundColor: AppColors.blue.withValues(alpha: 0.18), - child: Text('${episode.number}'), - ), - title: Text(l10n.episodeLabel(episode.number)), - subtitle: fraction == null - ? null - : Padding( - padding: const EdgeInsets.only(top: 6), - child: _ResumeBar(fraction: fraction), - ), - trailing: const Icon(Icons.play_circle_outline_rounded), - onTap: () => logic.openPlayer(context, index), - ), - ); - }, + const SizedBox(height: 10), + Text( + localizedDramaTitle(l10n, drama), + key: const Key('detail-title'), + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.15, + letterSpacing: 0, + ), + ), + const SizedBox(height: 5), + Text( + l10n.seasonOneOriginalRelease, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 7, + runSpacing: 7, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (drama.viewCount > 0) + _metaPill( + icon: Icons.local_fire_department_rounded, + label: '${_compactCount(drama.viewCount)} ${l10n.views}', + color: AppColors.blue, + ), + for (final genre in genres) + _metaPill(label: genre, color: AppColors.cream), + ], + ), + ], + ), + ); + } + + Widget _metaPill({ + IconData? icon, + required String label, + required Color color, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(7), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: color), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w500, ), ), ], @@ -290,42 +344,582 @@ class _DramaDetailPageState extends State { ); } - double? get _continueFraction { - final record = logic.progress; - if (record == null || !record.hasPosition) return null; - if (logic.continueIndex == null) return null; - return record.fraction; - } - - /// 下载当前续看集或第一集;业务判定完全交给服务端。 - Widget _downloadAction(BuildContext context, AppLocalizations l10n) { - final episodes = logic.drama.episodes; - final index = logic.continueIndex ?? 0; - final episode = episodes.isEmpty - ? null - : episodes[index.clamp(0, episodes.length - 1)]; - return IconButton( - key: const Key('detail-download'), - tooltip: l10n.downloads, - icon: const Icon(Icons.download_outlined), - onPressed: episode == null - ? null - : () => requestEpisodeDownload(context, episode.id), + Widget _synopsis(AppLocalizations l10n, Drama drama) { + final text = localizedDramaSynopsis(l10n, drama); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: GlassPanel( + key: const Key('detail-synopsis-card'), + radius: AppDimens.glassSmallRadius, + blur: 12, + padding: const EdgeInsets.fromLTRB(13, 11, 13, 9), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x1A12141A), Color(0x3312141A)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + key: const Key('detail-synopsis'), + maxLines: _synopsisExpanded ? 100 : 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + height: 1.45, + ), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + key: const Key('detail-synopsis-toggle'), + onPressed: () => + setState(() => _synopsisExpanded = !_synopsisExpanded), + style: TextButton.styleFrom( + foregroundColor: AppColors.secondary, + minimumSize: const Size(44, 32), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_synopsisExpanded ? l10n.showLess : l10n.more), + Icon( + _synopsisExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 16, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _primaryActions(BuildContext context, AppLocalizations l10n) { + final continueIndex = logic.continueIndex; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: SizedBox( + height: 52, + child: FilledButton.icon( + key: const Key('detail-cta'), + onPressed: () => + logic.openPlayer(context, continueIndex ?? 0), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 20), + label: Text( + continueIndex == null + ? l10n.startWatching + : l10n.continueEpisode(continueIndex + 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 10), + _squareAction( + key: const Key('detail-favorite'), + tooltip: l10n.favorite, + icon: + context.watch().isSaved(logic.drama.id) + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + selected: context.watch().isSaved( + logic.drama.id, + ), + onPressed: () => _toggleFavorite(l10n), + ), + const SizedBox(width: 10), + _squareAction( + key: const Key('detail-download'), + tooltip: l10n.downloads, + icon: Icons.download_outlined, + onPressed: _downloadCurrent, + ), + ], + ), + if (_continueFraction != null) ...[ + const SizedBox(height: 9), + _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: 12, + ), + ), + ), + TextButton( + onPressed: () => unawaited(logic.loadContinue()), + child: Text(l10n.retry), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _episodeHeader(AppLocalizations l10n, Drama drama) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 10), + child: Row( + children: [ + Text( + l10n.episodes, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 5), + Text( + '(${drama.episodeCount})', + style: const TextStyle( + color: AppColors.subtle, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Container( + key: const Key('detail-episodes-range'), + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.all, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 3), + const Icon( + Icons.keyboard_arrow_down_rounded, + size: 15, + color: AppColors.muted, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _episodeChips(Drama drama) { + final selected = logic.continueIndex ?? 0; + return SizedBox( + key: const Key('detail-episode-chips'), + height: 44, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20), + scrollDirection: Axis.horizontal, + itemCount: drama.episodes.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final episode = drama.episodes[index]; + final active = index == selected; + 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(12), + child: Container( + width: 46, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.16) + : AppColors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: active + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.35) + : AppColors.border, + ), + ), + child: Text( + '${episode.number}', + style: TextStyle( + color: active + ? AppColors.onPrimary + : locked + ? AppColors.secondary + : AppColors.muted, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + }, + ), + ); + } + + Widget _episodeGrid( + BuildContext context, + AppLocalizations l10n, + Drama drama, + ) { + return SliverPadding( + key: const Key('detail-episode-grid'), + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 108, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => + _episodeCard(context, l10n, drama, drama.episodes[index], index), + childCount: drama.episodes.length, + ), + ), + ); + } + + Widget _episodeCard( + BuildContext context, + AppLocalizations l10n, + Drama drama, + Episode episode, + int index, + ) { + final locked = episode.locked || (!episode.isFree && index > 0); + return Material( + key: Key('detail-episode-card-${episode.number}'), + color: AppColors.card, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => logic.openPlayer(context, index), + child: LayoutBuilder( + builder: (context, constraints) { + final thumbWidth = (constraints.maxWidth - 70).clamp(58.0, 96.0); + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + SizedBox( + key: Key('detail-episode-thumb-${episode.number}'), + width: thumbWidth, + height: 64, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const DecoratedBox( + decoration: BoxDecoration(color: Color(0x33000000)), + ), + Center( + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: locked + ? Colors.black54 + : AppColors.blue.withValues(alpha: 0.9), + shape: BoxShape.circle, + ), + child: Icon( + locked + ? Icons.lock_rounded + : Icons.play_arrow_rounded, + size: 14, + color: AppColors.onPrimary, + ), + ), + ), + if (episode.durationSec > 0) + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.68), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _clock( + Duration(seconds: episode.durationSec), + ), + style: const TextStyle( + color: AppColors.cream, + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Wrap( + spacing: 4, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _episodeBadge( + episode.isFree && !locked + ? l10n.free + : l10n.coins, + locked ? AppColors.secondary : AppColors.success, + ), + Text( + l10n.episodeLabel(episode.number), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 9.5, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + localizedEpisodeTitle(l10n, episode.number), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: locked + ? AppColors.muted + : AppColors.onSurface, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + GestureDetector( + onTap: () => + requestEpisodeDownload(context, episode.id), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.download_outlined, + size: 12, + color: AppColors.subtle, + ), + const SizedBox(width: 3), + Flexible( + child: Text( + l10n.downloads, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 9, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _episodeBadge(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: color.withValues(alpha: 0.28)), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 8.5, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + Widget _squareAction({ + required Key key, + required String tooltip, + required IconData icon, + required VoidCallback onPressed, + bool selected = false, + }) { + return SizedBox( + key: key, + width: 48, + height: 48, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + style: IconButton.styleFrom( + backgroundColor: selected ? AppColors.raised : AppColors.card, + foregroundColor: selected ? AppColors.blue : AppColors.cream, + side: BorderSide( + color: selected ? AppColors.blue : AppColors.outlineDark, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + icon: Icon(icon, size: 21), + ), + ); + } + + Widget _favoriteButton( + BuildContext context, + AppLocalizations l10n, { + required Key key, + }) { + final saved = context.watch().isSaved(logic.drama.id); + return _GlassCircleButton( + key: key, + tooltip: l10n.favorite, + icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, + selected: saved, + onPressed: () => _toggleFavorite(l10n), ); } - /// 详情页更多菜单:本批只提供举报短剧。 Widget _reportAction(BuildContext context, AppLocalizations l10n) { return PopupMenuButton( key: const Key('detail-more'), tooltip: l10n.more, + color: AppColors.card, onSelected: (_) => unawaited(_reportDrama(l10n)), itemBuilder: (_) => [ PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)), ], + child: const _GlassCircle( + child: Icon( + Icons.more_horiz_rounded, + color: AppColors.onSurface, + size: 20, + ), + ), ); } + Future _shareDrama(AppLocalizations l10n, Drama drama) async { + try { + await const PlatformShareService().shareDrama( + title: localizedDramaTitle(l10n, drama), + synopsis: localizedDramaSynopsis(l10n, drama), + footer: l10n.shareFooter, + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.shareFailed))); + } + } + + Future _toggleFavorite(AppLocalizations l10n) async { + if (!await ensureSignedIn(context)) return; + if (!mounted) return; + unawaited(context.read().lightImpact()); + final ok = await logic.toggleFavorite(); + if (ok || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + } + + void _downloadCurrent() { + final episodes = logic.drama.episodes; + if (episodes.isEmpty) return; + final index = (logic.continueIndex ?? 0).clamp(0, episodes.length - 1); + requestEpisodeDownload(context, episodes[index].id); + } + Future _reportDrama(AppLocalizations l10n) async { if (!await ensureSignedIn(context) || !mounted) return; final draft = await showReportDialog(context, title: l10n.reportDrama); @@ -348,25 +942,11 @@ class _DramaDetailPageState extends State { ); } - /// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。 - Widget _favoriteAction(BuildContext context, AppLocalizations l10n) { - final favorites = context.watch(); - final saved = favorites.isSaved(logic.drama.id); - return IconButton( - tooltip: l10n.favorite, - icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border), - onPressed: () async { - if (!await ensureSignedIn(context)) return; - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await logic.toggleFavorite(); - if (ok || !context.mounted) return; - // 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚) - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); - }, - ); + double? get _continueFraction { + final record = logic.progress; + if (record == null || !record.hasPosition) return null; + if (logic.continueIndex == null) return null; + return record.fraction; } String _clock(Duration duration) { @@ -378,7 +958,52 @@ class _DramaDetailPageState extends State { } } -/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。 +class _GlassCircleButton extends StatelessWidget { + const _GlassCircleButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + this.selected = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final bool selected; + + @override + Widget build(BuildContext context) { + return _GlassCircle( + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon( + icon, + size: 20, + color: selected ? AppColors.blue : AppColors.onSurface, + ), + ), + ); + } +} + +class _GlassCircle extends StatelessWidget { + const _GlassCircle({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 40, + height: 40, + child: GlassPanel(radius: 999, blur: 14, child: child), + ); + } +} + class _ResumeBar extends StatelessWidget { const _ResumeBar({required this.fraction, this.label}); @@ -394,9 +1019,9 @@ class _ResumeBar extends StatelessWidget { borderRadius: BorderRadius.circular(AppDimens.progressRadius), child: LinearProgressIndicator( value: fraction, - minHeight: AppDimens.progressHeight, + minHeight: AppDimens.progressLineHeight, backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), + valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), if (label != null) ...[ @@ -413,3 +1038,15 @@ class _ResumeBar extends StatelessWidget { ); } } + +String _compactCount(int value) { + if (value >= 1000000) { + final scaled = value / 1000000; + return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M'; + } + if (value >= 1000) { + final scaled = value / 1000; + return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K'; + } + return '$value'; +} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart new file mode 100644 index 0000000..3bd89f3 --- /dev/null +++ b/test/drama_detail/drama_detail_view_test.dart @@ -0,0 +1,151 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_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/catalog_controller.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/platform/haptics.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'; + +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; + +void main() { + testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', ( + 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())); + await tester.pumpAndSettle(); + + final hero = tester.widget( + find.byKey(const Key('detail-hero')), + ); + expect(hero.aspectRatio, closeTo(16 / 9, 0.001)); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + expect(find.byKey(const Key('detail-favorite')), findsOneWidget); + expect(find.byKey(const Key('detail-download')), findsOneWidget); + expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('detail-episode-chip-1'))), + const Size(46, 44), + ); + + final synopsis = tester.widget( + find.byKey(const Key('detail-synopsis')), + ); + expect(synopsis.maxLines, 2); + await tester.tap(find.byKey(const Key('detail-synopsis-toggle'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('detail-synopsis'))).maxLines, + 100, + ); + + final grid = tester.widget( + find.descendant( + of: find.byKey(const Key('detail-episode-grid')), + matching: find.byType(SliverGrid), + ), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 2); + expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))), + const Size(96, 64), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_fit_compact_viewport_when_detail_scrolls', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const Key('detail-scroll')), + const Offset(0, -520), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +Widget _host(Drama drama) { + final catalog = FakeCatalogRepository(dramas: [drama]); + return MultiProvider( + providers: [ + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakePlaybackRepository()), + Provider.value(value: FakeHaptics()), + ChangeNotifierProvider( + create: (_) => FavoriteController(InMemoryFavoriteRepository()), + ), + ChangeNotifierProvider( + create: (_) => InteractionController(FakeInteractionRepository()), + ), + ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), + ], + child: MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: DramaDetailPage(drama: drama), + ), + ); +} + +Drama _detailDrama() { + 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', + genre: 'Urban Romance', + category: 'Romance', + totalEpisodes: 8, + viewCount: 4800000, + detailLoaded: true, + episodes: [ + for (var number = 1; number <= 8; number++) + Episode( + id: 'detail-k25-$number', + number: number, + title: 'Episode $number', + isFree: number == 1, + durationSec: 135, + locked: number > 1, + videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '', + ), + ], + ); +}