From 21d457fd98bc4f3f26c3dafc15be19c9743a1b95 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=A0=A1=E6=AD=A3=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=8F=91=E7=8E=B0=E8=AF=A6=E6=83=85=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=99=A8=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 361 +++---- .../discover/widgets/home_drama_grid.dart | 157 ++- lib/pages/drama_detail/drama_detail_view.dart | 909 +++++------------- lib/pages/player/player_logic.dart | 12 + lib/pages/player/player_view.dart | 517 ++++++---- .../player/widgets/home_feed_overlay.dart | 554 +++-------- .../widgets/player_featured_comment.dart | 158 +++ .../player/widgets/seek_progress_bar.dart | 2 +- test/discover/home_all_dramas_grid_test.dart | 32 +- test/drama_detail/drama_detail_view_test.dart | 47 +- test/home/home_preview_layout_test.dart | 64 ++ test/player/player_fullscreen_test.dart | 27 +- test/widget_test.dart | 62 +- 13 files changed, 1218 insertions(+), 1684 deletions(-) create mode 100644 lib/pages/player/widgets/player_featured_comment.dart create mode 100644 test/home/home_preview_layout_test.dart diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index ae88d85..c468231 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -13,7 +13,6 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.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 'discover_binding.dart'; @@ -165,53 +164,41 @@ class _DiscoverPageState extends State { ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 2, 16, 12), + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), child: Row( children: [ + Container( + width: 3, + height: 18, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.trendingMicroDramas, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - l10n.trendingSubtitle, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 11, - ), - ), - ], + child: Text( + l10n.trendingMicroDramas, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewSectionTitle, + fontWeight: FontWeight.w800, + ), ), ), TextButton( onPressed: () => - categories.selectCode(CategoryController.hotCode), + categories.selectCode(CategoryController.allCode), style: TextButton.styleFrom( - foregroundColor: AppColors.blue, + foregroundColor: AppColors.accent, minimumSize: const Size(44, 44), - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: EdgeInsets.zero, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.seeAll, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - const Icon(Icons.chevron_right_rounded, size: 16), - ], + child: Text( + '${l10n.all} ›', + style: const TextStyle(fontSize: 10), ), ), ], @@ -236,55 +223,41 @@ class _DiscoverPageState extends State { Widget _header(AppLocalizations l10n) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 12), + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), child: Row( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - l10n.appName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 21, - fontWeight: FontWeight.w700, - height: 1.05, - letterSpacing: 0, - ), - ), - ), - ], - ), - const SizedBox(height: 3), - Text( - l10n.verticalMiniSeries, - style: const TextStyle( - color: AppColors.muted, - fontSize: 10, - fontWeight: FontWeight.w500, - letterSpacing: 0.6, - ), - ), - ], + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: logic.openSearch, + ), + const SizedBox(width: 7), Semantics( button: true, label: widget.signedIn ? l10n.myTab : l10n.logIn, @@ -293,20 +266,20 @@ class _DiscoverPageState extends State { customBorder: const CircleBorder(), onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, child: Container( - width: 38, - height: 38, + width: 28, + height: 28, decoration: BoxDecoration( shape: BoxShape.circle, - color: AppColors.raised, - border: Border.all( - color: AppColors.secondary.withValues(alpha: 0.7), - width: 2, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], ), ), child: const Icon( Icons.person_rounded, color: AppColors.onSurface, - size: 21, + size: 15, ), ), ), @@ -318,9 +291,9 @@ class _DiscoverPageState extends State { Widget _search() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), child: SizedBox( - height: AppDimens.searchHeight, + height: 36, child: TextField( key: const Key('discover-search'), controller: logic.search, @@ -331,27 +304,22 @@ class _DiscoverPageState extends State { decoration: InputDecoration( isDense: true, filled: true, - fillColor: AppColors.card, + fillColor: AppColors.input, hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5), + hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, - size: 20, - ), - suffixIcon: const Icon( - Icons.mic_none_rounded, - color: AppColors.muted, - size: 19, + size: 17, ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - borderSide: const BorderSide(color: AppColors.border), + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - borderSide: const BorderSide(color: AppColors.blue, width: 1.2), + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(color: AppColors.blue, width: 1), ), ), ), @@ -361,9 +329,7 @@ class _DiscoverPageState extends State { Widget _categories(AppLocalizations l10n, CategoryController categories) { final choices = <({String code, String label})>[ - (code: CategoryController.allCode, label: l10n.all), - (code: CategoryController.hotCode, label: l10n.hot), - (code: CategoryController.newCode, label: l10n.newest), + (code: CategoryController.allCode, label: l10n.recommended), for (final category in categories.categories) (code: 'category-${category.id}', label: category.name), ]; @@ -371,7 +337,7 @@ class _DiscoverPageState extends State { key: const Key('home-category-chips'), height: AppDimens.tapTarget, child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), scrollDirection: Axis.horizontal, itemCount: choices.length, separatorBuilder: (_, _) => const SizedBox(width: 8), @@ -390,12 +356,10 @@ class _DiscoverPageState extends State { alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 15), decoration: BoxDecoration( - color: selected ? AppColors.blue : AppColors.card, + color: selected ? AppColors.blue : const Color(0xFF29272B), borderRadius: BorderRadius.circular(999), border: Border.all( - color: selected - ? AppColors.blue - : Colors.white.withValues(alpha: 0.08), + color: selected ? AppColors.blue : Colors.transparent, ), ), child: Text( @@ -403,7 +367,7 @@ class _DiscoverPageState extends State { maxLines: 1, style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.muted, - fontSize: 12, + fontSize: AppDimens.chipFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), @@ -423,23 +387,20 @@ class _DiscoverPageState extends State { WatchProgress record, ) { final progress = record.fraction; - return GlassPanel( + return Container( key: const Key('discover-continue-watching'), - radius: AppDimens.glassSmallRadius, - blur: 18, - padding: const EdgeInsets.all(12), - gradient: const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)], + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), ), child: Row( children: [ ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), child: SizedBox( - width: 64, - height: 88, + width: 52, + height: 66, child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, @@ -447,57 +408,38 @@ class _DiscoverPageState extends State { ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 5), - Flexible( - child: Text( - l10n.continueWatching, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.blue, - fontSize: 9.5, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ), - ], + Text( + l10n.continueWatching, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.accent, + fontSize: 8, + fontWeight: FontWeight.w800, + ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( drama.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w600, + fontSize: 12, + fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( - _resumeMeta(drama, record), + _resumeShort(drama, record), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 10.5, - ), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), const SizedBox(height: 7), ClipRRect( @@ -505,7 +447,7 @@ class _DiscoverPageState extends State { child: LinearProgressIndicator( value: progress <= 0 ? null : progress, minHeight: 3, - backgroundColor: Colors.white.withValues(alpha: 0.12), + backgroundColor: const Color(0xFF474248), valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), @@ -525,29 +467,24 @@ class _DiscoverPageState extends State { style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, - minimumSize: const Size(64, 34), - padding: const EdgeInsets.symmetric(horizontal: 10), - shape: const StadiumBorder(), + minimumSize: const Size(56, 28), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), textStyle: const TextStyle( - fontSize: 11, + fontSize: 10, fontWeight: FontWeight.w700, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.play), - const SizedBox(width: 2), - const Icon(Icons.play_arrow_rounded, size: 14), - ], - ), + child: Text(l10n.resume), ), ], ), ); } - String _resumeMeta(Drama drama, WatchProgress record) { + String _resumeShort(Drama drama, WatchProgress record) { final label = AppLocalizations.of(context)!; Episode? episode; for (final item in drama.episodes) { @@ -556,10 +493,8 @@ class _DiscoverPageState extends State { break; } } - final head = episode == null - ? '' - : '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · '; - return '$head${_duration(record.position)} / ${_duration(record.duration)}'; + if (episode == null) return _duration(record.position); + return label.episodeOfTotal(episode.number, drama.episodeCount); } Widget _quickActions(AppLocalizations l10n, CategoryController categories) { @@ -574,7 +509,7 @@ class _DiscoverPageState extends State { onTap: logic.openSearch, ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-charts'), @@ -584,17 +519,17 @@ class _DiscoverPageState extends State { onTap: () => categories.selectCode(CategoryController.hotCode), ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-new'), icon: Icons.auto_awesome_rounded, - label: l10n.newest, + label: l10n.newReleases, color: AppColors.gold, onTap: () => categories.selectCode(CategoryController.newCode), ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-watchlist'), @@ -617,8 +552,8 @@ class _DiscoverPageState extends State { items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, onTap: () => logic.act( context, @@ -637,8 +572,8 @@ class _DiscoverPageState extends State { ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, label: l10n.myTab, onTap: () => logic.act( context, @@ -651,6 +586,37 @@ class _DiscoverPageState extends State { ); } +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 14), + ), + ); + } +} + class _QuickAction extends StatelessWidget { const _QuickAction({ super.key, @@ -668,31 +634,20 @@ class _QuickAction extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + color: AppColors.quick, + borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Container( - height: 76, - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - border: Border.all(color: AppColors.border), - ), + height: 62, + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 17), - ), - const SizedBox(height: 5), + Icon(icon, color: color, size: 16), + const SizedBox(height: 4), Text( label, maxLines: 2, @@ -700,7 +655,7 @@ class _QuickAction extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, - fontSize: 9.5, + fontSize: 9, fontWeight: FontWeight.w500, height: 1.05, ), diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 015e269..f2a1ee0 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -37,27 +37,22 @@ class _HomeDramaGrid extends StatelessWidget { ), ); } - if (dramas.isEmpty) { - final missingCategory = categories.selectedSectionMissing; return _fill( EmptyState( key: const Key('home-all-dramas-empty'), icon: searching ? Icons.search_off_rounded - : missingCategory - ? Icons.grid_view_rounded : Icons.movie_filter_outlined, title: searching ? l10n.noSearchResults - : missingCategory + : categories.selectedSectionMissing ? l10n.noCategoryDramas : l10n.noSearchResults, subtitle: l10n.emptyHint, ), ); } - return SliverMainAxisGroup( slivers: [ _dramaGrid(dramas), @@ -68,14 +63,14 @@ class _HomeDramaGrid extends StatelessWidget { Widget _dramaGrid(List items) => SliverPadding( key: const Key('home-all-dramas-section'), - padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + padding: const EdgeInsets.symmetric(horizontal: 14), sliver: SliverGrid( key: const Key('home-all-dramas-grid'), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, - mainAxisSpacing: 14, - crossAxisSpacing: 12, - childAspectRatio: 0.55, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 164, ), delegate: SliverChildBuilderDelegate( (context, index) => _HomeDramaCard( @@ -88,9 +83,8 @@ class _HomeDramaGrid extends StatelessWidget { ), ); - Widget _fill(Widget child) { - return SliverFillRemaining(hasScrollBody: false, child: child); - } + Widget _fill(Widget child) => + SliverFillRemaining(hasScrollBody: false, child: child); } class _HomeDramaCard extends StatelessWidget { @@ -107,18 +101,21 @@ class _HomeDramaCard extends StatelessWidget { @override Widget build(BuildContext context) { final genre = drama.genre.isEmpty ? drama.category : drama.genre; + final free = + drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); return InkWell( key: Key('home-drama-card-${drama.id}'), onTap: onTap, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AspectRatio( + SizedBox( key: Key('home-drama-cover-${drama.id}'), - aspectRatio: 3 / 4.2, + height: 120, child: ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(10), child: Stack( fit: StackFit.expand, children: [ @@ -132,120 +129,76 @@ class _HomeDramaCard extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Color(0x33000000), - Color(0x00000000), - Color(0xB3000000), - ], - stops: [0, 0.42, 1], + colors: [Color(0x0D000000), Color(0xCC000000)], + stops: [0.45, 1], ), ), ), - Positioned( - top: 8, - left: 8, - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - if (drama.isHot) _badge(l10n.hot, AppColors.blue), - if (drama.freeEpisodes > 0) - _badge(l10n.free, AppColors.success), - ], - ), - ), - if (drama.viewCount > 0) + if (free) Positioned( - right: 8, - bottom: 8, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.42), - borderRadius: BorderRadius.circular(7), + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - child: Text( - _compactCount(drama.viewCount), - key: Key('home-drama-views-${drama.id}'), - style: const TextStyle( - color: AppColors.cream, - fontSize: 10, - fontWeight: FontWeight.w600, - ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + l10n.free, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 8, + fontWeight: FontWeight.w800, ), ), ), ), + Positioned( + left: 6, + right: 6, + bottom: 6, + child: Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 9, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ), ], ), ), ), - const SizedBox(height: 8), + const SizedBox(height: 5), Text( drama.title, key: Key('home-drama-title-${drama.id}'), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w600, - height: 1.2, + fontSize: 11, + fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( genre.isEmpty ? l10n.videoPending : genre, key: Key('home-drama-genre-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 11, - fontWeight: FontWeight.w400, - height: 1.2, - ), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), ], ), ); } - - Widget _badge(String label, Color color) { - return DecoratedBox( - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(999), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - child: Text( - label, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 9, - fontWeight: FontWeight.w800, - letterSpacing: 0.3, - height: 1.1, - ), - ), - ), - ); - } -} - -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/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index f358240..fc8d774 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -21,14 +21,16 @@ 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/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'; -const String _reportMenuValue = 'report'; +const String _menuFavorite = 'favorite'; +const String _menuDownload = 'download'; +const String _menuShare = 'share'; +const String _menuReport = 'report'; class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); @@ -41,7 +43,6 @@ class DramaDetailPage extends StatefulWidget { class _DramaDetailPageState extends State { late final DramaDetailLogic logic; - bool _synopsisExpanded = false; @override void initState() { @@ -93,13 +94,8 @@ class _DramaDetailPageState extends State { key: const Key('detail-scroll'), slivers: [ 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)), + SliverToBoxAdapter(child: _content(context, l10n, drama)), + const SliverToBoxAdapter(child: SizedBox(height: 28)), ], ), ); @@ -159,314 +155,115 @@ class _DramaDetailPageState extends State { ); 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], - ), - ), - ), - ], + return SizedBox( + key: const Key('detail-hero'), + height: 190, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, ), - ), - 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 DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x33000000), Color(0xCC0B0C10)], ), - 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), - ], + ), ), - ), - ], + 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), + ), + ], + ), ); } - Widget _metadata(AppLocalizations l10n, Drama drama) { - final genres = [ - if (drama.genre.isNotEmpty) drama.genre, - if (drama.category.isNotEmpty) drama.category, - ]; + Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) { + final continueIndex = logic.continueIndex; return Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( + Text( + l10n.episodeCount(drama.episodeCount), 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), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 6, - height: 6, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.secondary, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(width: 5), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: AppColors.secondary, - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), - ], + style: const TextStyle( + color: AppColors.secondary, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 1, ), ), - const SizedBox(height: 10), + const SizedBox(height: 7), 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, + fontSize: 18, + fontWeight: FontWeight.w800, + height: 1.2, ), ), 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, + localizedDramaSynopsis(l10n, drama), + key: const Key('detail-synopsis'), + style: const TextStyle( + color: AppColors.muted, fontSize: 11, - fontWeight: FontWeight.w500, + height: 1.6, ), ), - ], - ), - ); - } - - 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, + 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, ), ), - 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), + const SizedBox(height: 8), _ResumeBar( fraction: _continueFraction!, label: l10n.resumeProgress( @@ -484,7 +281,7 @@ class _DramaDetailPageState extends State { errorText(l10n, logic.progressError, l10n.progressError), style: const TextStyle( color: AppColors.muted, - fontSize: 12, + fontSize: 11, ), ), ), @@ -495,393 +292,159 @@ class _DramaDetailPageState extends State { ], ), ], - ], - ), - ); - } - - Widget _episodeHeader(AppLocalizations l10n, Drama drama) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 10), - child: Row( - children: [ + const SizedBox(height: 18), Text( l10n.episodes, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, + fontSize: 14, + fontWeight: FontWeight.w800, ), ), - 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, - ), - ], - ), + const SizedBox(height: 8), + Wrap( + key: const Key('detail-episode-chips'), + spacing: 6, + runSpacing: 6, + children: [ + for (var index = 0; index < drama.episodes.length; index++) + _episodeChip(context, drama, index, continueIndex), + ], ), ], ), ); } - 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( + Widget _episodeChip( 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, + int? selectedIndex, ) { + final episode = drama.episodes[index]; + final selected = index == (selectedIndex ?? 0); 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, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - }, + return InkWell( + key: Key('detail-episode-chip-${episode.number}'), + onTap: () => logic.openPlayer(context, index), + borderRadius: BorderRadius.circular(20), + child: Container( + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.16) + : const Color(0xFF29272B), + borderRadius: BorderRadius.circular(20), ), - ), - ); - } - - 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), + child: Text( + '${episode.number}', + style: TextStyle( + color: selected + ? AppColors.onPrimary + : locked + ? AppColors.secondary + : AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w700, ), ), - icon: Icon(icon, size: 21), ), ); } - Widget _favoriteButton( - BuildContext context, - AppLocalizations l10n, { - required Key key, - }) { + Widget _moreMenu(BuildContext context, AppLocalizations l10n) { 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)), + onSelected: (value) { + switch (value) { + case _menuFavorite: + unawaited(_toggleFavorite(l10n)); + case _menuDownload: + _downloadCurrent(); + case _menuShare: + unawaited(_shareDrama(l10n, logic.drama)); + case _menuReport: + unawaited(_reportDrama(l10n)); + } + }, itemBuilder: (_) => [ - PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)), + PopupMenuItem( + key: const Key('detail-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), + ], + ), + ), + 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), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-share'), + value: _menuShare, + child: Row( + children: [ + const Icon( + Icons.share_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.share), + ], + ), + ), + 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: const _GlassCircle( - child: Icon( + child: Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: const Icon( Icons.more_horiz_rounded, + size: 18, color: AppColors.onSurface, - size: 20, ), ), ); @@ -958,52 +521,37 @@ class _DramaDetailPageState extends State { } } -class _GlassCircleButton extends StatelessWidget { - const _GlassCircleButton({ +class _RoundAction extends StatelessWidget { + const _RoundAction({ 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; + final VoidCallback? onPressed; @override Widget build(BuildContext context) { - return _GlassCircle( + return Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), child: IconButton( - tooltip: tooltip, + tooltip: tooltip.isEmpty ? null : tooltip, onPressed: onPressed, padding: EdgeInsets.zero, - icon: Icon( - icon, - size: 20, - color: selected ? AppColors.blue : AppColors.onSurface, - ), + icon: Icon(icon, size: 18, color: 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}); @@ -1016,11 +564,11 @@ class _ResumeBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.progressRadius), + borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: fraction, - minHeight: AppDimens.progressLineHeight, - backgroundColor: Colors.white24, + minHeight: 3, + backgroundColor: const Color(0xFF474248), valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), @@ -1028,25 +576,10 @@ class _ResumeBar extends StatelessWidget { const SizedBox(height: 4), Text( label!, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), + style: const TextStyle(color: AppColors.muted, fontSize: 10), ), ], ], ); } } - -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/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index aa4e50b..64c459e 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -531,6 +531,18 @@ class PlayerLogic extends ChangeNotifier { : null; } + bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; + + Future playNextEpisode() async { + final next = nextVisibleIndex(session.currentIndex); + if (next == null || !pages.hasClients) return; + await pages.animateToPage( + visibleIndices.indexOf(next), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a195c69..63a1b63 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -18,13 +18,13 @@ import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; +import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -340,7 +340,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { l10n: l10n, drama: drama, episode: episode, - controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), saved: context.watch().isSaved(drama.id), onLike: () async { @@ -381,10 +380,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready - ? (position) => logic.seekTo(context, videoPort!, position) - : null, ) else if (!logic.screenCleared) _PlaybackOverlay( @@ -398,9 +393,13 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onBack: widget.embedded ? null : () => unawaited(logic.leave(context)), - onDiscover: () => logic.showDiscover(context), onEpisodes: () => logic.showEpisodes(context), - onMy: () => logic.showMy(context), + onNext: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + playing: logic.session.userWantsPlay, + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) onLike: () async { if (await ensureSignedIn(context)) { @@ -434,7 +433,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onDownload: () => requestEpisodeDownload(context, episode.id), onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), - onClear: () => logic.setScreenCleared(true), onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, @@ -442,7 +440,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, - showBottomNav: false, ), if (logic.screenCleared) SafeArea( @@ -649,20 +646,19 @@ class _PlaybackOverlay extends StatelessWidget { required this.controller, required this.liked, required this.saved, + required this.playing, + required this.onTogglePlay, required this.onBack, - required this.onDiscover, required this.onEpisodes, - required this.onMy, + required this.onComments, required this.onLike, required this.onSave, required this.onDownload, - required this.onComments, required this.onShare, - required this.onClear, required this.onPip, required this.onScrubStart, required this.onSeek, - required this.showBottomNav, + this.onNext, }); final AppLocalizations l10n; @@ -671,230 +667,333 @@ class _PlaybackOverlay extends StatelessWidget { final VideoPlayerController? controller; final bool liked; final bool saved; + final bool playing; + final VoidCallback onTogglePlay; final VoidCallback? onBack; - final VoidCallback onDiscover; final VoidCallback onEpisodes; - final VoidCallback onMy; + final VoidCallback onComments; + final VoidCallback? onNext; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onDownload; - final VoidCallback onComments; final VoidCallback onShare; - final VoidCallback onClear; final VoidCallback? onPip; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; - final bool showBottomNav; @override - Widget build(BuildContext context) => SafeArea( - bottom: showBottomNav, - child: Stack( - children: [ - Column( + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + _topBar(context), + Expanded( + child: Stack( + children: [ + Center( + child: GestureDetector( + key: const Key('player-play-toggle'), + onTap: onTogglePlay, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 26, + ), + ), + ), + ), + const Positioned( + right: 10, + bottom: 14, + child: Text( + '1.0x', + style: TextStyle(color: Color(0xFFDDDDDD), fontSize: 9), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: Column( + children: [ + SeekProgressBar( + valueListenable: controller, + semanticsLabel: l10n.seekVideo, + showTimes: false, + onScrubStart: onScrubStart, + onSeek: onSeek, + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: _PlayerAction( + key: const Key('player-episodes'), + icon: Icons.view_list_rounded, + label: l10n.episodes, + onTap: onEpisodes, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _PlayerAction( + key: const Key('player-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 32, + child: FilledButton( + key: const Key('player-next'), + onPressed: onNext, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.subtle, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + l10n.nextEpisode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon(Icons.skip_next_rounded, size: 14), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + PlayerFeaturedComment(drama: drama, onViewAll: onComments), + ], + ), + ), + ], + ), + ); + } + + Widget _topBar(BuildContext context) { + return SizedBox( + height: 46, + child: Row( + children: [ + if (onBack != null) + _TopAction( + key: const Key('player-back'), + tooltip: l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: onBack!, + ) + else + const SizedBox(width: 28), + const SizedBox(width: 8), + Expanded( + child: Text( + '${localizedDramaTitle(l10n, drama)} · ' + '${l10n.episodeLabel(episode.number)}', + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + _TopAction( + key: const Key('player-more'), + tooltip: l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ); + } + + Future _openMore(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const Spacer(), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 8, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _info()), - _rail(), - ], + ListTile( + key: const Key('player-more-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + onDownload(); + }, ), - const SizedBox(height: 4), - Padding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - 20 + MediaQuery.paddingOf(context).bottom, + if (onPip != null) + ListTile( + key: const Key('player-more-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + onPip!(); + }, ), - child: SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - onScrubStart: onScrubStart, - onSeek: onSeek, + ListTile( + key: const Key('player-more-like'), + leading: Icon( + liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + color: liked ? AppColors.blue : AppColors.muted, ), + title: Text(l10n.like), + onTap: () { + Navigator.pop(sheetContext); + onLike(); + }, + ), + ListTile( + key: const Key('player-more-favorite'), + leading: Icon( + saved ? Icons.star_rounded : Icons.star_border_rounded, + color: saved ? AppColors.blue : AppColors.muted, + ), + title: Text(l10n.favorite), + onTap: () { + Navigator.pop(sheetContext); + onSave(); + }, + ), + ListTile( + key: const Key('player-more-share'), + leading: const Icon(Icons.share_outlined, color: AppColors.muted), + title: Text(l10n.share), + onTap: () { + Navigator.pop(sheetContext); + onShare(); + }, ), - if (showBottomNav) _fallbackNav(), ], ), - if (onBack != null) - Positioned( - top: 4, - left: 8, - child: IconButton( - tooltip: l10n.backToSeries, - onPressed: onBack, - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.arrow_back_rounded), - ), - ), - Positioned( - top: 4, - right: 8, - child: IconButton( - tooltip: l10n.clearScreen, - onPressed: onClear, - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.fit_screen_outlined), - ), - ), - ], - ), - ); - - Widget _info() => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w800, - height: 1.15, - shadows: [Shadow(blurRadius: 12, color: Colors.black)], - ), ), - const SizedBox(height: 6), - Text( - '${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - ], - ); - - Widget _rail() => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _RailAction( - key: const Key('rail-like'), - icon: liked ? Icons.favorite : Icons.favorite_border, - label: l10n.like, - selected: liked, - onTap: onLike, - ), - _RailAction( - key: const Key('rail-save'), - icon: saved ? Icons.bookmark : Icons.bookmark_border, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - key: const Key('rail-download'), - icon: Icons.download_outlined, - label: l10n.downloads, - onTap: onDownload, - ), - _RailAction( - icon: Icons.grid_view_outlined, - label: l10n.episodes, - onTap: onEpisodes, - ), - _RailAction( - key: const Key('rail-comments'), - icon: Icons.chat_bubble_outline, - label: l10n.comments, - onTap: onComments, - ), - _RailAction( - key: const Key('rail-share'), - icon: Icons.share_outlined, - label: l10n.share, - onTap: onShare, - ), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _railTapSize, - height: _railTapSize, - ), - color: AppColors.cream, - icon: const Icon( - Icons.picture_in_picture_alt_outlined, - size: _railIconSize, - ), - ), - ], - ); - - Widget _fallbackNav() => AppBottomNav( - items: [ - AppBottomNavItem( - tabKey: const Key('player-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, - label: l10n.homeTab, - selected: true, - onTap: () {}, - ), - AppBottomNavItem( - tabKey: const Key('player-nav-discover'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, - onTap: onDiscover, - ), - AppBottomNavItem( - tabKey: const Key('player-nav-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, - label: l10n.myTab, - onTap: onMy, - ), - ], - ); + ); + } } -const double _railIconSize = 28; -const double _railIconWeight = 300; -const double _railTapSize = 44; +class _TopAction extends StatelessWidget { + const _TopAction({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); -class _RailAction extends StatelessWidget { - const _RailAction({ + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 15), + ), + ); + } +} + +class _PlayerAction extends StatelessWidget { + const _PlayerAction({ super.key, required this.icon, required this.label, required this.onTap, - this.selected = false, }); + final IconData icon; final String label; - final VoidCallback? onTap; - final bool selected; + final VoidCallback onTap; @override - Widget build(BuildContext context) => IconButton( - tooltip: label, - onPressed: onTap, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _railTapSize, - height: _railTapSize, - ), - color: selected ? AppColors.blueSoft : AppColors.cream, - icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0), - ); + Widget build(BuildContext context) { + return Material( + color: const Color(0xFF2A2A30), + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: AppColors.secondary), + const SizedBox(width: 3), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index e631877..1e0de71 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -3,27 +3,21 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/glass_panel.dart'; -import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -import 'seek_progress_bar.dart'; -/// Immersive home layer from `screens/01-home.html`. -/// -/// Playback, ticket, progress, favorite, like, comment, and share callbacks are -/// all owned by [PlayerPage]; this widget only paints the design overlay. +/// Home feed layer from `docs/移动端设计预览.html` screen 01. class HomeFeedOverlay extends StatelessWidget { const HomeFeedOverlay({ super.key, required this.l10n, required this.drama, required this.episode, - required this.controller, required this.liked, required this.saved, required this.onLike, @@ -34,15 +28,12 @@ class HomeFeedOverlay extends StatelessWidget { required this.onDownload, required this.onClear, required this.onSearch, - required this.onScrubStart, - required this.onSeek, this.onPip, }); final AppLocalizations l10n; final Drama drama; final Episode episode; - final VideoPlayerController? controller; final bool liked; final bool saved; final VoidCallback onLike; @@ -54,12 +45,9 @@ class HomeFeedOverlay extends StatelessWidget { final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; @override Widget build(BuildContext context) { - final compact = MediaQuery.sizeOf(context).height < 620; return SafeArea( bottom: false, child: Column( @@ -67,16 +55,16 @@ class HomeFeedOverlay extends StatelessWidget { _header(context), Expanded( child: Align( - alignment: Alignment.bottomRight, + alignment: Alignment.centerRight, child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 12, 4), - child: _rail(compact), + padding: const EdgeInsets.fromLTRB(0, 0, 10, 12), + child: _rail(), ), ), ), Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), - child: _infoCard(context, compact), + padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + child: _infoCard(), ), ], ), @@ -85,79 +73,35 @@ class HomeFeedOverlay extends StatelessWidget { Widget _header(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 12, 4), + padding: const EdgeInsets.fromLTRB(14, 6, 10, 4), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 7), - Flexible( - child: Text( - localizedDramaTitle(l10n, drama), - key: const Key('home-feed-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, - height: 1.1, - shadows: [ - Shadow(blurRadius: 10, color: Colors.black87), - ], - ), - ), - ), - if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()], - ], - ), - const SizedBox(height: 3), - Text( - '${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.64), - fontSize: 10.5, - fontWeight: FontWeight.w500, - shadows: const [ - Shadow(blurRadius: 8, color: Colors.black87), - ], - ), - ), - ], + child: Text( + localizedDramaTitle(l10n, drama), + key: const Key('home-feed-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), ), ), - const SizedBox(width: 6), - _GlassIconButton( + _RoundIcon( + key: const Key('home-feed-search'), tooltip: l10n.search, icon: Icons.search_rounded, onPressed: onSearch, ), - const SizedBox(width: 4), - _GlassIconButton( - tooltip: l10n.clearScreen, - icon: Icons.fit_screen_outlined, - onPressed: onClear, - ), - const SizedBox(width: 4), - _GlassIconButton( + const SizedBox(width: 6), + _RoundIcon( + key: const Key('home-feed-more'), tooltip: l10n.more, - icon: Icons.more_horiz_rounded, + icon: Icons.menu_rounded, onPressed: () => _openMore(context), ), ], @@ -165,131 +109,53 @@ class HomeFeedOverlay extends StatelessWidget { ); } - Widget _hotBadge() { - return DecoratedBox( - decoration: BoxDecoration( - color: AppColors.blue.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(5), - border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - child: Text( - l10n.hot, - style: const TextStyle( - color: AppColors.blue, - fontSize: 8, - fontWeight: FontWeight.w800, - letterSpacing: 0.4, - height: 1.1, - ), - ), - ), - ); - } - - Widget _rail(bool compact) { + Widget _rail() { return Column( mainAxisSize: MainAxisSize.min, children: [ - Semantics( - button: true, - label: saved ? l10n.followingTab : l10n.followSeries, - child: InkWell( - key: const Key('home-feed-follow'), - onTap: onSave, - customBorder: const CircleBorder(), - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: compact ? 40 : 44, - height: compact ? 40 : 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.blue, width: 2), - color: AppColors.raised, - ), - child: ClipOval( - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - ), - ), - Positioned( - bottom: -4, - left: 14, - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: saved ? AppColors.success : AppColors.blue, - shape: BoxShape.circle, - border: Border.all(color: AppColors.ink, width: 2), - ), - child: Icon( - saved ? Icons.check_rounded : Icons.add_rounded, - color: AppColors.onPrimary, - size: 11, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 8), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-like'), icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, label: l10n.like, selected: liked, onTap: onLike, ), - _RailAction( - compact: compact, - key: const Key('rail-save'), - icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-comments'), icon: Icons.chat_bubble_outline_rounded, label: l10n.comments, onTap: onComments, ), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-share'), - icon: Icons.share_outlined, + icon: Icons.ios_share_rounded, label: l10n.share, onTap: onShare, ), + _RailItem( + key: const Key('rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: l10n.favorite, + selected: saved, + onTap: onSave, + ), ], ); } - Widget _infoCard(BuildContext context, bool compact) { - final genres = [ - if (drama.genre.isNotEmpty) drama.genre, - if (drama.category.isNotEmpty) drama.category, - ].take(2).toList(); + Widget _infoCard() { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; return GlassPanel( key: const Key('home-feed-info-card'), - radius: 26, - blur: 24, - padding: const EdgeInsets.fromLTRB(16, 13, 16, 12), + radius: 14, + blur: 6, + padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0x260B0C10), Color(0x590B0C10)], + colors: [Color(0xBF0F0F14), Color(0xA60F0F14)], ), + borderColor: Colors.white.withValues(alpha: 0.06), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -298,94 +164,73 @@ class HomeFeedOverlay extends StatelessWidget { children: [ Expanded( child: Wrap( - spacing: 5, + spacing: 6, runSpacing: 4, children: [ - for (var i = 0; i < genres.length; i++) - _genreChip(genres[i], i.isEven), + if (genre.isNotEmpty) + _tag( + genre, + background: AppColors.blue, + foreground: AppColors.onPrimary, + ), + if (drama.category.isNotEmpty && drama.category != genre) + _tag( + drama.category, + background: const Color(0xFF333333), + foreground: const Color(0xFFDDDDDD), + ), ], ), ), const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 5, - height: 5, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.success, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(width: 4), - Text( - '${l10n.episodeLabel(episode.number)} / ' - '${drama.episodeCount}', - key: const Key('home-feed-episode'), - style: const TextStyle( - color: AppColors.cream, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ], + Flexible( + child: Text( + l10n.episodeOfTotal(episode.number, drama.episodeCount), + key: const Key('home-feed-episode'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 7), Text( - drama.synopsis, - maxLines: compact ? 1 : 2, + localizedEpisodeTitle(l10n, episode.number), + key: const Key('home-feed-episode-title'), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.cream, - fontSize: 12, - height: 1.35, + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 6), - SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - showTimes: false, - compact: true, - onScrubStart: onScrubStart, - onSeek: onSeek, - ), - _timeRow(), - const SizedBox(height: 8), + const SizedBox(height: 10), Row( children: [ Expanded( child: SizedBox( - height: 40, + height: 34, child: FilledButton.icon( key: const Key('home-feed-follow-series'), onPressed: onSave, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, - shape: const StadiumBorder(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, ), ), - icon: Icon( - saved ? Icons.check_rounded : Icons.add_rounded, - size: 16, - ), + icon: const Icon(Icons.play_arrow_rounded, size: 16), label: Text( saved ? l10n.followingTab : l10n.followSeries, maxLines: 1, @@ -395,11 +240,33 @@ class HomeFeedOverlay extends StatelessWidget { ), ), const SizedBox(width: 8), - _GlassAction( - tooltip: l10n.episodes, - icon: Icons.grid_view_rounded, - label: l10n.episodes, - onPressed: onEpisodes, + Expanded( + child: SizedBox( + height: 34, + child: FilledButton( + key: const Key('home-feed-episodes'), + onPressed: onEpisodes, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2A2A30), + foregroundColor: AppColors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: Text( + l10n.episodes, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), ), ], ), @@ -408,41 +275,29 @@ class HomeFeedOverlay extends StatelessWidget { ); } - Widget _genreChip(String label, bool secondary) { - final color = secondary ? AppColors.secondary : AppColors.blue; + Widget _tag( + String label, { + required Color background, + required Color foreground, + }) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.3)), + color: background, + borderRadius: BorderRadius.circular(6), ), child: Text( label, maxLines: 1, - overflow: TextOverflow.ellipsis, style: TextStyle( - color: color, - fontSize: 10, - fontWeight: FontWeight.w600, - height: 1.1, + color: foreground, + fontSize: 8, + fontWeight: FontWeight.w800, ), ), ); } - Widget _timeRow() { - final source = controller; - if (source == null) { - return const _Times(position: Duration.zero, duration: Duration.zero); - } - return ValueListenableBuilder( - valueListenable: source, - builder: (context, value, _) => - _Times(position: value.position, duration: value.duration), - ); - } - Future _openMore(BuildContext context) async { await showModalBottomSheet( context: context, @@ -456,7 +311,7 @@ class HomeFeedOverlay extends StatelessWidget { key: const Key('home-feed-download'), leading: const Icon( Icons.download_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.downloads), onTap: () { @@ -469,7 +324,7 @@ class HomeFeedOverlay extends StatelessWidget { key: const Key('home-feed-pip'), leading: const Icon( Icons.picture_in_picture_alt_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.pictureInPicture), onTap: () { @@ -478,9 +333,10 @@ class HomeFeedOverlay extends StatelessWidget { }, ), ListTile( + key: const Key('home-feed-clear'), leading: const Icon( Icons.fit_screen_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.clearScreen), onTap: () { @@ -495,35 +351,9 @@ class HomeFeedOverlay extends StatelessWidget { } } -class _Times extends StatelessWidget { - const _Times({required this.position, required this.duration}); - - final Duration position; - final Duration duration; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _format(position), - style: const TextStyle(color: AppColors.muted, fontSize: 9), - ), - Text( - _format(duration), - style: const TextStyle(color: AppColors.muted, fontSize: 9), - ), - ], - ), - ); - } -} - -class _GlassIconButton extends StatelessWidget { - const _GlassIconButton({ +class _RoundIcon extends StatelessWidget { + const _RoundIcon({ + super.key, required this.tooltip, required this.icon, required this.onPressed, @@ -531,140 +361,76 @@ class _GlassIconButton extends StatelessWidget { final String tooltip; final IconData icon; - final VoidCallback? onPressed; + final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox( - width: 34, - height: 34, + width: 28, + height: 28, child: IconButton( tooltip: tooltip, onPressed: onPressed, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: Colors.black.withValues(alpha: 0.28), + backgroundColor: const Color(0xCC2C2D33), foregroundColor: AppColors.onSurface, - side: BorderSide(color: Colors.white.withValues(alpha: 0.16)), ), - icon: Icon(icon, size: 18), + icon: Icon(icon, size: 14), ), ); } } -class _GlassAction extends StatelessWidget { - const _GlassAction({ - required this.tooltip, - required this.icon, - required this.label, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final String label; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(999), - child: Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: Colors.white.withValues(alpha: 0.15)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: AppColors.secondary, size: 15), - const SizedBox(width: 5), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.cream, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _RailAction extends StatelessWidget { - const _RailAction({ +class _RailItem extends StatelessWidget { + const _RailItem({ super.key, required this.icon, required this.label, required this.onTap, this.selected = false, - this.compact = false, }); final IconData icon; final String label; final VoidCallback onTap; final bool selected; - final bool compact; @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.only(bottom: compact ? 2 : 3), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - GlassPanel( - radius: 999, - blur: 16, - child: SizedBox( - width: compact ? 40 : 38, - height: compact ? 40 : 38, - child: IconButton( - tooltip: label, - onPressed: onTap, - padding: EdgeInsets.zero, - icon: Icon( + padding: const EdgeInsets.only(bottom: 14), + child: Semantics( + button: true, + label: label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: SizedBox( + width: 44, + child: Column( + children: [ + Icon( icon, - size: 19, + size: 22, color: selected ? AppColors.blue : AppColors.onSurface, ), - ), + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 9, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], ), ), - if (!compact) ...[ - const SizedBox(height: 2), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.cream, - fontSize: 9, - fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ], - ], + ), ), ); } } - -String _format(Duration value) { - final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; -} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart new file mode 100644 index 0000000..e888ba8 --- /dev/null +++ b/lib/pages/player/widgets/player_featured_comment.dart @@ -0,0 +1,158 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/repositories/comment_repository.dart'; +import '../../../core/data/repositories/interaction_repository.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +class PlayerFeaturedComment extends StatefulWidget { + const PlayerFeaturedComment({ + super.key, + required this.drama, + required this.onViewAll, + }); + + final Drama drama; + final VoidCallback onViewAll; + + @override + State createState() => _PlayerFeaturedCommentState(); +} + +class _PlayerFeaturedCommentState extends State { + late final CommentController _controller; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + dramaId: widget.drama.id, + ); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + if (_controller.comments.isEmpty) return const SizedBox.shrink(); + return _card(context, _controller.comments.first); + }, + ); + } + + Widget _card(BuildContext context, DramaComment comment) { + final l10n = AppLocalizations.of(context)!; + final time = _time(l10n, comment.createdAt); + return InkWell( + key: const Key('player-featured-comment'), + onTap: widget.onViewAll, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.secondary.withValues(alpha: 0.2), + ), + child: Text( + _initial(comment.nickname), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + comment.nickname.isEmpty + ? l10n.localViewer + : comment.nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + if (time != null) ...[ + const SizedBox(width: 4), + Text( + '· $time', + style: const TextStyle( + color: Color(0xFF777777), + fontSize: 9, + ), + ), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 5), + Text( + comment.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFDDDDDD), + fontSize: 10, + height: 1.5, + ), + ), + ], + ), + ), + ); + } + + String? _time(AppLocalizations l10n, DateTime? createdAt) { + if (createdAt == null) return null; + final diff = DateTime.now().difference(createdAt); + if (diff.inMinutes < 1) return null; + return l10n.minutesAgo(diff.inMinutes); + } + + String _initial(String nickname) { + final trimmed = nickname.trim(); + return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + } +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index cc2855d..6680fa1 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -129,7 +129,7 @@ class _SeekProgressBarState extends State { activeTrackColor: AppColors.blue, inactiveTrackColor: widget.compact ? Colors.white.withValues(alpha: 0.18) - : Colors.white30, + : const Color(0xFF474248), thumbColor: widget.compact ? AppColors.blue : Colors.white, thumbShape: RoundSliderThumbShape( enabledThumbRadius: widget.compact diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index b19c94d..3ff02ef 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -39,44 +39,42 @@ void main() { expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); - expect(find.text('All'), findsOneWidget); + expect(find.text('Recommended'), findsOneWidget); expect(find.text('For You'), findsNothing); - expect( - tester.getCenter(find.text('All')).dx, - lessThan(tester.getCenter(find.text('Hot')).dx), - ); final grid = tester.widget( find.byKey(const Key('home-all-dramas-grid')), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 2); - expect(delegate.crossAxisSpacing, 12); - expect(delegate.mainAxisSpacing, 14); - expect(delegate.childAspectRatio, closeTo(0.55, 0.001)); + expect(delegate.crossAxisSpacing, 10); + expect(delegate.mainAxisSpacing, 10); + expect(delegate.mainAxisExtent, 164); expect( tester .widget( find.byKey(const Key('home-all-dramas-section')), ) .padding, - const EdgeInsets.symmetric(horizontal: 16), + const EdgeInsets.symmetric(horizontal: 14), ); expect(find.byType(GridView), findsNothing); expect(find.byType(CustomScrollView), findsOneWidget); expect(find.byType(RemoteImage), findsWidgets); - final cover = tester.widget( - find.byKey(const Key('home-drama-cover-city-lights-demo')), + expect( + tester + .getSize(find.byKey(const Key('home-drama-cover-city-lights-demo'))) + .height, + 120, ); - expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001)); expect( tester .widget( find.byKey(const Key('home-drama-title-city-lights-demo')), ) .maxLines, - 2, + 1, ); expect( tester @@ -100,14 +98,14 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('The Signal'), findsWidgets); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); await tester.tap(find.text('Romance')); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsNothing, @@ -182,7 +180,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(tester.takeException(), isNull); }); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 3bd89f3..7fee3c7 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -21,7 +21,7 @@ import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; void main() { - testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', ( + testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -32,45 +32,24 @@ void main() { 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(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190); 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(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40); 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), - ); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); 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, - ); + expect(synopsis.style?.fontSize, 11); + expect(synopsis.style?.height, 1.6); - 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), - ); + 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-download')), findsOneWidget); + expect(find.byKey(const Key('detail-share')), findsOneWidget); + expect(find.byKey(const Key('detail-report')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -90,7 +69,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart new file mode 100644 index 0000000..ca085e3 --- /dev/null +++ b/test/home/home_preview_layout_test.dart @@ -0,0 +1,64 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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/app.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 '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episode')), findsOneWidget); + expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height, + 34, + ); + expect(find.text('HOT'), findsNothing); + expect(find.textContaining('Season'), findsNothing); + expect(find.byKey(const Key('home-feed-follow')), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 1b4468a..e9693bc 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -29,11 +29,15 @@ void main() { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(); expect(find.byType(PageView), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-download')), findsOneWidget); + expect(find.byKey(const Key('player-more-pip')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -57,24 +61,29 @@ void main() { await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(); - expect(find.text('Episode 2 · Playing now'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); expect(tester.takeException(), isNull); }); } void _expectFullscreenOverlay() { - expect(find.byKey(const Key('rail-like')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - expect(find.byKey(const Key('rail-comments')), findsOneWidget); - expect(find.byKey(const Key('rail-share')), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsOneWidget); - expect(find.byTooltip('Clear screen'), findsOneWidget); + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-more')), findsOneWidget); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect(find.byKey(const Key('player-episodes')), findsOneWidget); + expect(find.byKey(const Key('player-comments')), findsOneWidget); + expect(find.byKey(const Key('player-next')), findsOneWidget); expect(find.byType(Slider), findsOneWidget); + expect(find.byKey(const Key('rail-like')), findsNothing); + expect(find.byKey(const Key('rail-save')), findsNothing); expect(find.byKey(const Key('card-player-video-frame')), findsNothing); expect(find.byKey(const Key('card-player-progress')), findsNothing); expect(find.byKey(const Key('card-player-next')), findsNothing); - expect(find.byKey(const Key('player-title')), findsNothing); } Future _pumpApp(WidgetTester tester) async { diff --git a/test/widget_test.dart b/test/widget_test.dart index 0d90f10..10525fd 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -110,17 +110,19 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); - expect(find.byType(Slider), findsOneWidget); + expect(find.byType(Slider), findsNothing); expect(find.text('Home'), findsWidgets); - expect(find.textContaining('Playing now'), findsOneWidget); - expect(find.text('Recommended'), findsNothing); - expect(find.text('Following'), findsNothing); - expect(find.byTooltip('Episodes'), findsWidgets); - expect(find.byTooltip('Clear screen'), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsNothing); - await tester.tap(find.byTooltip('More')); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('rail-like')), findsOneWidget); + expect(find.byKey(const Key('rail-comments')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + await tester.tap(find.byKey(const Key('home-feed-more'))); await tester.pumpAndSettle(); - expect(find.text('Picture in picture'), findsOneWidget); + expect(find.byKey(const Key('home-feed-download')), findsOneWidget); + expect(find.byKey(const Key('home-feed-pip')), findsOneWidget); + expect(find.byKey(const Key('home-feed-clear')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(find.text('Watch Full'), findsNothing); @@ -139,14 +141,16 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Clear screen')); + await tester.tap(find.byKey(const Key('home-feed-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-feed-clear'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); await tester.tap(find.byTooltip('Exit clear screen')); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - expect(find.byTooltip('Clear screen'), findsOneWidget); + expect(find.byKey(const Key('home-feed-more')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -233,12 +237,11 @@ void main() { expect(find.text('我的'), findsWidgets); await tester.drag(find.byType(ListView), const Offset(0, 700)); await tester.pumpAndSettle(); - expect(find.text('我的账户'), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); expect(find.text('首页'), findsOneWidget); - await tester.tap(find.text('找剧')); + await tester.tap(find.text('发现')); await tester.pumpAndSettle(); - expect(find.text('全部'), findsOneWidget); - expect(find.text('热门'), findsOneWidget); + expect(find.text('推荐'), findsOneWidget); }); testWidgets( @@ -259,7 +262,9 @@ void main() { expect(find.text('City Lights & Velvet Nights'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap( + find.byKey(const Key('home-drama-card-city-lights-demo')), + ); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); expect(tester.takeException(), isNull); @@ -324,7 +329,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('All'), findsOneWidget); + expect(find.text('Recommended'), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect( @@ -341,7 +346,7 @@ void main() { ), findsOneWidget, ); - expect(find.byTooltip('Search'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('tab-my'))); @@ -372,7 +377,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -396,7 +401,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -440,12 +445,17 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap( + find.byKey(const Key('home-drama-card-city-lights-demo')), + ); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.text('Episode 2 · Playing now'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, ); @@ -507,9 +517,9 @@ void main() { await tester.pumpAndSettle(); final keys = [ const Key('rail-like'), - const Key('rail-save'), const Key('rail-comments'), const Key('rail-share'), + const Key('rail-save'), ]; final tops = keys .map((key) => tester.getTopLeft(find.byKey(key)).dy) @@ -644,10 +654,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.scrollUntilVisible( - find.byKey(const Key('mine-library-tab-1')), - 300, - ); + await tester.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget);