feat(ui): 校正首页发现详情播放器预览

This commit is contained in:
Zhou Weidong committed 2026-10-19 15:50:11 +08:00
1 parent 87db548591
commit 21d457fd98
13 files changed
+1094 -1560

No files matched your search

+140 -185
View File
@@ -13,7 +13,6 @@ import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/glass_panel.dart';
import '../../core/widgets/remote_image.dart'; import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'discover_binding.dart'; import 'discover_binding.dart';
@@ -165,53 +164,41 @@ class _DiscoverPageState extends State<DiscoverPage> {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12), padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
child: Row( child: Row(
children: [ children: [
Container(
width: 3,
height: 18,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Expanded( Expanded(
child: Column( child: Text(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.trendingMicroDramas, l10n.trendingMicroDramas,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 17, fontSize: AppDimens.previewSectionTitle,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 2),
Text(
l10n.trendingSubtitle,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 11,
),
),
],
),
), ),
TextButton( TextButton(
onPressed: () => onPressed: () =>
categories.selectCode(CategoryController.hotCode), categories.selectCode(CategoryController.allCode),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.blue, foregroundColor: AppColors.accent,
minimumSize: const Size(44, 44), minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.zero,
), ),
child: Row( child: Text(
mainAxisSize: MainAxisSize.min, '${l10n.all} ›',
children: [ style: const TextStyle(fontSize: 10),
Text(
l10n.seeAll,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const Icon(Icons.chevron_right_rounded, size: 16),
],
), ),
), ),
], ],
@@ -236,55 +223,41 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _header(AppLocalizations l10n) { Widget _header(AppLocalizations l10n) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12), padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
child: Row( child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Container( Container(
width: 8, width: 24,
height: 8, height: 24,
decoration: const BoxDecoration( alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue, color: AppColors.blue,
shape: BoxShape.circle, borderRadius: BorderRadius.circular(7),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 16,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Text(
child: Text( l10n.discoverTab,
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontFamily: 'Outfit', fontFamily: 'Outfit',
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 21, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
height: 1.05,
letterSpacing: 0,
), ),
), ),
const Spacer(),
_HeaderIcon(
key: const Key('discover-search-button'),
icon: Icons.search_rounded,
tooltip: l10n.search,
onTap: logic.openSearch,
), ),
], const SizedBox(width: 7),
),
const SizedBox(height: 3),
Text(
l10n.verticalMiniSeries,
style: const TextStyle(
color: AppColors.muted,
fontSize: 10,
fontWeight: FontWeight.w500,
letterSpacing: 0.6,
),
),
],
),
),
const SizedBox(width: 12),
Semantics( Semantics(
button: true, button: true,
label: widget.signedIn ? l10n.myTab : l10n.logIn, label: widget.signedIn ? l10n.myTab : l10n.logIn,
@@ -293,20 +266,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
child: Container( child: Container(
width: 38, width: 28,
height: 38, height: 28,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: AppColors.raised, gradient: const LinearGradient(
border: Border.all( begin: Alignment.topLeft,
color: AppColors.secondary.withValues(alpha: 0.7), end: Alignment.bottomRight,
width: 2, colors: [Color(0xFF6A5CFF), AppColors.blue],
), ),
), ),
child: const Icon( child: const Icon(
Icons.person_rounded, Icons.person_rounded,
color: AppColors.onSurface, color: AppColors.onSurface,
size: 21, size: 15,
), ),
), ),
), ),
@@ -318,9 +291,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _search() { Widget _search() {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 14),
child: SizedBox( child: SizedBox(
height: AppDimens.searchHeight, height: 36,
child: TextField( child: TextField(
key: const Key('discover-search'), key: const Key('discover-search'),
controller: logic.search, controller: logic.search,
@@ -331,27 +304,22 @@ class _DiscoverPageState extends State<DiscoverPage> {
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
filled: true, filled: true,
fillColor: AppColors.card, fillColor: AppColors.input,
hintText: AppLocalizations.of(context)!.searchDiscover, 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( prefixIcon: const Icon(
Icons.search_rounded, Icons.search_rounded,
color: AppColors.subtle, color: AppColors.subtle,
size: 20, size: 17,
),
suffixIcon: const Icon(
Icons.mic_none_rounded,
color: AppColors.muted,
size: 19,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.border), borderSide: BorderSide.none,
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.blue, width: 1.2), borderSide: const BorderSide(color: AppColors.blue, width: 1),
), ),
), ),
), ),
@@ -361,9 +329,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _categories(AppLocalizations l10n, CategoryController categories) { Widget _categories(AppLocalizations l10n, CategoryController categories) {
final choices = <({String code, String label})>[ final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.all), (code: CategoryController.allCode, label: l10n.recommended),
(code: CategoryController.hotCode, label: l10n.hot),
(code: CategoryController.newCode, label: l10n.newest),
for (final category in categories.categories) for (final category in categories.categories)
(code: 'category-${category.id}', label: category.name), (code: 'category-${category.id}', label: category.name),
]; ];
@@ -371,7 +337,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
key: const Key('home-category-chips'), key: const Key('home-category-chips'),
height: AppDimens.tapTarget, height: AppDimens.tapTarget,
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: choices.length, itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8), separatorBuilder: (_, _) => const SizedBox(width: 8),
@@ -390,12 +356,10 @@ class _DiscoverPageState extends State<DiscoverPage> {
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 15), padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? AppColors.blue : AppColors.card, color: selected ? AppColors.blue : const Color(0xFF29272B),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all( border: Border.all(
color: selected color: selected ? AppColors.blue : Colors.transparent,
? AppColors.blue
: Colors.white.withValues(alpha: 0.08),
), ),
), ),
child: Text( child: Text(
@@ -403,7 +367,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
maxLines: 1, maxLines: 1,
style: TextStyle( style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted, color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 12, fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500, fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
height: 1.1, height: 1.1,
), ),
@@ -423,23 +387,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
WatchProgress record, WatchProgress record,
) { ) {
final progress = record.fraction; final progress = record.fraction;
return GlassPanel( return Container(
key: const Key('discover-continue-watching'), key: const Key('discover-continue-watching'),
radius: AppDimens.glassSmallRadius, padding: const EdgeInsets.all(9),
blur: 18, decoration: BoxDecoration(
padding: const EdgeInsets.all(12), color: AppColors.cardAlt,
gradient: const LinearGradient( borderRadius: BorderRadius.circular(12),
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)],
), ),
child: Row( child: Row(
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
child: SizedBox( child: SizedBox(
width: 64, width: 52,
height: 88, height: 66,
child: RemoteImage( child: RemoteImage(
drama.posterUrl, drama.posterUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -447,57 +408,38 @@ class _DiscoverPageState extends State<DiscoverPage> {
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 9),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Text(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Flexible(
child: Text(
l10n.continueWatching, l10n.continueWatching,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.blue, color: AppColors.accent,
fontSize: 9.5, fontSize: 8,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
letterSpacing: 0.4,
), ),
), ),
), const SizedBox(height: 2),
],
),
const SizedBox(height: 3),
Text( Text(
drama.title, drama.title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 13, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
_resumeMeta(drama, record), _resumeShort(drama, record),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
color: AppColors.muted,
fontSize: 10.5,
),
), ),
const SizedBox(height: 7), const SizedBox(height: 7),
ClipRRect( ClipRRect(
@@ -505,7 +447,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: progress <= 0 ? null : progress, value: progress <= 0 ? null : progress,
minHeight: 3, minHeight: 3,
backgroundColor: Colors.white.withValues(alpha: 0.12), backgroundColor: const Color(0xFF474248),
valueColor: const AlwaysStoppedAnimation(AppColors.blue), valueColor: const AlwaysStoppedAnimation(AppColors.blue),
), ),
), ),
@@ -525,29 +467,24 @@ class _DiscoverPageState extends State<DiscoverPage> {
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.blue, backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary, foregroundColor: AppColors.onPrimary,
minimumSize: const Size(64, 34), minimumSize: const Size(56, 28),
padding: const EdgeInsets.symmetric(horizontal: 10), padding: EdgeInsets.zero,
shape: const StadiumBorder(), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: 11, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
child: Row( child: Text(l10n.resume),
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.play),
const SizedBox(width: 2),
const Icon(Icons.play_arrow_rounded, size: 14),
],
),
), ),
], ],
), ),
); );
} }
String _resumeMeta(Drama drama, WatchProgress record) { String _resumeShort(Drama drama, WatchProgress record) {
final label = AppLocalizations.of(context)!; final label = AppLocalizations.of(context)!;
Episode? episode; Episode? episode;
for (final item in drama.episodes) { for (final item in drama.episodes) {
@@ -556,10 +493,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
break; break;
} }
} }
final head = episode == null if (episode == null) return _duration(record.position);
? '' return label.episodeOfTotal(episode.number, drama.episodeCount);
: '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · ';
return '$head${_duration(record.position)} / ${_duration(record.duration)}';
} }
Widget _quickActions(AppLocalizations l10n, CategoryController categories) { Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
@@ -574,7 +509,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
onTap: logic.openSearch, onTap: logic.openSearch,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 7),
Expanded( Expanded(
child: _QuickAction( child: _QuickAction(
key: const Key('discover-quick-charts'), key: const Key('discover-quick-charts'),
@@ -584,17 +519,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
onTap: () => categories.selectCode(CategoryController.hotCode), onTap: () => categories.selectCode(CategoryController.hotCode),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 7),
Expanded( Expanded(
child: _QuickAction( child: _QuickAction(
key: const Key('discover-quick-new'), key: const Key('discover-quick-new'),
icon: Icons.auto_awesome_rounded, icon: Icons.auto_awesome_rounded,
label: l10n.newest, label: l10n.newReleases,
color: AppColors.gold, color: AppColors.gold,
onTap: () => categories.selectCode(CategoryController.newCode), onTap: () => categories.selectCode(CategoryController.newCode),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 7),
Expanded( Expanded(
child: _QuickAction( child: _QuickAction(
key: const Key('discover-quick-watchlist'), key: const Key('discover-quick-watchlist'),
@@ -617,8 +552,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
items: [ items: [
AppBottomNavItem( AppBottomNavItem(
tabKey: const Key('discover-nav-home'), tabKey: const Key('discover-nav-home'),
icon: Icons.home_outlined, icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.home_rounded, selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab, label: l10n.homeTab,
onTap: () => logic.act( onTap: () => logic.act(
context, context,
@@ -637,8 +572,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
), ),
AppBottomNavItem( AppBottomNavItem(
tabKey: const Key('discover-nav-my'), tabKey: const Key('discover-nav-my'),
icon: Icons.person_outline_rounded, icon: Icons.video_library_outlined,
selectedIcon: Icons.person_rounded, selectedIcon: Icons.video_library_rounded,
label: l10n.myTab, label: l10n.myTab,
onTap: () => logic.act( onTap: () => logic.act(
context, context,
@@ -651,6 +586,37 @@ class _DiscoverPageState extends State<DiscoverPage> {
); );
} }
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 { class _QuickAction extends StatelessWidget {
const _QuickAction({ const _QuickAction({
super.key, super.key,
@@ -668,31 +634,20 @@ class _QuickAction extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
color: AppColors.card, color: AppColors.quick,
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
height: 76, height: 62,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
border: Border.all(color: AppColors.border),
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Icon(icon, color: color, size: 16),
width: 32, const SizedBox(height: 4),
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),
Text( Text(
label, label,
maxLines: 2, maxLines: 2,
@@ -700,7 +655,7 @@ class _QuickAction extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.muted, color: AppColors.muted,
fontSize: 9.5, fontSize: 9,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: 1.05, height: 1.05,
), ),
+49 -96
View File
@@ -37,27 +37,22 @@ class _HomeDramaGrid extends StatelessWidget {
), ),
); );
} }
if (dramas.isEmpty) { if (dramas.isEmpty) {
final missingCategory = categories.selectedSectionMissing;
return _fill( return _fill(
EmptyState( EmptyState(
key: const Key('home-all-dramas-empty'), key: const Key('home-all-dramas-empty'),
icon: searching icon: searching
? Icons.search_off_rounded ? Icons.search_off_rounded
: missingCategory
? Icons.grid_view_rounded
: Icons.movie_filter_outlined, : Icons.movie_filter_outlined,
title: searching title: searching
? l10n.noSearchResults ? l10n.noSearchResults
: missingCategory : categories.selectedSectionMissing
? l10n.noCategoryDramas ? l10n.noCategoryDramas
: l10n.noSearchResults, : l10n.noSearchResults,
subtitle: l10n.emptyHint, subtitle: l10n.emptyHint,
), ),
); );
} }
return SliverMainAxisGroup( return SliverMainAxisGroup(
slivers: [ slivers: [
_dramaGrid(dramas), _dramaGrid(dramas),
@@ -68,14 +63,14 @@ class _HomeDramaGrid extends StatelessWidget {
Widget _dramaGrid(List<Drama> items) => SliverPadding( Widget _dramaGrid(List<Drama> items) => SliverPadding(
key: const Key('home-all-dramas-section'), key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), padding: const EdgeInsets.symmetric(horizontal: 14),
sliver: SliverGrid( sliver: SliverGrid(
key: const Key('home-all-dramas-grid'), key: const Key('home-all-dramas-grid'),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
mainAxisSpacing: 14, mainAxisSpacing: 10,
crossAxisSpacing: 12, crossAxisSpacing: 10,
childAspectRatio: 0.55, mainAxisExtent: 164,
), ),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) => _HomeDramaCard( (context, index) => _HomeDramaCard(
@@ -88,9 +83,8 @@ class _HomeDramaGrid extends StatelessWidget {
), ),
); );
Widget _fill(Widget child) { Widget _fill(Widget child) =>
return SliverFillRemaining(hasScrollBody: false, child: child); SliverFillRemaining(hasScrollBody: false, child: child);
}
} }
class _HomeDramaCard extends StatelessWidget { class _HomeDramaCard extends StatelessWidget {
@@ -107,18 +101,21 @@ class _HomeDramaCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final genre = drama.genre.isEmpty ? drama.category : drama.genre; final genre = drama.genre.isEmpty ? drama.category : drama.genre;
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return InkWell( return InkWell(
key: Key('home-drama-card-${drama.id}'), key: Key('home-drama-card-${drama.id}'),
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(AppDimens.cardRadius), borderRadius: BorderRadius.circular(10),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AspectRatio( SizedBox(
key: Key('home-drama-cover-${drama.id}'), key: Key('home-drama-cover-${drama.id}'),
aspectRatio: 3 / 4.2, height: 120,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.cardRadius), borderRadius: BorderRadius.circular(10),
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -132,120 +129,76 @@ class _HomeDramaCard extends StatelessWidget {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [Color(0x0D000000), Color(0xCC000000)],
Color(0x33000000), stops: [0.45, 1],
Color(0x00000000),
Color(0xB3000000),
],
stops: [0, 0.42, 1],
), ),
), ),
), ),
if (free)
Positioned( Positioned(
top: 8, top: 6,
left: 8, left: 6,
child: Wrap( child: Container(
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)
Positioned(
right: 8,
bottom: 8,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(7),
),
child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text( child: Text(
_compactCount(drama.viewCount), l10n.free,
key: Key('home-drama-views-${drama.id}'),
style: const TextStyle( style: const TextStyle(
color: AppColors.cream, color: AppColors.onPrimary,
fontSize: 10, fontSize: 8,
fontWeight: FontWeight.w600, 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( Text(
drama.title, drama.title,
key: Key('home-drama-title-${drama.id}'), key: Key('home-drama-title-${drama.id}'),
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 13, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w800,
height: 1.2,
), ),
), ),
const SizedBox(height: 3), const SizedBox(height: 2),
Text( Text(
genre.isEmpty ? l10n.videoPending : genre, genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'), key: Key('home-drama-genre-${drama.id}'),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
color: AppColors.subtle,
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.2,
),
), ),
], ],
), ),
); );
} }
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';
} }
+161 -628
View File
@@ -21,14 +21,16 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart'; import '../../core/widgets/download_action.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/glass_panel.dart';
import '../../core/widgets/remote_image.dart'; import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.dart'; import '../interaction/widgets/report_dialog.dart';
import 'drama_detail_binding.dart'; import 'drama_detail_binding.dart';
import 'drama_detail_logic.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 { class DramaDetailPage extends StatefulWidget {
const DramaDetailPage({super.key, required this.drama}); const DramaDetailPage({super.key, required this.drama});
@@ -41,7 +43,6 @@ class DramaDetailPage extends StatefulWidget {
class _DramaDetailPageState extends State<DramaDetailPage> { class _DramaDetailPageState extends State<DramaDetailPage> {
late final DramaDetailLogic logic; late final DramaDetailLogic logic;
bool _synopsisExpanded = false;
@override @override
void initState() { void initState() {
@@ -93,13 +94,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
key: const Key('detail-scroll'), key: const Key('detail-scroll'),
slivers: [ slivers: [
SliverToBoxAdapter(child: _hero(context, l10n, drama)), SliverToBoxAdapter(child: _hero(context, l10n, drama)),
SliverToBoxAdapter(child: _metadata(l10n, drama)), SliverToBoxAdapter(child: _content(context, l10n, drama)),
SliverToBoxAdapter(child: _synopsis(l10n, drama)), const SliverToBoxAdapter(child: SizedBox(height: 28)),
SliverToBoxAdapter(child: _primaryActions(context, l10n)),
SliverToBoxAdapter(child: _episodeHeader(l10n, drama)),
SliverToBoxAdapter(child: _episodeChips(drama)),
_episodeGrid(context, l10n, drama),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
], ],
), ),
); );
@@ -159,11 +155,9 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
); );
Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) {
return Stack( return SizedBox(
children: [
AspectRatio(
key: const Key('detail-hero'), key: const Key('detail-hero'),
aspectRatio: 16 / 9, height: 190,
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -177,262 +171,88 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [Color(0x33000000), Color(0xCC0B0C10)],
Color(0x260B0C10),
Color(0x000B0C10),
AppColors.ink,
],
stops: [0, 0.5, 1],
), ),
), ),
), ),
],
),
),
Positioned( Positioned(
top: MediaQuery.paddingOf(context).top + 8, top: MediaQuery.paddingOf(context).top + 14,
left: 16, left: 14,
right: 16, child: _RoundAction(
child: Row(
children: [
_GlassCircleButton(
key: const Key('detail-back'), key: const Key('detail-back'),
tooltip: MaterialLocalizations.of(context).backButtonTooltip, tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Icons.chevron_left_rounded, icon: Icons.chevron_left_rounded,
onPressed: () => Navigator.of(context).maybePop(), onPressed: () => Navigator.of(context).maybePop(),
), ),
const Spacer(),
_GlassCircleButton(
key: const Key('detail-share'),
tooltip: l10n.share,
icon: Icons.share_outlined,
onPressed: () => unawaited(_shareDrama(l10n, drama)),
),
const SizedBox(width: 8),
_favoriteButton(
context,
l10n,
key: const Key('detail-favorite-top'),
),
const SizedBox(width: 8),
_reportAction(context, l10n),
],
), ),
Positioned(
top: MediaQuery.paddingOf(context).top + 14,
right: 14,
child: _moreMenu(context, l10n),
), ),
], ],
),
); );
} }
Widget _metadata(AppLocalizations l10n, Drama drama) { Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) {
final genres = [ final continueIndex = logic.continueIndex;
if (drama.genre.isNotEmpty) drama.genre,
if (drama.category.isNotEmpty) drama.category,
];
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container(
key: const Key('detail-episode-count'),
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: AppColors.secondary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: AppColors.secondary.withValues(alpha: 0.3),
),
),
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( Text(
l10n.episodeCount(drama.episodeCount), l10n.episodeCount(drama.episodeCount),
key: const Key('detail-episode-count'),
style: const TextStyle( style: const TextStyle(
color: AppColors.secondary, color: AppColors.secondary,
fontSize: 10, fontSize: 9,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
letterSpacing: 0.5, letterSpacing: 1,
), ),
), ),
], const SizedBox(height: 7),
),
),
const SizedBox(height: 10),
Text( Text(
localizedDramaTitle(l10n, drama), localizedDramaTitle(l10n, drama),
key: const Key('detail-title'), key: const Key('detail-title'),
style: const TextStyle( style: const TextStyle(
fontFamily: 'Outfit', fontFamily: 'Outfit',
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 24, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
height: 1.15, height: 1.2,
letterSpacing: 0,
),
),
const SizedBox(height: 5),
Text(
l10n.seasonOneOriginalRelease,
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 10), 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( Text(
label, localizedDramaSynopsis(l10n, drama),
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
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'), key: const Key('detail-synopsis'),
maxLines: _synopsisExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.muted, color: AppColors.muted,
fontSize: 13,
height: 1.45,
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: const Key('detail-synopsis-toggle'),
onPressed: () =>
setState(() => _synopsisExpanded = !_synopsisExpanded),
style: TextButton.styleFrom(
foregroundColor: AppColors.secondary,
minimumSize: const Size(44, 32),
padding: const EdgeInsets.symmetric(horizontal: 4),
textStyle: const TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, height: 1.6,
), ),
), ),
child: Row( const SizedBox(height: 12),
mainAxisSize: MainAxisSize.min, SizedBox(
children: [ width: double.infinity,
Text(_synopsisExpanded ? l10n.showLess : l10n.more), height: 40,
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( child: FilledButton.icon(
key: const Key('detail-cta'), key: const Key('detail-cta'),
onPressed: () => onPressed: () => logic.openPlayer(context, continueIndex ?? 0),
logic.openPlayer(context, continueIndex ?? 0),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.blue, backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary, foregroundColor: AppColors.onPrimary,
shape: const StadiumBorder(), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDimens.buttonRadius),
),
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
icon: const Icon(Icons.play_arrow_rounded, size: 20), icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: Text( label: Text(
continueIndex == null continueIndex == null
? l10n.startWatching ? l10n.startWatching
@@ -442,31 +262,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
), ),
), ),
), ),
),
const SizedBox(width: 10),
_squareAction(
key: const Key('detail-favorite'),
tooltip: l10n.favorite,
icon:
context.watch<FavoriteController>().isSaved(logic.drama.id)
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
selected: context.watch<FavoriteController>().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) ...[ if (_continueFraction != null) ...[
const SizedBox(height: 9), const SizedBox(height: 8),
_ResumeBar( _ResumeBar(
fraction: _continueFraction!, fraction: _continueFraction!,
label: l10n.resumeProgress( label: l10n.resumeProgress(
@@ -484,7 +281,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
errorText(l10n, logic.progressError, l10n.progressError), errorText(l10n, logic.progressError, l10n.progressError),
style: const TextStyle( style: const TextStyle(
color: AppColors.muted, color: AppColors.muted,
fontSize: 12, fontSize: 11,
), ),
), ),
), ),
@@ -495,393 +292,159 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
], ],
), ),
], ],
], const SizedBox(height: 18),
),
);
}
Widget _episodeHeader(AppLocalizations l10n, Drama drama) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 10),
child: Row(
children: [
Text( Text(
l10n.episodes, l10n.episodes,
style: const TextStyle( style: const TextStyle(
fontFamily: 'Outfit', fontFamily: 'Outfit',
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 17, fontSize: 14,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(width: 5), const SizedBox(height: 8),
Text( Wrap(
'(${drama.episodeCount})', key: const Key('detail-episode-chips'),
style: const TextStyle( spacing: 6,
color: AppColors.subtle, runSpacing: 6,
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: [ children: [
Text( for (var index = 0; index < drama.episodes.length; index++)
l10n.all, _episodeChip(context, drama, index, continueIndex),
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 3),
const Icon(
Icons.keyboard_arrow_down_rounded,
size: 15,
color: AppColors.muted,
),
], ],
), ),
),
], ],
), ),
); );
} }
Widget _episodeChips(Drama drama) { Widget _episodeChip(
final selected = logic.continueIndex ?? 0; BuildContext context,
return SizedBox( Drama drama,
key: const Key('detail-episode-chips'), int index,
height: 44, int? selectedIndex,
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 episode = drama.episodes[index];
final active = index == selected; final selected = index == (selectedIndex ?? 0);
final locked = episode.locked || (!episode.isFree && index > 0); final locked = episode.locked || (!episode.isFree && index > 0);
return InkWell( return InkWell(
key: Key('detail-episode-chip-${episode.number}'), key: Key('detail-episode-chip-${episode.number}'),
onTap: () => logic.openPlayer(context, index), onTap: () => logic.openPlayer(context, index),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(20),
child: Container( child: Container(
width: 46, constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
height: 44, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: active color: selected
? AppColors.blue ? AppColors.blue
: locked : locked
? AppColors.secondary.withValues(alpha: 0.16) ? AppColors.secondary.withValues(alpha: 0.16)
: AppColors.card, : const Color(0xFF29272B),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(20),
border: Border.all(
color: active
? AppColors.blue
: locked
? AppColors.secondary.withValues(alpha: 0.35)
: AppColors.border,
),
), ),
child: Text( child: Text(
'${episode.number}', '${episode.number}',
style: TextStyle( style: TextStyle(
color: active color: selected
? AppColors.onPrimary ? AppColors.onPrimary
: locked : locked
? AppColors.secondary ? AppColors.secondary
: AppColors.muted, : AppColors.muted,
fontSize: 13, fontSize: 11,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
), ),
); );
},
),
);
} }
Widget _episodeGrid( Widget _moreMenu(BuildContext context, AppLocalizations l10n) {
BuildContext context,
AppLocalizations l10n,
Drama drama,
) {
return SliverPadding(
key: const Key('detail-episode-grid'),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
mainAxisExtent: 108,
),
delegate: SliverChildBuilderDelegate(
(context, index) =>
_episodeCard(context, l10n, drama, drama.episodes[index], index),
childCount: drama.episodes.length,
),
),
);
}
Widget _episodeCard(
BuildContext context,
AppLocalizations l10n,
Drama drama,
Episode episode,
int index,
) {
final locked = episode.locked || (!episode.isFree && index > 0);
return Material(
key: Key('detail-episode-card-${episode.number}'),
color: AppColors.card,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => logic.openPlayer(context, index),
child: LayoutBuilder(
builder: (context, constraints) {
final thumbWidth = (constraints.maxWidth - 70).clamp(58.0, 96.0);
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
SizedBox(
key: Key('detail-episode-thumb-${episode.number}'),
width: thumbWidth,
height: 64,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
const DecoratedBox(
decoration: BoxDecoration(color: Color(0x33000000)),
),
Center(
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: locked
? Colors.black54
: AppColors.blue.withValues(alpha: 0.9),
shape: BoxShape.circle,
),
child: Icon(
locked
? Icons.lock_rounded
: Icons.play_arrow_rounded,
size: 14,
color: AppColors.onPrimary,
),
),
),
if (episode.durationSec > 0)
Positioned(
right: 4,
bottom: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.68),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_clock(
Duration(seconds: episode.durationSec),
),
style: const TextStyle(
color: AppColors.cream,
fontSize: 8,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Wrap(
spacing: 4,
runSpacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_episodeBadge(
episode.isFree && !locked
? l10n.free
: l10n.coins,
locked ? AppColors.secondary : AppColors.success,
),
Text(
l10n.episodeLabel(episode.number),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 9.5,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 4),
Text(
localizedEpisodeTitle(l10n, episode.number),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: locked
? AppColors.muted
: AppColors.onSurface,
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
GestureDetector(
onTap: () =>
requestEpisodeDownload(context, episode.id),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.download_outlined,
size: 12,
color: AppColors.subtle,
),
const SizedBox(width: 3),
Flexible(
child: Text(
l10n.downloads,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 9,
),
),
),
],
),
),
],
),
),
],
),
);
},
),
),
);
}
Widget _episodeBadge(String label, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withValues(alpha: 0.28)),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 8.5,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _squareAction({
required Key key,
required String tooltip,
required IconData icon,
required VoidCallback onPressed,
bool selected = false,
}) {
return SizedBox(
key: key,
width: 48,
height: 48,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
style: IconButton.styleFrom(
backgroundColor: selected ? AppColors.raised : AppColors.card,
foregroundColor: selected ? AppColors.blue : AppColors.cream,
side: BorderSide(
color: selected ? AppColors.blue : AppColors.outlineDark,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
icon: Icon(icon, size: 21),
),
);
}
Widget _favoriteButton(
BuildContext context,
AppLocalizations l10n, {
required Key key,
}) {
final saved = context.watch<FavoriteController>().isSaved(logic.drama.id); final saved = context.watch<FavoriteController>().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<String>( return PopupMenuButton<String>(
key: const Key('detail-more'), key: const Key('detail-more'),
tooltip: l10n.more, tooltip: l10n.more,
color: AppColors.card, 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: (_) => [ 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),
], ],
child: const _GlassCircle( ),
child: Icon( ),
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: Container(
width: 28,
height: 28,
decoration: const BoxDecoration(
color: Color(0x80000000),
shape: BoxShape.circle,
),
child: const Icon(
Icons.more_horiz_rounded, Icons.more_horiz_rounded,
size: 18,
color: AppColors.onSurface, color: AppColors.onSurface,
size: 20,
), ),
), ),
); );
@@ -958,48 +521,33 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
} }
} }
class _GlassCircleButton extends StatelessWidget { class _RoundAction extends StatelessWidget {
const _GlassCircleButton({ const _RoundAction({
super.key, super.key,
required this.tooltip, required this.tooltip,
required this.icon, required this.icon,
required this.onPressed, required this.onPressed,
this.selected = false,
}); });
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
final VoidCallback onPressed; final VoidCallback? onPressed;
final bool selected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _GlassCircle( return Container(
width: 28,
height: 28,
decoration: const BoxDecoration(
color: Color(0x80000000),
shape: BoxShape.circle,
),
child: IconButton( child: IconButton(
tooltip: tooltip, tooltip: tooltip.isEmpty ? null : tooltip,
onPressed: onPressed, onPressed: onPressed,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: Icon( icon: Icon(icon, size: 18, color: AppColors.onSurface),
icon,
size: 20,
color: selected ? AppColors.blue : AppColors.onSurface,
), ),
),
);
}
}
class _GlassCircle extends StatelessWidget {
const _GlassCircle({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 40,
height: 40,
child: GlassPanel(radius: 999, blur: 14, child: child),
); );
} }
} }
@@ -1016,11 +564,11 @@ class _ResumeBar extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.progressRadius), borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: fraction, value: fraction,
minHeight: AppDimens.progressLineHeight, minHeight: 3,
backgroundColor: Colors.white24, backgroundColor: const Color(0xFF474248),
valueColor: const AlwaysStoppedAnimation(AppColors.blue), valueColor: const AlwaysStoppedAnimation(AppColors.blue),
), ),
), ),
@@ -1028,25 +576,10 @@ class _ResumeBar extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
label!, label!,
style: const TextStyle( style: const TextStyle(color: AppColors.muted, fontSize: 10),
color: AppColors.muted,
fontSize: AppDimens.caption,
),
), ),
], ],
], ],
); );
} }
} }
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';
}
+12
View File
@@ -531,6 +531,18 @@ class PlayerLogic extends ChangeNotifier {
: null; : null;
} }
bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null;
Future<void> 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) { void beginScrub(VideoPlayerPort player) {
if (session.active != player) return; if (session.active != player) return;
scrubbingPlayer = player; scrubbingPlayer = player;
+289 -190
View File
@@ -18,13 +18,13 @@ import '../../core/formatting/localized_content.dart';
import '../../core/platform/haptics.dart'; import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart'; import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'player_binding.dart'; import 'player_binding.dart';
import 'player_logic.dart'; import 'player_logic.dart';
import 'widgets/home_feed_overlay.dart'; import 'widgets/home_feed_overlay.dart';
import 'widgets/player_featured_comment.dart';
import 'widgets/seek_progress_bar.dart'; import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/playback_repository.dart';
@@ -340,7 +340,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
l10n: l10n, l10n: l10n,
drama: drama, drama: drama,
episode: episode, episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id), liked: interactions.isLiked(drama.id),
saved: context.watch<FavoriteController>().isSaved(drama.id), saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async { onLike: () async {
@@ -381,10 +380,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onPip: logic.pictureInPicture.isPlatformSupported onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context)) ? () => unawaited(logic.enterPip(context))
: null, : null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
) )
else if (!logic.screenCleared) else if (!logic.screenCleared)
_PlaybackOverlay( _PlaybackOverlay(
@@ -398,9 +393,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onBack: widget.embedded onBack: widget.embedded
? null ? null
: () => unawaited(logic.leave(context)), : () => unawaited(logic.leave(context)),
onDiscover: () => logic.showDiscover(context),
onEpisodes: () => logic.showEpisodes(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 { onLike: () async {
if (await ensureSignedIn(context)) { if (await ensureSignedIn(context)) {
@@ -434,7 +433,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onDownload: () => requestEpisodeDownload(context, episode.id), onDownload: () => requestEpisodeDownload(context, episode.id),
onComments: () => logic.showComments(context, drama), onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama), onShare: () => logic.shareDrama(context, drama),
onClear: () => logic.setScreenCleared(true),
onPip: logic.pictureInPicture.isPlatformSupported onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context)) ? () => unawaited(logic.enterPip(context))
: null, : null,
@@ -442,7 +440,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onSeek: ready onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position) ? (position) => logic.seekTo(context, videoPort!, position)
: null, : null,
showBottomNav: false,
), ),
if (logic.screenCleared) if (logic.screenCleared)
SafeArea( SafeArea(
@@ -649,20 +646,19 @@ class _PlaybackOverlay extends StatelessWidget {
required this.controller, required this.controller,
required this.liked, required this.liked,
required this.saved, required this.saved,
required this.playing,
required this.onTogglePlay,
required this.onBack, required this.onBack,
required this.onDiscover,
required this.onEpisodes, required this.onEpisodes,
required this.onMy, required this.onComments,
required this.onLike, required this.onLike,
required this.onSave, required this.onSave,
required this.onDownload, required this.onDownload,
required this.onComments,
required this.onShare, required this.onShare,
required this.onClear,
required this.onPip, required this.onPip,
required this.onScrubStart, required this.onScrubStart,
required this.onSeek, required this.onSeek,
required this.showBottomNav, this.onNext,
}); });
final AppLocalizations l10n; final AppLocalizations l10n;
@@ -671,230 +667,333 @@ class _PlaybackOverlay extends StatelessWidget {
final VideoPlayerController? controller; final VideoPlayerController? controller;
final bool liked; final bool liked;
final bool saved; final bool saved;
final bool playing;
final VoidCallback onTogglePlay;
final VoidCallback? onBack; final VoidCallback? onBack;
final VoidCallback onDiscover;
final VoidCallback onEpisodes; final VoidCallback onEpisodes;
final VoidCallback onMy; final VoidCallback onComments;
final VoidCallback? onNext;
final VoidCallback onLike; final VoidCallback onLike;
final VoidCallback onSave; final VoidCallback onSave;
final VoidCallback onDownload; final VoidCallback onDownload;
final VoidCallback onComments;
final VoidCallback onShare; final VoidCallback onShare;
final VoidCallback onClear;
final VoidCallback? onPip; final VoidCallback? onPip;
final VoidCallback? onScrubStart; final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek; final Future<void> Function(Duration)? onSeek;
final bool showBottomNav;
@override @override
Widget build(BuildContext context) => SafeArea( Widget build(BuildContext context) {
bottom: showBottomNav, return SafeArea(
child: Column(
children: [
_topBar(context),
Expanded(
child: Stack( child: Stack(
children: [ children: [
Column( Center(
children: [ child: GestureDetector(
const Spacer(), key: const Key('player-play-toggle'),
Padding( onTap: onTogglePlay,
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0), child: Container(
child: Row( width: 52,
crossAxisAlignment: CrossAxisAlignment.end, height: 52,
children: [ decoration: const BoxDecoration(
Expanded(child: _info()), color: Color(0x80000000),
_rail(), 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),
),
),
], ],
), ),
), ),
const SizedBox(height: 4),
Padding( Padding(
padding: EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
16, child: Column(
0, children: [
16, SeekProgressBar(
20 + MediaQuery.paddingOf(context).bottom,
),
child: SeekProgressBar(
valueListenable: controller, valueListenable: controller,
semanticsLabel: l10n.seekVideo, semanticsLabel: l10n.seekVideo,
showTimes: false,
onScrubStart: onScrubStart, onScrubStart: onScrubStart,
onSeek: onSeek, onSeek: onSeek,
), ),
),
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), const SizedBox(height: 6),
Text( Row(
'${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: [ children: [
_RailAction( Expanded(
key: const Key('rail-like'), child: _PlayerAction(
icon: liked ? Icons.favorite : Icons.favorite_border, key: const Key('player-episodes'),
label: l10n.like, icon: Icons.view_list_rounded,
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, label: l10n.episodes,
onTap: onEpisodes, onTap: onEpisodes,
), ),
_RailAction( ),
key: const Key('rail-comments'), const SizedBox(width: 8),
icon: Icons.chat_bubble_outline, Expanded(
child: _PlayerAction(
key: const Key('player-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments, label: l10n.comments,
onTap: onComments, onTap: onComments,
), ),
_RailAction( ),
key: const Key('rail-share'), const SizedBox(width: 8),
icon: Icons.share_outlined, Expanded(
label: l10n.share, child: SizedBox(
onTap: onShare, 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<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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();
},
), ),
if (onPip != null) if (onPip != null)
IconButton( ListTile(
tooltip: l10n.pictureInPicture, key: const Key('player-more-pip'),
onPressed: onPip, leading: const Icon(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _railTapSize,
height: _railTapSize,
),
color: AppColors.cream,
icon: const Icon(
Icons.picture_in_picture_alt_outlined, Icons.picture_in_picture_alt_outlined,
size: _railIconSize, color: AppColors.secondary,
), ),
title: Text(l10n.pictureInPicture),
onTap: () {
Navigator.pop(sheetContext);
onPip!();
},
),
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();
},
), ),
], ],
),
),
); );
}
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; class _TopAction extends StatelessWidget {
const double _railIconWeight = 300; const _TopAction({
const double _railTapSize = 44; super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
});
class _RailAction extends StatelessWidget { final String tooltip;
const _RailAction({ 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, super.key,
required this.icon, required this.icon,
required this.label, required this.label,
required this.onTap, required this.onTap,
this.selected = false,
}); });
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback? onTap; final VoidCallback onTap;
final bool selected;
@override @override
Widget build(BuildContext context) => IconButton( Widget build(BuildContext context) {
tooltip: label, return Material(
onPressed: onTap, color: const Color(0xFF2A2A30),
padding: EdgeInsets.zero, borderRadius: BorderRadius.circular(9),
constraints: const BoxConstraints.tightFor( clipBehavior: Clip.antiAlias,
width: _railTapSize, child: InkWell(
height: _railTapSize, 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,
),
),
),
],
),
),
), ),
color: selected ? AppColors.blueSoft : AppColors.cream,
icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0),
); );
}
} }
+139 -373
View File
@@ -3,27 +3,21 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/glass_panel.dart'; import '../../../core/widgets/glass_panel.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import 'seek_progress_bar.dart';
/// Immersive home layer from `screens/01-home.html`. /// Home feed layer from `docs/移动端设计预览.html` screen 01.
///
/// Playback, ticket, progress, favorite, like, comment, and share callbacks are
/// all owned by [PlayerPage]; this widget only paints the design overlay.
class HomeFeedOverlay extends StatelessWidget { class HomeFeedOverlay extends StatelessWidget {
const HomeFeedOverlay({ const HomeFeedOverlay({
super.key, super.key,
required this.l10n, required this.l10n,
required this.drama, required this.drama,
required this.episode, required this.episode,
required this.controller,
required this.liked, required this.liked,
required this.saved, required this.saved,
required this.onLike, required this.onLike,
@@ -34,15 +28,12 @@ class HomeFeedOverlay extends StatelessWidget {
required this.onDownload, required this.onDownload,
required this.onClear, required this.onClear,
required this.onSearch, required this.onSearch,
required this.onScrubStart,
required this.onSeek,
this.onPip, this.onPip,
}); });
final AppLocalizations l10n; final AppLocalizations l10n;
final Drama drama; final Drama drama;
final Episode episode; final Episode episode;
final VideoPlayerController? controller;
final bool liked; final bool liked;
final bool saved; final bool saved;
final VoidCallback onLike; final VoidCallback onLike;
@@ -54,12 +45,9 @@ class HomeFeedOverlay extends StatelessWidget {
final VoidCallback onClear; final VoidCallback onClear;
final VoidCallback onSearch; final VoidCallback onSearch;
final VoidCallback? onPip; final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final compact = MediaQuery.sizeOf(context).height < 620;
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Column( child: Column(
@@ -67,16 +55,16 @@ class HomeFeedOverlay extends StatelessWidget {
_header(context), _header(context),
Expanded( Expanded(
child: Align( child: Align(
alignment: Alignment.bottomRight, alignment: Alignment.centerRight,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 12, 4), padding: const EdgeInsets.fromLTRB(0, 0, 10, 12),
child: _rail(compact), child: _rail(),
), ),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
child: _infoCard(context, compact), child: _infoCard(),
), ),
], ],
), ),
@@ -85,26 +73,10 @@ class HomeFeedOverlay extends StatelessWidget {
Widget _header(BuildContext context) { Widget _header(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 4), padding: const EdgeInsets.fromLTRB(14, 6, 10, 4),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( 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( child: Text(
localizedDramaTitle(l10n, drama), localizedDramaTitle(l10n, drama),
key: const Key('home-feed-title'), key: const Key('home-feed-title'),
@@ -113,51 +85,23 @@ class HomeFeedOverlay extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
fontFamily: 'Outfit', fontFamily: 'Outfit',
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 17, fontSize: 13,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
height: 1.1, shadows: [Shadow(blurRadius: 8, color: Colors.black)],
shadows: [
Shadow(blurRadius: 10, color: Colors.black87),
],
), ),
), ),
), ),
if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()], _RoundIcon(
], key: const Key('home-feed-search'),
),
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),
],
),
),
],
),
),
const SizedBox(width: 6),
_GlassIconButton(
tooltip: l10n.search, tooltip: l10n.search,
icon: Icons.search_rounded, icon: Icons.search_rounded,
onPressed: onSearch, onPressed: onSearch,
), ),
const SizedBox(width: 4), const SizedBox(width: 6),
_GlassIconButton( _RoundIcon(
tooltip: l10n.clearScreen, key: const Key('home-feed-more'),
icon: Icons.fit_screen_outlined,
onPressed: onClear,
),
const SizedBox(width: 4),
_GlassIconButton(
tooltip: l10n.more, tooltip: l10n.more,
icon: Icons.more_horiz_rounded, icon: Icons.menu_rounded,
onPressed: () => _openMore(context), onPressed: () => _openMore(context),
), ),
], ],
@@ -165,131 +109,53 @@ class HomeFeedOverlay extends StatelessWidget {
); );
} }
Widget _hotBadge() { Widget _rail() {
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) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Semantics( _RailItem(
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,
key: const Key('rail-like'), key: const Key('rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: l10n.like, label: l10n.like,
selected: liked, selected: liked,
onTap: onLike, onTap: onLike,
), ),
_RailAction( _RailItem(
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,
key: const Key('rail-comments'), key: const Key('rail-comments'),
icon: Icons.chat_bubble_outline_rounded, icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments, label: l10n.comments,
onTap: onComments, onTap: onComments,
), ),
_RailAction( _RailItem(
compact: compact,
key: const Key('rail-share'), key: const Key('rail-share'),
icon: Icons.share_outlined, icon: Icons.ios_share_rounded,
label: l10n.share, label: l10n.share,
onTap: onShare, 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) { Widget _infoCard() {
final genres = [ final genre = drama.genre.isEmpty ? drama.category : drama.genre;
if (drama.genre.isNotEmpty) drama.genre,
if (drama.category.isNotEmpty) drama.category,
].take(2).toList();
return GlassPanel( return GlassPanel(
key: const Key('home-feed-info-card'), key: const Key('home-feed-info-card'),
radius: 26, radius: 14,
blur: 24, blur: 6,
padding: const EdgeInsets.fromLTRB(16, 13, 16, 12), padding: const EdgeInsets.fromLTRB(12, 11, 12, 11),
gradient: const LinearGradient( gradient: const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0x260B0C10), Color(0x590B0C10)], colors: [Color(0xBF0F0F14), Color(0xA60F0F14)],
), ),
borderColor: Colors.white.withValues(alpha: 0.06),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -298,94 +164,73 @@ class HomeFeedOverlay extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Wrap( child: Wrap(
spacing: 5, spacing: 6,
runSpacing: 4, runSpacing: 4,
children: [ children: [
for (var i = 0; i < genres.length; i++) if (genre.isNotEmpty)
_genreChip(genres[i], i.isEven), _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), const SizedBox(width: 6),
Container( Flexible(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), child: Text(
decoration: BoxDecoration( l10n.episodeOfTotal(episode.number, drama.episodeCount),
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'), key: const Key('home-feed-episode'),
style: const TextStyle( maxLines: 1,
color: AppColors.cream, overflow: TextOverflow.ellipsis,
fontSize: 10, textAlign: TextAlign.right,
fontWeight: FontWeight.w600, style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
), ),
), ),
], ],
), ),
), const SizedBox(height: 7),
],
),
const SizedBox(height: 8),
Text( Text(
drama.synopsis, localizedEpisodeTitle(l10n, episode.number),
maxLines: compact ? 1 : 2, key: const Key('home-feed-episode-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.cream, color: AppColors.onSurface,
fontSize: 12, fontSize: 13,
height: 1.35, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 10),
SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
showTimes: false,
compact: true,
onScrubStart: onScrubStart,
onSeek: onSeek,
),
_timeRow(),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: SizedBox( child: SizedBox(
height: 40, height: 34,
child: FilledButton.icon( child: FilledButton.icon(
key: const Key('home-feed-follow-series'), key: const Key('home-feed-follow-series'),
onPressed: onSave, onPressed: onSave,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.blue, backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary, foregroundColor: AppColors.onPrimary,
shape: const StadiumBorder(), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppDimens.buttonRadius,
),
),
padding: EdgeInsets.zero,
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
icon: Icon( icon: const Icon(Icons.play_arrow_rounded, size: 16),
saved ? Icons.check_rounded : Icons.add_rounded,
size: 16,
),
label: Text( label: Text(
saved ? l10n.followingTab : l10n.followSeries, saved ? l10n.followingTab : l10n.followSeries,
maxLines: 1, maxLines: 1,
@@ -395,11 +240,33 @@ class HomeFeedOverlay extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_GlassAction( Expanded(
tooltip: l10n.episodes, child: SizedBox(
icon: Icons.grid_view_rounded, height: 34,
label: l10n.episodes, child: FilledButton(
key: const Key('home-feed-episodes'),
onPressed: onEpisodes, 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) { Widget _tag(
final color = secondary ? AppColors.secondary : AppColors.blue; String label, {
required Color background,
required Color foreground,
}) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.15), color: background,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.3)),
), ),
child: Text( child: Text(
label, label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: color, color: foreground,
fontSize: 10, fontSize: 8,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w800,
height: 1.1,
), ),
), ),
); );
} }
Widget _timeRow() {
final source = controller;
if (source == null) {
return const _Times(position: Duration.zero, duration: Duration.zero);
}
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) =>
_Times(position: value.position, duration: value.duration),
);
}
Future<void> _openMore(BuildContext context) async { Future<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
@@ -456,7 +311,7 @@ class HomeFeedOverlay extends StatelessWidget {
key: const Key('home-feed-download'), key: const Key('home-feed-download'),
leading: const Icon( leading: const Icon(
Icons.download_outlined, Icons.download_outlined,
color: AppColors.blueSoft, color: AppColors.secondary,
), ),
title: Text(l10n.downloads), title: Text(l10n.downloads),
onTap: () { onTap: () {
@@ -469,7 +324,7 @@ class HomeFeedOverlay extends StatelessWidget {
key: const Key('home-feed-pip'), key: const Key('home-feed-pip'),
leading: const Icon( leading: const Icon(
Icons.picture_in_picture_alt_outlined, Icons.picture_in_picture_alt_outlined,
color: AppColors.blueSoft, color: AppColors.secondary,
), ),
title: Text(l10n.pictureInPicture), title: Text(l10n.pictureInPicture),
onTap: () { onTap: () {
@@ -478,9 +333,10 @@ class HomeFeedOverlay extends StatelessWidget {
}, },
), ),
ListTile( ListTile(
key: const Key('home-feed-clear'),
leading: const Icon( leading: const Icon(
Icons.fit_screen_outlined, Icons.fit_screen_outlined,
color: AppColors.blueSoft, color: AppColors.secondary,
), ),
title: Text(l10n.clearScreen), title: Text(l10n.clearScreen),
onTap: () { onTap: () {
@@ -495,35 +351,9 @@ class HomeFeedOverlay extends StatelessWidget {
} }
} }
class _Times extends StatelessWidget { class _RoundIcon extends StatelessWidget {
const _Times({required this.position, required this.duration}); const _RoundIcon({
super.key,
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({
required this.tooltip, required this.tooltip,
required this.icon, required this.icon,
required this.onPressed, required this.onPressed,
@@ -531,140 +361,76 @@ class _GlassIconButton extends StatelessWidget {
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
final VoidCallback? onPressed; final VoidCallback onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
width: 34, width: 28,
height: 34, height: 28,
child: IconButton( child: IconButton(
tooltip: tooltip, tooltip: tooltip,
onPressed: onPressed, onPressed: onPressed,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.28), backgroundColor: const Color(0xCC2C2D33),
foregroundColor: AppColors.onSurface, 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 { class _RailItem extends StatelessWidget {
const _GlassAction({ const _RailItem({
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({
super.key, super.key,
required this.icon, required this.icon,
required this.label, required this.label,
required this.onTap, required this.onTap,
this.selected = false, this.selected = false,
this.compact = false,
}); });
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback onTap; final VoidCallback onTap;
final bool selected; final bool selected;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: EdgeInsets.only(bottom: compact ? 2 : 3), padding: const EdgeInsets.only(bottom: 14),
child: Column( child: Semantics(
mainAxisSize: MainAxisSize.min, button: true,
children: [ label: label,
GlassPanel( child: InkWell(
radius: 999, onTap: onTap,
blur: 16, borderRadius: BorderRadius.circular(22),
child: SizedBox( child: SizedBox(
width: compact ? 40 : 38, width: 44,
height: compact ? 40 : 38, child: Column(
child: IconButton( children: [
tooltip: label, Icon(
onPressed: onTap,
padding: EdgeInsets.zero,
icon: Icon(
icon, icon,
size: 19, size: 22,
color: selected ? AppColors.blue : AppColors.onSurface, color: selected ? AppColors.blue : AppColors.onSurface,
), ),
),
),
),
if (!compact) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
label, label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.cream, color: AppColors.cream,
fontSize: 9, fontSize: 9,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: Colors.black)], 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';
}
@@ -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<PlayerFeaturedComment> createState() => _PlayerFeaturedCommentState();
}
class _PlayerFeaturedCommentState extends State<PlayerFeaturedComment> {
late final CommentController _controller;
@override
void initState() {
super.initState();
_controller = CommentController(
comments: context.read<CommentRepository>(),
interactions: context.read<InteractionRepository>(),
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();
}
}
@@ -129,7 +129,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
activeTrackColor: AppColors.blue, activeTrackColor: AppColors.blue,
inactiveTrackColor: widget.compact inactiveTrackColor: widget.compact
? Colors.white.withValues(alpha: 0.18) ? Colors.white.withValues(alpha: 0.18)
: Colors.white30, : const Color(0xFF474248),
thumbColor: widget.compact ? AppColors.blue : Colors.white, thumbColor: widget.compact ? AppColors.blue : Colors.white,
thumbShape: RoundSliderThumbShape( thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compact enabledThumbRadius: widget.compact
+15 -17
View File
@@ -39,44 +39,42 @@ void main() {
expect(find.text('Continue Watching'), findsNothing); expect(find.text('Continue Watching'), findsNothing);
expect(find.byKey(const Key('home-all-dramas-title')), 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(find.text('For You'), findsNothing);
expect(
tester.getCenter(find.text('All')).dx,
lessThan(tester.getCenter(find.text('Hot')).dx),
);
final grid = tester.widget<SliverGrid>( final grid = tester.widget<SliverGrid>(
find.byKey(const Key('home-all-dramas-grid')), find.byKey(const Key('home-all-dramas-grid')),
); );
final delegate = final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 2); expect(delegate.crossAxisCount, 2);
expect(delegate.crossAxisSpacing, 12); expect(delegate.crossAxisSpacing, 10);
expect(delegate.mainAxisSpacing, 14); expect(delegate.mainAxisSpacing, 10);
expect(delegate.childAspectRatio, closeTo(0.55, 0.001)); expect(delegate.mainAxisExtent, 164);
expect( expect(
tester tester
.widget<SliverPadding>( .widget<SliverPadding>(
find.byKey(const Key('home-all-dramas-section')), find.byKey(const Key('home-all-dramas-section')),
) )
.padding, .padding,
const EdgeInsets.symmetric(horizontal: 16), const EdgeInsets.symmetric(horizontal: 14),
); );
expect(find.byType(GridView), findsNothing); expect(find.byType(GridView), findsNothing);
expect(find.byType(CustomScrollView), findsOneWidget); expect(find.byType(CustomScrollView), findsOneWidget);
expect(find.byType(RemoteImage), findsWidgets); expect(find.byType(RemoteImage), findsWidgets);
final cover = tester.widget<AspectRatio>( expect(
find.byKey(const Key('home-drama-cover-city-lights-demo')), tester
.getSize(find.byKey(const Key('home-drama-cover-city-lights-demo')))
.height,
120,
); );
expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001));
expect( expect(
tester tester
.widget<Text>( .widget<Text>(
find.byKey(const Key('home-drama-title-city-lights-demo')), find.byKey(const Key('home-drama-title-city-lights-demo')),
) )
.maxLines, .maxLines,
2, 1,
); );
expect( expect(
tester tester
@@ -100,14 +98,14 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsWidgets);
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets);
await tester.tap(find.text('Romance')); await tester.tap(find.text('Romance'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsNothing); expect(find.text('The Signal'), findsNothing);
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect( expect(
find.byKey(const Key('home-drama-card-the-signal-demo')), find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing, findsNothing,
@@ -182,7 +180,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); 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); expect(tester.takeException(), isNull);
}); });
+13 -34
View File
@@ -21,7 +21,7 @@ import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart'; import '../support/fake_interaction_repositories.dart';
void main() { void main() {
testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', ( testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', (
tester, tester,
) async { ) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
@@ -32,45 +32,24 @@ void main() {
await tester.pumpWidget(_host(_detailDrama())); await tester.pumpWidget(_host(_detailDrama()));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final hero = tester.widget<AspectRatio>( expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190);
find.byKey(const Key('detail-hero')),
);
expect(hero.aspectRatio, closeTo(16 / 9, 0.001));
expect(find.byKey(const Key('detail-cta')), findsOneWidget); expect(find.byKey(const Key('detail-cta')), findsOneWidget);
expect(find.byKey(const Key('detail-favorite')), findsOneWidget); expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40);
expect(find.byKey(const Key('detail-download')), findsOneWidget);
expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget);
expect( expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
tester.getSize(find.byKey(const Key('detail-episode-chip-1'))),
const Size(46, 44),
);
final synopsis = tester.widget<Text>( final synopsis = tester.widget<Text>(
find.byKey(const Key('detail-synopsis')), find.byKey(const Key('detail-synopsis')),
); );
expect(synopsis.maxLines, 2); expect(synopsis.style?.fontSize, 11);
await tester.tap(find.byKey(const Key('detail-synopsis-toggle'))); expect(synopsis.style?.height, 1.6);
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.byKey(const Key('detail-synopsis'))).maxLines,
100,
);
final grid = tester.widget<SliverGrid>( await tester.tap(find.byKey(const Key('detail-more')));
find.descendant( await tester.pumpAndSettle();
of: find.byKey(const Key('detail-episode-grid')), expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
matching: find.byType(SliverGrid), expect(find.byKey(const Key('detail-download')), findsOneWidget);
), expect(find.byKey(const Key('detail-share')), findsOneWidget);
); expect(find.byKey(const Key('detail-report')), findsOneWidget);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 2);
expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))),
const Size(96, 64),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
@@ -90,7 +69,7 @@ void main() {
); );
await tester.pumpAndSettle(); 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); expect(tester.takeException(), isNull);
}); });
} }
+64
View File
@@ -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);
});
}
+18 -9
View File
@@ -29,11 +29,15 @@ void main() {
await _pumpApp(tester); await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); 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(); await tester.pumpAndSettle();
_expectFullscreenOverlay(); _expectFullscreenOverlay();
expect(find.byType(PageView), findsOneWidget); 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); expect(tester.takeException(), isNull);
}); });
@@ -57,24 +61,29 @@ void main() {
await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); await tester.tap(find.byKey(const Key('detail-episode-chip-2')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
_expectFullscreenOverlay(); _expectFullscreenOverlay();
expect(find.text('Episode 2 · Playing now'), findsOneWidget); expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
contains('Episode 2'),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
void _expectFullscreenOverlay() { void _expectFullscreenOverlay() {
expect(find.byKey(const Key('rail-like')), findsOneWidget); expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(find.byKey(const Key('rail-save')), findsOneWidget); expect(find.byKey(const Key('player-title')), findsOneWidget);
expect(find.byKey(const Key('rail-comments')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget);
expect(find.byKey(const Key('rail-share')), findsOneWidget); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget);
expect(find.byTooltip('Picture in picture'), findsOneWidget); expect(find.byKey(const Key('player-episodes')), findsOneWidget);
expect(find.byTooltip('Clear screen'), findsOneWidget); expect(find.byKey(const Key('player-comments')), findsOneWidget);
expect(find.byKey(const Key('player-next')), findsOneWidget);
expect(find.byType(Slider), 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-video-frame')), findsNothing);
expect(find.byKey(const Key('card-player-progress')), findsNothing); expect(find.byKey(const Key('card-player-progress')), findsNothing);
expect(find.byKey(const Key('card-player-next')), findsNothing); expect(find.byKey(const Key('card-player-next')), findsNothing);
expect(find.byKey(const Key('player-title')), findsNothing);
} }
Future<void> _pumpApp(WidgetTester tester) async { Future<void> _pumpApp(WidgetTester tester) async {
+35 -27
View File
@@ -110,17 +110,19 @@ void main() {
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
expect(find.byType(Slider), findsOneWidget); expect(find.byType(Slider), findsNothing);
expect(find.text('Home'), findsWidgets); expect(find.text('Home'), findsWidgets);
expect(find.textContaining('Playing now'), findsOneWidget); expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.text('Recommended'), findsNothing); expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
expect(find.text('Following'), findsNothing); expect(find.byKey(const Key('rail-like')), findsOneWidget);
expect(find.byTooltip('Episodes'), findsWidgets); expect(find.byKey(const Key('rail-comments')), findsOneWidget);
expect(find.byTooltip('Clear screen'), findsOneWidget); expect(find.byKey(const Key('rail-share')), findsOneWidget);
expect(find.byTooltip('Picture in picture'), findsNothing); expect(find.byKey(const Key('rail-save')), findsOneWidget);
await tester.tap(find.byTooltip('More')); await tester.tap(find.byKey(const Key('home-feed-more')));
await tester.pumpAndSettle(); 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.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Watch Full'), findsNothing); expect(find.text('Watch Full'), findsNothing);
@@ -139,14 +141,16 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); 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(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byKey(const Key('home-tabs-bar')), findsNothing);
expect(find.byTooltip('Exit clear screen'), findsOneWidget); expect(find.byTooltip('Exit clear screen'), findsOneWidget);
await tester.tap(find.byTooltip('Exit clear screen')); await tester.tap(find.byTooltip('Exit clear screen'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); 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); expect(tester.takeException(), isNull);
}); });
@@ -233,12 +237,11 @@ void main() {
expect(find.text('我的'), findsWidgets); expect(find.text('我的'), findsWidgets);
await tester.drag(find.byType(ListView), const Offset(0, 700)); await tester.drag(find.byType(ListView), const Offset(0, 700));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('我的账户'), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
expect(find.text('首页'), findsOneWidget); expect(find.text('首页'), findsOneWidget);
await tester.tap(find.text('找剧')); await tester.tap(find.text('发现'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('全部'), findsOneWidget); expect(find.text('推荐'), findsOneWidget);
expect(find.text('热门'), findsOneWidget);
}); });
testWidgets( testWidgets(
@@ -259,7 +262,9 @@ void main() {
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); 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(); await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget); expect(find.byType(Slider), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
@@ -324,7 +329,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); 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('home-all-dramas-grid')), findsOneWidget);
expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget);
expect( expect(
@@ -341,7 +346,7 @@ void main() {
), ),
findsOneWidget, findsOneWidget,
); );
expect(find.byTooltip('Search'), findsNothing); expect(find.byTooltip('Search'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const Key('tab-my'))); 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.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byTooltip('Search'), findsNothing); expect(find.byTooltip('Search'), findsOneWidget);
expect( expect(
Navigator.of( Navigator.of(
tester.element(find.byKey(const Key('home-all-dramas-grid'))), 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.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byTooltip('Search'), findsNothing); expect(find.byTooltip('Search'), findsOneWidget);
expect( expect(
Navigator.of( Navigator.of(
tester.element(find.byKey(const Key('home-all-dramas-grid'))), 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.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); 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(); await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget); expect(find.byType(Slider), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.drag(find.byType(PageView), const Offset(0, -550));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Episode 2 · Playing now'), findsOneWidget); expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
contains('Episode 2'),
);
expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets);
}, },
); );
@@ -507,9 +517,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final keys = [ final keys = [
const Key('rail-like'), const Key('rail-like'),
const Key('rail-save'),
const Key('rail-comments'), const Key('rail-comments'),
const Key('rail-share'), const Key('rail-share'),
const Key('rail-save'),
]; ];
final tops = keys final tops = keys
.map((key) => tester.getTopLeft(find.byKey(key)).dy) .map((key) => tester.getTopLeft(find.byKey(key)).dy)
@@ -644,10 +654,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.scrollUntilVisible( await tester.drag(find.byType(ListView), const Offset(0, -320));
find.byKey(const Key('mine-library-tab-1')), await tester.pumpAndSettle();
300,
);
await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);