merge: K26 客户端六屏按预览校正

This commit is contained in:
Zhou Weidong committed 2026-10-19 15:51:04 +08:00
commit a5398daab3
38 files changed
+2540 -2398

No files matched your search

+16 -7
View File
@@ -38,25 +38,34 @@ abstract final class AppColors {
static const ink = Color(0xFF0B0C10);
/// Bottom navigation background (rendered at 95% with blur).
static const bar = Color(0xFF0B0C10);
static const bar = Color(0xFF0D0D11);
/// Cards lifted off [ink].
static const card = Color(0xFF16181F);
static const card = Color(0xFF1B1C21);
/// Alternate card surface from the mobile preview.
static const cardAlt = Color(0xFF1C1D21);
/// Floating surfaces, inputs, and nested rows.
static const raised = Color(0xFF1D1E28);
static const raised = Color(0xFF1C1D21);
static const quick = Color(0xFF202126);
static const input = Color(0xFF25252A);
static const goldCard = Color(0xFF201B1E);
/// Body text on dark surfaces.
static const cream = Color(0xFFE3E2E8);
static const cream = Color(0xFFE6E1E3);
/// Titles and high-emphasis text.
static const onSurface = Color(0xFFFFFFFF);
/// Secondary text.
static const muted = Color(0xFFA1A1AA);
static const muted = Color(0xFFD2CDD0);
/// Weak hints and timestamps.
static const subtle = Color(0xFF71717A);
static const subtle = Color(0xFF9A96A3);
/// Soft orange used for continue labels and secondary times.
static const accent = Color(0xFFFFA490);
/// 8% white border used by cards and inputs.
static const border = Color(0x14FFFFFF);
@@ -65,7 +74,7 @@ abstract final class AppColors {
static const glassBorder = Color(0x24FFFFFF);
static const outlineDark = Color(0xFF2A2C36);
static const outlineVariantDark = Color(0xFF242630);
static const outlineVariantDark = Color(0xFF242329);
static const outlineLight = Color(0xFF8E8E93);
static const outlineVariantLight = Color(0xFFE4E4E7);
}
+21 -11
View File
@@ -9,15 +9,17 @@ abstract final class AppDimens {
static const double spaceXl = 24;
static const double pageHorizontal = 16;
static const double cardRadius = 16;
static const double cardRadius = 12;
static const double authCardRadius = 20;
static const double cardPadding = 16;
static const double innerRadius = 12;
static const double innerRadius = 10;
static const double innerPadding = 12;
static const double pillRadius = 20;
static const double glassSmallRadius = 16;
static const double glassRadius = 24;
static const double glassCardRadius = 26;
static const double glassSmallRadius = 12;
static const double glassRadius = 14;
static const double glassCardRadius = 14;
static const double buttonRadius = 9;
static const double coverRadius = 10;
static const double caption = 12;
static const double emptyStateSubtitle = 12.5;
@@ -27,17 +29,25 @@ abstract final class AppDimens {
static const double sectionTitle = 16;
static const double pageTitle = 20;
static const double detailHeadline = 24;
static const double navLabel = 11;
static const double detailHeadlineSmall = 18;
static const double previewCardTitle = 13;
static const double previewSectionTitle = 14;
static const double chipFont = 10;
static const double navLabel = 9;
static const double buttonHeight = 48;
static const double primaryButtonHeight = 52;
static const double buttonCompact = 32;
static const double buttonSmall = 34;
static const double buttonMedium = 40;
static const double buttonLarge = 44;
static const double tapTarget = 44;
static const double navHeight = 64;
static const double navIcon = 22;
static const double progressHeight = 4;
static const double progressRadius = 4;
static const double progressLineHeight = 1.5;
static const double navHeight = 54;
static const double navIcon = 17;
static const double progressHeight = 3;
static const double progressRadius = 2;
static const double progressLineHeight = 3;
static const double searchHeight = 44;
static const double navDot = 3;
+4 -16
View File
@@ -16,7 +16,6 @@ class AppBottomNavItem {
required this.label,
required this.onTap,
this.selected = false,
this.indicatorKey,
});
final Key tabKey;
@@ -25,7 +24,6 @@ class AppBottomNavItem {
final String label;
final VoidCallback onTap;
final bool selected;
final Key? indicatorKey;
}
/// Shared 3-tab navigation used by all root tabs and the player fallback.
@@ -52,7 +50,9 @@ class AppBottomNav extends StatelessWidget {
height: AppDimens.navHeight,
decoration: BoxDecoration(
color: backgroundColor.withValues(alpha: 0.95),
border: const Border(top: BorderSide(color: AppColors.border)),
border: const Border(
top: BorderSide(color: AppColors.outlineVariantDark),
),
),
child: Row(
children: [for (final item in items) _NavDestination(item: item)],
@@ -94,7 +94,7 @@ class _NavDestination extends StatelessWidget {
: Colors.white.withValues(alpha: 0.45),
),
),
const SizedBox(height: 3),
const SizedBox(height: 2),
Text(
item.label,
maxLines: 1,
@@ -108,18 +108,6 @@ class _NavDestination extends StatelessWidget {
letterSpacing: 0,
),
),
const SizedBox(height: 3),
AnimatedContainer(
key: item.indicatorKey,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
width: AppDimens.navDot,
height: AppDimens.navDot,
decoration: BoxDecoration(
color: item.selected ? AppColors.blue : Colors.transparent,
shape: BoxShape.circle,
),
),
],
),
),
+1 -1
View File
@@ -13,7 +13,7 @@ class GlassPanel extends StatelessWidget {
super.key,
required this.child,
this.radius = AppDimens.glassRadius,
this.blur = AppDimens.glassBlur,
this.blur = 10,
this.padding,
this.borderColor = AppColors.glassBorder,
this.gradient,
+22 -1
View File
@@ -15,6 +15,14 @@
"discoverTab": "Discover",
"searchDiscover": "Search dramas, actors, genres...",
"featured": "Featured",
"recommended": "Recommended",
"fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "Sign in to sync progress, favorites and coins",
"comingSoon": "Coming soon",
"authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.",
"authContinueGoogle": "Continue with Google",
"authPhoneSignIn": "Phone sign in",
"authEmailSignIn": "Email sign in",
"suspense": "Suspense",
"romance": "Romance",
"actionGenre": "Action",
@@ -31,6 +39,19 @@
"startWatching": "Start Watching",
"showLess": "Less",
"views": "views",
"episodeOfTotal": "Episode {current} / {total}",
"@episodeOfTotal": {
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"minutesAgo": "{count} min ago",
"@minutesAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "Artwork",
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
"videoPending": "Video pending",
@@ -52,7 +73,7 @@
},
"resume": "Resume",
"history": "History",
"watchlist": "Watchlist",
"watchlist": "Favorites",
"likedTab": "Liked",
"all": "All",
"inProgress": "In Progress",
+22 -1
View File
@@ -12,9 +12,17 @@
"openQuickMenu": "Buka menu cepat",
"quickAccess": "Akses cepat",
"currentSeries": "Serial saat ini",
"discoverTab": "Jelajahi",
"discoverTab": "Temukan",
"searchDiscover": "Cari drama, aktor, genre...",
"featured": "Unggulan",
"recommended": "Rekomendasi",
"fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin",
"comingSoon": "Segera hadir",
"authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.",
"authContinueGoogle": "Lanjutkan dengan Google",
"authPhoneSignIn": "Masuk dengan ponsel",
"authEmailSignIn": "Masuk dengan email",
"suspense": "Thriller",
"romance": "Romantis",
"actionGenre": "Aksi",
@@ -31,6 +39,19 @@
"startWatching": "Mulai Menonton",
"showLess": "Lebih sedikit",
"views": "tayangan",
"episodeOfTotal": "Episode {current} / {total}",
"@episodeOfTotal": {
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"minutesAgo": "{count} mnt lalu",
"@minutesAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "Gambar",
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
"videoPending": "Video belum siap",
+61 -1
View File
@@ -190,6 +190,54 @@ abstract class AppLocalizations {
/// **'Featured'**
String get featured;
/// No description provided for @recommended.
///
/// In en, this message translates to:
/// **'Recommended'**
String get recommended;
/// No description provided for @fruitShortsBrand.
///
/// In en, this message translates to:
/// **'FruitShorts'**
String get fruitShortsBrand;
/// No description provided for @authLandingSubtitle.
///
/// In en, this message translates to:
/// **'Sign in to sync progress, favorites and coins'**
String get authLandingSubtitle;
/// No description provided for @comingSoon.
///
/// In en, this message translates to:
/// **'Coming soon'**
String get comingSoon;
/// No description provided for @authLegal.
///
/// In en, this message translates to:
/// **'By continuing, you agree to the Terms of Service and Privacy Policy.'**
String get authLegal;
/// No description provided for @authContinueGoogle.
///
/// In en, this message translates to:
/// **'Continue with Google'**
String get authContinueGoogle;
/// No description provided for @authPhoneSignIn.
///
/// In en, this message translates to:
/// **'Phone sign in'**
String get authPhoneSignIn;
/// No description provided for @authEmailSignIn.
///
/// In en, this message translates to:
/// **'Email sign in'**
String get authEmailSignIn;
/// No description provided for @suspense.
///
/// In en, this message translates to:
@@ -286,6 +334,18 @@ abstract class AppLocalizations {
/// **'views'**
String get views;
/// No description provided for @episodeOfTotal.
///
/// In en, this message translates to:
/// **'Episode {current} / {total}'**
String episodeOfTotal(int current, int total);
/// No description provided for @minutesAgo.
///
/// In en, this message translates to:
/// **'{count} min ago'**
String minutesAgo(int count);
/// No description provided for @artworkOnly.
///
/// In en, this message translates to:
@@ -373,7 +433,7 @@ abstract class AppLocalizations {
/// No description provided for @watchlist.
///
/// In en, this message translates to:
/// **'Watchlist'**
/// **'Favorites'**
String get watchlist;
/// No description provided for @likedTab.
+37 -1
View File
@@ -54,6 +54,32 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get featured => 'Featured';
@override
String get recommended => 'Recommended';
@override
String get fruitShortsBrand => 'FruitShorts';
@override
String get authLandingSubtitle =>
'Sign in to sync progress, favorites and coins';
@override
String get comingSoon => 'Coming soon';
@override
String get authLegal =>
'By continuing, you agree to the Terms of Service and Privacy Policy.';
@override
String get authContinueGoogle => 'Continue with Google';
@override
String get authPhoneSignIn => 'Phone sign in';
@override
String get authEmailSignIn => 'Email sign in';
@override
String get suspense => 'Suspense';
@@ -102,6 +128,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get views => 'views';
@override
String episodeOfTotal(int current, int total) {
return 'Episode $current / $total';
}
@override
String minutesAgo(int count) {
return '$count min ago';
}
@override
String get artworkOnly => 'Artwork';
@@ -149,7 +185,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get history => 'History';
@override
String get watchlist => 'Watchlist';
String get watchlist => 'Favorites';
@override
String get likedTab => 'Liked';
+37 -1
View File
@@ -46,7 +46,7 @@ class AppLocalizationsId extends AppLocalizations {
String get currentSeries => 'Serial saat ini';
@override
String get discoverTab => 'Jelajahi';
String get discoverTab => 'Temukan';
@override
String get searchDiscover => 'Cari drama, aktor, genre...';
@@ -54,6 +54,32 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get featured => 'Unggulan';
@override
String get recommended => 'Rekomendasi';
@override
String get fruitShortsBrand => 'FruitShorts';
@override
String get authLandingSubtitle =>
'Masuk untuk menyinkronkan progres, favorit, dan koin';
@override
String get comingSoon => 'Segera hadir';
@override
String get authLegal =>
'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.';
@override
String get authContinueGoogle => 'Lanjutkan dengan Google';
@override
String get authPhoneSignIn => 'Masuk dengan ponsel';
@override
String get authEmailSignIn => 'Masuk dengan email';
@override
String get suspense => 'Thriller';
@@ -102,6 +128,16 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get views => 'tayangan';
@override
String episodeOfTotal(int current, int total) {
return 'Episode $current / $total';
}
@override
String minutesAgo(int count) {
return '$count mnt lalu';
}
@override
String get artworkOnly => 'Gambar';
+36 -2
View File
@@ -45,7 +45,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get currentSeries => '当前短剧';
@override
String get discoverTab => '找剧';
String get discoverTab => '发现';
@override
String get searchDiscover => '搜索短剧、演员、类型…';
@@ -53,6 +53,30 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get featured => '精选';
@override
String get recommended => '推荐';
@override
String get fruitShortsBrand => '水果短剧';
@override
String get authLandingSubtitle => '登录后同步进度、收藏与金币权益';
@override
String get comingSoon => '即将开放';
@override
String get authLegal => '继续即代表同意《用户协议》与《隐私政策》';
@override
String get authContinueGoogle => '使用 Google 继续';
@override
String get authPhoneSignIn => '手机号登录';
@override
String get authEmailSignIn => '邮箱登录';
@override
String get suspense => '悬疑';
@@ -101,6 +125,16 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get views => '次播放';
@override
String episodeOfTotal(int current, int total) {
return '第 $current 集 / 共 $total 集';
}
@override
String minutesAgo(int count) {
return '$count 分钟前';
}
@override
String get artworkOnly => '海报展示';
@@ -146,7 +180,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get history => '历史';
@override
String get watchlist => '追剧';
String get watchlist => '收藏';
@override
String get likedTab => '喜欢';
+23 -2
View File
@@ -12,9 +12,17 @@
"openQuickMenu": "打开快捷菜单",
"quickAccess": "快捷入口",
"currentSeries": "当前短剧",
"discoverTab": "找剧",
"discoverTab": "发现",
"searchDiscover": "搜索短剧、演员、类型…",
"featured": "精选",
"recommended": "推荐",
"fruitShortsBrand": "水果短剧",
"authLandingSubtitle": "登录后同步进度、收藏与金币权益",
"comingSoon": "即将开放",
"authLegal": "继续即代表同意《用户协议》与《隐私政策》",
"authContinueGoogle": "使用 Google 继续",
"authPhoneSignIn": "手机号登录",
"authEmailSignIn": "邮箱登录",
"suspense": "悬疑",
"romance": "都市爱情",
"actionGenre": "动作",
@@ -31,6 +39,19 @@
"startWatching": "开始观看",
"showLess": "收起",
"views": "次播放",
"episodeOfTotal": "第 {current} 集 / 共 {total} 集",
"@episodeOfTotal": {
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"minutesAgo": "{count} 分钟前",
"@minutesAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "海报展示",
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
"videoPending": "视频待提供",
@@ -52,7 +73,7 @@
},
"resume": "继续播放",
"history": "历史",
"watchlist": "追剧",
"watchlist": "收藏",
"likedTab": "喜欢",
"all": "全部",
"inProgress": "未看完",
+161 -9
View File
@@ -60,6 +60,7 @@ class _AuthPageState extends State<AuthPage> {
late final FocusNode _emailFocus;
late final FocusNode _passwordFocus;
bool _obscurePassword = true;
bool _emailEntryOpen = false;
@override
void initState() {
@@ -243,7 +244,13 @@ class _AuthPageState extends State<AuthPage> {
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip,
onPressed: () => Navigator.of(context).pop(),
onPressed: () {
if (_emailEntryOpen) {
setState(() => _emailEntryOpen = false);
} else {
Navigator.of(context).pop();
}
},
icon: const Icon(
Icons.arrow_back_rounded,
color: AppColors.onPrimary,
@@ -272,16 +279,107 @@ class _AuthPageState extends State<AuthPage> {
child: child,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
SizedBox(height: compact ? _compactBrandGap : 28),
_authCard(l10n),
],
),
child: _emailEntryOpen
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
SizedBox(height: compact ? _compactBrandGap : 28),
_authCard(l10n),
],
)
: _landing(l10n),
);
Widget _landing(AppLocalizations l10n) => Column(
key: const Key('auth-landing'),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
key: const Key('auth-brand-logo'),
width: 64,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue, AppColors.secondary],
),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 32,
),
),
),
const SizedBox(height: 14),
Text(
l10n.fruitShortsBrand,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
l10n.authLandingSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11),
),
const SizedBox(height: 22),
_AuthEntryButton(
key: const Key('auth-entry-google'),
label: 'G ${l10n.authContinueGoogle}',
background: Colors.white,
foreground: const Color(0xFF1A1A1A),
leadingColor: const Color(0xFF4285F4),
onTap: () => _comingSoon(l10n),
),
const SizedBox(height: 11),
_AuthEntryButton(
key: const Key('auth-entry-phone'),
label: l10n.authPhoneSignIn,
icon: Icons.phone_android_rounded,
background: AppColors.input,
foreground: AppColors.onSurface,
onTap: () => _comingSoon(l10n),
),
const SizedBox(height: 11),
_AuthEntryButton(
key: const Key('auth-entry-email'),
label: l10n.authEmailSignIn,
icon: Icons.mail_outline_rounded,
background: AppColors.input,
foreground: AppColors.onSurface,
onTap: () => setState(() => _emailEntryOpen = true),
),
const SizedBox(height: 18),
Text(
l10n.authLegal,
key: const Key('auth-legal'),
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF6B6772),
fontSize: 9,
height: 1.6,
),
),
],
);
void _comingSoon(AppLocalizations l10n) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.comingSoon)));
}
Widget _authCard(AppLocalizations l10n) => _AuthCard(
state: _AuthCardState(
l10n: l10n,
@@ -320,3 +418,57 @@ class _AuthPageState extends State<AuthPage> {
setState(() => _logic.toggleMode());
}
}
class _AuthEntryButton extends StatelessWidget {
const _AuthEntryButton({
super.key,
required this.label,
required this.background,
required this.foreground,
required this.onTap,
this.icon,
this.leadingColor,
});
final String label;
final Color background;
final Color foreground;
final VoidCallback onTap;
final IconData? icon;
final Color? leadingColor;
@override
Widget build(BuildContext context) {
return Material(
color: background,
borderRadius: BorderRadius.circular(9),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 44,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null)
Icon(icon, size: 16, color: leadingColor ?? foreground),
if (icon != null) const SizedBox(width: 7),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}
+158 -203
View File
@@ -13,7 +13,6 @@ import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/glass_panel.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'discover_binding.dart';
@@ -165,53 +164,41 @@ class _DiscoverPageState extends State<DiscoverPage> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12),
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
child: Row(
children: [
Container(
width: 3,
height: 18,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.trendingMicroDramas,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
l10n.trendingSubtitle,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 11,
),
),
],
child: Text(
l10n.trendingMicroDramas,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.previewSectionTitle,
fontWeight: FontWeight.w800,
),
),
),
TextButton(
onPressed: () =>
categories.selectCode(CategoryController.hotCode),
categories.selectCode(CategoryController.allCode),
style: TextButton.styleFrom(
foregroundColor: AppColors.blue,
foregroundColor: AppColors.accent,
minimumSize: const Size(44, 44),
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: EdgeInsets.zero,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.seeAll,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const Icon(Icons.chevron_right_rounded, size: 16),
],
child: Text(
'${l10n.all} ›',
style: const TextStyle(fontSize: 10),
),
),
],
@@ -236,55 +223,41 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _header(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 21,
fontWeight: FontWeight.w700,
height: 1.05,
letterSpacing: 0,
),
),
),
],
),
const SizedBox(height: 3),
Text(
l10n.verticalMiniSeries,
style: const TextStyle(
color: AppColors.muted,
fontSize: 10,
fontWeight: FontWeight.w500,
letterSpacing: 0.6,
),
),
],
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 16,
),
),
const SizedBox(width: 12),
const SizedBox(width: 8),
Text(
l10n.discoverTab,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const Spacer(),
_HeaderIcon(
key: const Key('discover-search-button'),
icon: Icons.search_rounded,
tooltip: l10n.search,
onTap: logic.openSearch,
),
const SizedBox(width: 7),
Semantics(
button: true,
label: widget.signedIn ? l10n.myTab : l10n.logIn,
@@ -293,20 +266,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
customBorder: const CircleBorder(),
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
child: Container(
width: 38,
height: 38,
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.raised,
border: Border.all(
color: AppColors.secondary.withValues(alpha: 0.7),
width: 2,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 21,
size: 15,
),
),
),
@@ -318,9 +291,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _search() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: SizedBox(
height: AppDimens.searchHeight,
height: 36,
child: TextField(
key: const Key('discover-search'),
controller: logic.search,
@@ -331,27 +304,22 @@ class _DiscoverPageState extends State<DiscoverPage> {
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.card,
fillColor: AppColors.input,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5),
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 20,
),
suffixIcon: const Icon(
Icons.mic_none_rounded,
color: AppColors.muted,
size: 19,
size: 17,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
borderSide: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
borderSide: const BorderSide(color: AppColors.blue, width: 1.2),
borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.blue, width: 1),
),
),
),
@@ -361,9 +329,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _categories(AppLocalizations l10n, CategoryController categories) {
final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.all),
(code: CategoryController.hotCode, label: l10n.hot),
(code: CategoryController.newCode, label: l10n.newest),
(code: CategoryController.allCode, label: l10n.recommended),
for (final category in categories.categories)
(code: 'category-${category.id}', label: category.name),
];
@@ -371,7 +337,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
key: const Key('home-category-chips'),
height: AppDimens.tapTarget,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal,
itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
@@ -390,12 +356,10 @@ class _DiscoverPageState extends State<DiscoverPage> {
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration(
color: selected ? AppColors.blue : AppColors.card,
color: selected ? AppColors.blue : const Color(0xFF29272B),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected
? AppColors.blue
: Colors.white.withValues(alpha: 0.08),
color: selected ? AppColors.blue : Colors.transparent,
),
),
child: Text(
@@ -403,7 +367,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 12,
fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
height: 1.1,
),
@@ -423,23 +387,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
WatchProgress record,
) {
final progress = record.fraction;
return GlassPanel(
return Container(
key: const Key('discover-continue-watching'),
radius: AppDimens.glassSmallRadius,
blur: 18,
padding: const EdgeInsets.all(12),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)],
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.cardAlt,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 64,
height: 88,
width: 52,
height: 66,
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
@@ -447,57 +408,38 @@ class _DiscoverPageState extends State<DiscoverPage> {
),
),
),
const SizedBox(width: 12),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Flexible(
child: Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.blue,
fontSize: 9.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
),
],
Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.accent,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
const SizedBox(height: 2),
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
_resumeMeta(drama, record),
_resumeShort(drama, record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 10.5,
),
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
const SizedBox(height: 7),
ClipRRect(
@@ -505,7 +447,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
child: LinearProgressIndicator(
value: progress <= 0 ? null : progress,
minHeight: 3,
backgroundColor: Colors.white.withValues(alpha: 0.12),
backgroundColor: const Color(0xFF474248),
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
@@ -525,29 +467,24 @@ class _DiscoverPageState extends State<DiscoverPage> {
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
minimumSize: const Size(64, 34),
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: const StadiumBorder(),
minimumSize: const Size(56, 28),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(
fontSize: 11,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.play),
const SizedBox(width: 2),
const Icon(Icons.play_arrow_rounded, size: 14),
],
),
child: Text(l10n.resume),
),
],
),
);
}
String _resumeMeta(Drama drama, WatchProgress record) {
String _resumeShort(Drama drama, WatchProgress record) {
final label = AppLocalizations.of(context)!;
Episode? episode;
for (final item in drama.episodes) {
@@ -556,10 +493,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
break;
}
}
final head = episode == null
? ''
: '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · ';
return '$head${_duration(record.position)} / ${_duration(record.duration)}';
if (episode == null) return _duration(record.position);
return label.episodeOfTotal(episode.number, drama.episodeCount);
}
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
@@ -574,7 +509,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
onTap: logic.openSearch,
),
),
const SizedBox(width: 8),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-charts'),
@@ -584,17 +519,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
onTap: () => categories.selectCode(CategoryController.hotCode),
),
),
const SizedBox(width: 8),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-new'),
icon: Icons.auto_awesome_rounded,
label: l10n.newest,
label: l10n.newReleases,
color: AppColors.gold,
onTap: () => categories.selectCode(CategoryController.newCode),
),
),
const SizedBox(width: 8),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-watchlist'),
@@ -617,8 +552,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
items: [
AppBottomNavItem(
tabKey: const Key('discover-nav-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
onTap: () => logic.act(
context,
@@ -637,8 +572,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
),
AppBottomNavItem(
tabKey: const Key('discover-nav-my'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
onTap: () => logic.act(
context,
@@ -651,6 +586,37 @@ class _DiscoverPageState extends State<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 {
const _QuickAction({
super.key,
@@ -668,31 +634,20 @@ class _QuickAction extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
color: AppColors.quick,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Container(
height: 76,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
border: Border.all(color: AppColors.border),
),
height: 62,
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 17),
),
const SizedBox(height: 5),
Icon(icon, color: color, size: 16),
const SizedBox(height: 4),
Text(
label,
maxLines: 2,
@@ -700,7 +655,7 @@ class _QuickAction extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 9.5,
fontSize: 9,
fontWeight: FontWeight.w500,
height: 1.05,
),
+55 -102
View File
@@ -37,27 +37,22 @@ class _HomeDramaGrid extends StatelessWidget {
),
);
}
if (dramas.isEmpty) {
final missingCategory = categories.selectedSectionMissing;
return _fill(
EmptyState(
key: const Key('home-all-dramas-empty'),
icon: searching
? Icons.search_off_rounded
: missingCategory
? Icons.grid_view_rounded
: Icons.movie_filter_outlined,
title: searching
? l10n.noSearchResults
: missingCategory
: categories.selectedSectionMissing
? l10n.noCategoryDramas
: l10n.noSearchResults,
subtitle: l10n.emptyHint,
),
);
}
return SliverMainAxisGroup(
slivers: [
_dramaGrid(dramas),
@@ -68,14 +63,14 @@ class _HomeDramaGrid extends StatelessWidget {
Widget _dramaGrid(List<Drama> items) => SliverPadding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
padding: const EdgeInsets.symmetric(horizontal: 14),
sliver: SliverGrid(
key: const Key('home-all-dramas-grid'),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 14,
crossAxisSpacing: 12,
childAspectRatio: 0.55,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
mainAxisExtent: 164,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _HomeDramaCard(
@@ -88,9 +83,8 @@ class _HomeDramaGrid extends StatelessWidget {
),
);
Widget _fill(Widget child) {
return SliverFillRemaining(hasScrollBody: false, child: child);
}
Widget _fill(Widget child) =>
SliverFillRemaining(hasScrollBody: false, child: child);
}
class _HomeDramaCard extends StatelessWidget {
@@ -107,18 +101,21 @@ class _HomeDramaCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return InkWell(
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
SizedBox(
key: Key('home-drama-cover-${drama.id}'),
aspectRatio: 3 / 4.2,
height: 120,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
@@ -132,120 +129,76 @@ class _HomeDramaCard extends StatelessWidget {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x33000000),
Color(0x00000000),
Color(0xB3000000),
],
stops: [0, 0.42, 1],
colors: [Color(0x0D000000), Color(0xCC000000)],
stops: [0.45, 1],
),
),
),
Positioned(
top: 8,
left: 8,
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
if (drama.isHot) _badge(l10n.hot, AppColors.blue),
if (drama.freeEpisodes > 0)
_badge(l10n.free, AppColors.success),
],
),
),
if (drama.viewCount > 0)
if (free)
Positioned(
right: 8,
bottom: 8,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(7),
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
child: Text(
_compactCount(drama.viewCount),
key: Key('home-drama-views-${drama.id}'),
style: const TextStyle(
color: AppColors.cream,
fontSize: 10,
fontWeight: FontWeight.w600,
),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text(
l10n.free,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
),
),
Positioned(
left: 6,
right: 6,
bottom: 6,
child: Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
),
),
],
),
),
),
const SizedBox(height: 8),
const SizedBox(height: 5),
Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
const SizedBox(height: 2),
Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.2,
),
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
],
),
);
}
Widget _badge(String label, Color color) {
return DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
child: Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
height: 1.1,
),
),
),
);
}
}
String _compactCount(int value) {
if (value >= 1000000) {
final scaled = value / 1000000;
return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M';
}
if (value >= 1000) {
final scaled = value / 1000;
return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K';
}
return '$value';
}
File diff suppressed because it is too large. Load diff
+4 -7
View File
@@ -245,11 +245,10 @@ class _HomeTabsState extends State<_HomeTabs> {
items: [
AppBottomNavItem(
tabKey: const Key('tab-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
selected: logic.tab == 0,
indicatorKey: const Key('tab-home-indicator'),
onTap: () => _selectTab(context, 0),
),
AppBottomNavItem(
@@ -258,16 +257,14 @@ class _HomeTabsState extends State<_HomeTabs> {
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
selected: logic.tab == 1,
indicatorKey: const Key('tab-discover-indicator'),
onTap: () => _selectTab(context, 1),
),
AppBottomNavItem(
tabKey: const Key('tab-my'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
selected: logic.tab == 2,
indicatorKey: const Key('tab-my-indicator'),
onTap: () => _selectTab(context, 2, myProfile: true),
),
],
File diff suppressed because it is too large. Load diff
+12
View File
@@ -531,6 +531,18 @@ class PlayerLogic extends ChangeNotifier {
: 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) {
if (session.active != player) return;
scrubbingPlayer = player;
+308 -209
View File
@@ -18,13 +18,13 @@ import '../../core/formatting/localized_content.dart';
import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/home_feed_overlay.dart';
import 'widgets/player_featured_comment.dart';
import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
@@ -340,7 +340,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id),
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
@@ -381,10 +380,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
)
else if (!logic.screenCleared)
_PlaybackOverlay(
@@ -398,9 +393,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onDiscover: () => logic.showDiscover(context),
onEpisodes: () => logic.showEpisodes(context),
onMy: () => logic.showMy(context),
onNext: logic.hasNextEpisode
? () => unawaited(logic.playNextEpisode())
: null,
playing: logic.session.userWantsPlay,
onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
onLike: () async {
if (await ensureSignedIn(context)) {
@@ -434,7 +433,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onDownload: () => requestEpisodeDownload(context, episode.id),
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onClear: () => logic.setScreenCleared(true),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
@@ -442,7 +440,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
showBottomNav: false,
),
if (logic.screenCleared)
SafeArea(
@@ -649,20 +646,19 @@ class _PlaybackOverlay extends StatelessWidget {
required this.controller,
required this.liked,
required this.saved,
required this.playing,
required this.onTogglePlay,
required this.onBack,
required this.onDiscover,
required this.onEpisodes,
required this.onMy,
required this.onComments,
required this.onLike,
required this.onSave,
required this.onDownload,
required this.onComments,
required this.onShare,
required this.onClear,
required this.onPip,
required this.onScrubStart,
required this.onSeek,
required this.showBottomNav,
this.onNext,
});
final AppLocalizations l10n;
@@ -671,230 +667,333 @@ class _PlaybackOverlay extends StatelessWidget {
final VideoPlayerController? controller;
final bool liked;
final bool saved;
final bool playing;
final VoidCallback onTogglePlay;
final VoidCallback? onBack;
final VoidCallback onDiscover;
final VoidCallback onEpisodes;
final VoidCallback onMy;
final VoidCallback onComments;
final VoidCallback? onNext;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onDownload;
final VoidCallback onComments;
final VoidCallback onShare;
final VoidCallback onClear;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showBottomNav;
@override
Widget build(BuildContext context) => SafeArea(
bottom: showBottomNav,
child: Stack(
children: [
Column(
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
_topBar(context),
Expanded(
child: Stack(
children: [
Center(
child: GestureDetector(
key: const Key('player-play-toggle'),
onTap: onTogglePlay,
child: Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Color(0x80000000),
shape: BoxShape.circle,
),
child: Icon(
playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 26,
),
),
),
),
const Positioned(
right: 10,
bottom: 14,
child: Text(
'1.0x',
style: TextStyle(color: Color(0xFFDDDDDD), fontSize: 9),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: Column(
children: [
SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
showTimes: false,
onScrubStart: onScrubStart,
onSeek: onSeek,
),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: _PlayerAction(
key: const Key('player-episodes'),
icon: Icons.view_list_rounded,
label: l10n.episodes,
onTap: onEpisodes,
),
),
const SizedBox(width: 8),
Expanded(
child: _PlayerAction(
key: const Key('player-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments,
onTap: onComments,
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 32,
child: FilledButton(
key: const Key('player-next'),
onPressed: onNext,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
disabledBackgroundColor: AppColors.raised,
disabledForegroundColor: AppColors.subtle,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
padding: EdgeInsets.zero,
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
l10n.nextEpisode,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 2),
const Icon(Icons.skip_next_rounded, size: 14),
],
),
),
),
),
],
),
const SizedBox(height: 10),
PlayerFeaturedComment(drama: drama, onViewAll: onComments),
],
),
),
],
),
);
}
Widget _topBar(BuildContext context) {
return SizedBox(
height: 46,
child: Row(
children: [
if (onBack != null)
_TopAction(
key: const Key('player-back'),
tooltip: l10n.backToSeries,
icon: Icons.chevron_left_rounded,
onPressed: onBack!,
)
else
const SizedBox(width: 28),
const SizedBox(width: 8),
Expanded(
child: Text(
'${localizedDramaTitle(l10n, drama)} · '
'${l10n.episodeLabel(episode.number)}',
key: const Key('player-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
_TopAction(
key: const Key('player-more'),
tooltip: l10n.more,
icon: Icons.more_horiz_rounded,
onPressed: () => _openMore(context),
),
],
),
);
}
Future<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: _info()),
_rail(),
],
ListTile(
key: const Key('player-more-download'),
leading: const Icon(
Icons.download_outlined,
color: AppColors.secondary,
),
title: Text(l10n.downloads),
onTap: () {
Navigator.pop(sheetContext);
onDownload();
},
),
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
20 + MediaQuery.paddingOf(context).bottom,
if (onPip != null)
ListTile(
key: const Key('player-more-pip'),
leading: const Icon(
Icons.picture_in_picture_alt_outlined,
color: AppColors.secondary,
),
title: Text(l10n.pictureInPicture),
onTap: () {
Navigator.pop(sheetContext);
onPip!();
},
),
child: SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
onScrubStart: onScrubStart,
onSeek: onSeek,
ListTile(
key: const Key('player-more-like'),
leading: Icon(
liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
color: liked ? AppColors.blue : AppColors.muted,
),
title: Text(l10n.like),
onTap: () {
Navigator.pop(sheetContext);
onLike();
},
),
ListTile(
key: const Key('player-more-favorite'),
leading: Icon(
saved ? Icons.star_rounded : Icons.star_border_rounded,
color: saved ? AppColors.blue : AppColors.muted,
),
title: Text(l10n.favorite),
onTap: () {
Navigator.pop(sheetContext);
onSave();
},
),
ListTile(
key: const Key('player-more-share'),
leading: const Icon(Icons.share_outlined, color: AppColors.muted),
title: Text(l10n.share),
onTap: () {
Navigator.pop(sheetContext);
onShare();
},
),
if (showBottomNav) _fallbackNav(),
],
),
if (onBack != null)
Positioned(
top: 4,
left: 8,
child: IconButton(
tooltip: l10n.backToSeries,
onPressed: onBack,
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
),
icon: const Icon(Icons.arrow_back_rounded),
),
),
Positioned(
top: 4,
right: 8,
child: IconButton(
tooltip: l10n.clearScreen,
onPressed: onClear,
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
),
icon: const Icon(Icons.fit_screen_outlined),
),
),
],
),
);
Widget _info() => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
height: 1.15,
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
),
),
const SizedBox(height: 6),
Text(
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
],
);
Widget _rail() => Column(
mainAxisSize: MainAxisSize.min,
children: [
_RailAction(
key: const Key('rail-like'),
icon: liked ? Icons.favorite : Icons.favorite_border,
label: l10n.like,
selected: liked,
onTap: onLike,
),
_RailAction(
key: const Key('rail-save'),
icon: saved ? Icons.bookmark : Icons.bookmark_border,
label: l10n.save,
selected: saved,
onTap: onSave,
),
_RailAction(
key: const Key('rail-download'),
icon: Icons.download_outlined,
label: l10n.downloads,
onTap: onDownload,
),
_RailAction(
icon: Icons.grid_view_outlined,
label: l10n.episodes,
onTap: onEpisodes,
),
_RailAction(
key: const Key('rail-comments'),
icon: Icons.chat_bubble_outline,
label: l10n.comments,
onTap: onComments,
),
_RailAction(
key: const Key('rail-share'),
icon: Icons.share_outlined,
label: l10n.share,
onTap: onShare,
),
if (onPip != null)
IconButton(
tooltip: l10n.pictureInPicture,
onPressed: onPip,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _railTapSize,
height: _railTapSize,
),
color: AppColors.cream,
icon: const Icon(
Icons.picture_in_picture_alt_outlined,
size: _railIconSize,
),
),
],
);
Widget _fallbackNav() => AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('player-nav-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
selected: true,
onTap: () {},
),
AppBottomNavItem(
tabKey: const Key('player-nav-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
onTap: onDiscover,
),
AppBottomNavItem(
tabKey: const Key('player-nav-my'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
onTap: onMy,
),
],
);
);
}
}
const double _railIconSize = 28;
const double _railIconWeight = 300;
const double _railTapSize = 44;
class _TopAction extends StatelessWidget {
const _TopAction({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
});
class _RailAction extends StatelessWidget {
const _RailAction({
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF2C2D33),
foregroundColor: AppColors.onSurface,
),
icon: Icon(icon, size: 15),
),
);
}
}
class _PlayerAction extends StatelessWidget {
const _PlayerAction({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => IconButton(
tooltip: label,
onPressed: onTap,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _railTapSize,
height: _railTapSize,
),
color: selected ? AppColors.blueSoft : AppColors.cream,
icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0),
);
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF2A2A30),
borderRadius: BorderRadius.circular(9),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 32,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 14, color: AppColors.secondary),
const SizedBox(width: 3),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}
}
+160 -394
View File
@@ -3,27 +3,21 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/glass_panel.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import 'seek_progress_bar.dart';
/// Immersive home layer from `screens/01-home.html`.
///
/// Playback, ticket, progress, favorite, like, comment, and share callbacks are
/// all owned by [PlayerPage]; this widget only paints the design overlay.
/// Home feed layer from `docs/移动端设计预览.html` screen 01.
class HomeFeedOverlay extends StatelessWidget {
const HomeFeedOverlay({
super.key,
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
required this.saved,
required this.onLike,
@@ -34,15 +28,12 @@ class HomeFeedOverlay extends StatelessWidget {
required this.onDownload,
required this.onClear,
required this.onSearch,
required this.onScrubStart,
required this.onSeek,
this.onPip,
});
final AppLocalizations l10n;
final Drama drama;
final Episode episode;
final VideoPlayerController? controller;
final bool liked;
final bool saved;
final VoidCallback onLike;
@@ -54,12 +45,9 @@ class HomeFeedOverlay extends StatelessWidget {
final VoidCallback onClear;
final VoidCallback onSearch;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
@override
Widget build(BuildContext context) {
final compact = MediaQuery.sizeOf(context).height < 620;
return SafeArea(
bottom: false,
child: Column(
@@ -67,16 +55,16 @@ class HomeFeedOverlay extends StatelessWidget {
_header(context),
Expanded(
child: Align(
alignment: Alignment.bottomRight,
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 12, 4),
child: _rail(compact),
padding: const EdgeInsets.fromLTRB(0, 0, 10, 12),
child: _rail(),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
child: _infoCard(context, compact),
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
child: _infoCard(),
),
],
),
@@ -85,79 +73,35 @@ class HomeFeedOverlay extends StatelessWidget {
Widget _header(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 4),
padding: const EdgeInsets.fromLTRB(14, 6, 10, 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 7),
Flexible(
child: Text(
localizedDramaTitle(l10n, drama),
key: const Key('home-feed-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 17,
fontWeight: FontWeight.w700,
height: 1.1,
shadows: [
Shadow(blurRadius: 10, color: Colors.black87),
],
),
),
),
if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()],
],
),
const SizedBox(height: 3),
Text(
'${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.64),
fontSize: 10.5,
fontWeight: FontWeight.w500,
shadows: const [
Shadow(blurRadius: 8, color: Colors.black87),
],
),
),
],
child: Text(
localizedDramaTitle(l10n, drama),
key: const Key('home-feed-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
),
const SizedBox(width: 6),
_GlassIconButton(
_RoundIcon(
key: const Key('home-feed-search'),
tooltip: l10n.search,
icon: Icons.search_rounded,
onPressed: onSearch,
),
const SizedBox(width: 4),
_GlassIconButton(
tooltip: l10n.clearScreen,
icon: Icons.fit_screen_outlined,
onPressed: onClear,
),
const SizedBox(width: 4),
_GlassIconButton(
const SizedBox(width: 6),
_RoundIcon(
key: const Key('home-feed-more'),
tooltip: l10n.more,
icon: Icons.more_horiz_rounded,
icon: Icons.menu_rounded,
onPressed: () => _openMore(context),
),
],
@@ -165,131 +109,53 @@ class HomeFeedOverlay extends StatelessWidget {
);
}
Widget _hotBadge() {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
child: Text(
l10n.hot,
style: const TextStyle(
color: AppColors.blue,
fontSize: 8,
fontWeight: FontWeight.w800,
letterSpacing: 0.4,
height: 1.1,
),
),
),
);
}
Widget _rail(bool compact) {
Widget _rail() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
button: true,
label: saved ? l10n.followingTab : l10n.followSeries,
child: InkWell(
key: const Key('home-feed-follow'),
onTap: onSave,
customBorder: const CircleBorder(),
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: compact ? 40 : 44,
height: compact ? 40 : 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: AppColors.blue, width: 2),
color: AppColors.raised,
),
child: ClipOval(
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
Positioned(
bottom: -4,
left: 14,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: saved ? AppColors.success : AppColors.blue,
shape: BoxShape.circle,
border: Border.all(color: AppColors.ink, width: 2),
),
child: Icon(
saved ? Icons.check_rounded : Icons.add_rounded,
color: AppColors.onPrimary,
size: 11,
),
),
),
],
),
),
),
const SizedBox(height: 8),
_RailAction(
compact: compact,
_RailItem(
key: const Key('rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: l10n.like,
selected: liked,
onTap: onLike,
),
_RailAction(
compact: compact,
key: const Key('rail-save'),
icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
label: l10n.save,
selected: saved,
onTap: onSave,
),
_RailAction(
compact: compact,
_RailItem(
key: const Key('rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments,
onTap: onComments,
),
_RailAction(
compact: compact,
_RailItem(
key: const Key('rail-share'),
icon: Icons.share_outlined,
icon: Icons.ios_share_rounded,
label: l10n.share,
onTap: onShare,
),
_RailItem(
key: const Key('rail-save'),
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
label: l10n.favorite,
selected: saved,
onTap: onSave,
),
],
);
}
Widget _infoCard(BuildContext context, bool compact) {
final genres = [
if (drama.genre.isNotEmpty) drama.genre,
if (drama.category.isNotEmpty) drama.category,
].take(2).toList();
Widget _infoCard() {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
return GlassPanel(
key: const Key('home-feed-info-card'),
radius: 26,
blur: 24,
padding: const EdgeInsets.fromLTRB(16, 13, 16, 12),
radius: 14,
blur: 6,
padding: const EdgeInsets.fromLTRB(12, 11, 12, 11),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0x260B0C10), Color(0x590B0C10)],
colors: [Color(0xBF0F0F14), Color(0xA60F0F14)],
),
borderColor: Colors.white.withValues(alpha: 0.06),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -298,94 +164,73 @@ class HomeFeedOverlay extends StatelessWidget {
children: [
Expanded(
child: Wrap(
spacing: 5,
spacing: 6,
runSpacing: 4,
children: [
for (var i = 0; i < genres.length; i++)
_genreChip(genres[i], i.isEven),
if (genre.isNotEmpty)
_tag(
genre,
background: AppColors.blue,
foreground: AppColors.onPrimary,
),
if (drama.category.isNotEmpty && drama.category != genre)
_tag(
drama.category,
background: const Color(0xFF333333),
foreground: const Color(0xFFDDDDDD),
),
],
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 5,
height: 5,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 4),
Text(
'${l10n.episodeLabel(episode.number)} / '
'${drama.episodeCount}',
key: const Key('home-feed-episode'),
style: const TextStyle(
color: AppColors.cream,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
Flexible(
child: Text(
l10n.episodeOfTotal(episode.number, drama.episodeCount),
key: const Key('home-feed-episode'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
),
],
),
const SizedBox(height: 8),
const SizedBox(height: 7),
Text(
drama.synopsis,
maxLines: compact ? 1 : 2,
localizedEpisodeTitle(l10n, episode.number),
key: const Key('home-feed-episode-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 12,
height: 1.35,
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
showTimes: false,
compact: true,
onScrubStart: onScrubStart,
onSeek: onSeek,
),
_timeRow(),
const SizedBox(height: 8),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: SizedBox(
height: 40,
height: 34,
child: FilledButton.icon(
key: const Key('home-feed-follow-series'),
onPressed: onSave,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
shape: const StadiumBorder(),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppDimens.buttonRadius,
),
),
padding: EdgeInsets.zero,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
icon: Icon(
saved ? Icons.check_rounded : Icons.add_rounded,
size: 16,
),
icon: const Icon(Icons.play_arrow_rounded, size: 16),
label: Text(
saved ? l10n.followingTab : l10n.followSeries,
maxLines: 1,
@@ -395,11 +240,33 @@ class HomeFeedOverlay extends StatelessWidget {
),
),
const SizedBox(width: 8),
_GlassAction(
tooltip: l10n.episodes,
icon: Icons.grid_view_rounded,
label: l10n.episodes,
onPressed: onEpisodes,
Expanded(
child: SizedBox(
height: 34,
child: FilledButton(
key: const Key('home-feed-episodes'),
onPressed: onEpisodes,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2A2A30),
foregroundColor: AppColors.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppDimens.buttonRadius,
),
),
padding: EdgeInsets.zero,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
child: Text(
l10n.episodes,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
],
),
@@ -408,41 +275,29 @@ class HomeFeedOverlay extends StatelessWidget {
);
}
Widget _genreChip(String label, bool secondary) {
final color = secondary ? AppColors.secondary : AppColors.blue;
Widget _tag(
String label, {
required Color background,
required Color foreground,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.3)),
color: background,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w600,
height: 1.1,
color: foreground,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
);
}
Widget _timeRow() {
final source = controller;
if (source == null) {
return const _Times(position: Duration.zero, duration: Duration.zero);
}
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) =>
_Times(position: value.position, duration: value.duration),
);
}
Future<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
@@ -456,7 +311,7 @@ class HomeFeedOverlay extends StatelessWidget {
key: const Key('home-feed-download'),
leading: const Icon(
Icons.download_outlined,
color: AppColors.blueSoft,
color: AppColors.secondary,
),
title: Text(l10n.downloads),
onTap: () {
@@ -469,7 +324,7 @@ class HomeFeedOverlay extends StatelessWidget {
key: const Key('home-feed-pip'),
leading: const Icon(
Icons.picture_in_picture_alt_outlined,
color: AppColors.blueSoft,
color: AppColors.secondary,
),
title: Text(l10n.pictureInPicture),
onTap: () {
@@ -478,9 +333,10 @@ class HomeFeedOverlay extends StatelessWidget {
},
),
ListTile(
key: const Key('home-feed-clear'),
leading: const Icon(
Icons.fit_screen_outlined,
color: AppColors.blueSoft,
color: AppColors.secondary,
),
title: Text(l10n.clearScreen),
onTap: () {
@@ -495,35 +351,9 @@ class HomeFeedOverlay extends StatelessWidget {
}
}
class _Times extends StatelessWidget {
const _Times({required this.position, required this.duration});
final Duration position;
final Duration duration;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(position),
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
Text(
_format(duration),
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
],
),
);
}
}
class _GlassIconButton extends StatelessWidget {
const _GlassIconButton({
class _RoundIcon extends StatelessWidget {
const _RoundIcon({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
@@ -531,140 +361,76 @@ class _GlassIconButton extends StatelessWidget {
final String tooltip;
final IconData icon;
final VoidCallback? onPressed;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 34,
height: 34,
width: 28,
height: 28,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.28),
backgroundColor: const Color(0xCC2C2D33),
foregroundColor: AppColors.onSurface,
side: BorderSide(color: Colors.white.withValues(alpha: 0.16)),
),
icon: Icon(icon, size: 18),
icon: Icon(icon, size: 14),
),
);
}
}
class _GlassAction extends StatelessWidget {
const _GlassAction({
required this.tooltip,
required this.icon,
required this.label,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(999),
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: AppColors.secondary, size: 15),
const SizedBox(width: 5),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.cream,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
class _RailAction extends StatelessWidget {
const _RailAction({
class _RailItem extends StatelessWidget {
const _RailItem({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
this.compact = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
final bool compact;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 2 : 3),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GlassPanel(
radius: 999,
blur: 16,
child: SizedBox(
width: compact ? 40 : 38,
height: compact ? 40 : 38,
child: IconButton(
tooltip: label,
onPressed: onTap,
padding: EdgeInsets.zero,
icon: Icon(
padding: const EdgeInsets.only(bottom: 14),
child: Semantics(
button: true,
label: label,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(22),
child: SizedBox(
width: 44,
child: Column(
children: [
Icon(
icon,
size: 19,
size: 22,
color: selected ? AppColors.blue : AppColors.onSurface,
),
),
const SizedBox(height: 2),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 9,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
),
],
),
),
if (!compact) ...[
const SizedBox(height: 2),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.cream,
fontSize: 9,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
),
],
],
),
),
);
}
}
String _format(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
@@ -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,
inactiveTrackColor: widget.compact
? Colors.white.withValues(alpha: 0.18)
: Colors.white30,
: const Color(0xFF474248),
thumbColor: widget.compact ? AppColors.blue : Colors.white,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compact
@@ -46,6 +46,8 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
expect(find.byKey(const Key('mine-account-profile')), findsOneWidget);
+3
View File
@@ -82,4 +82,7 @@ Future<void> _pumpAuth(WidgetTester tester) async {
),
);
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
}
+65
View File
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import '../support/fake_haptics.dart';
import '../support/fake_http_adapter.dart';
void main() {
testWidgets('should_show_three_login_entries_and_open_email_form', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final auth = AuthController(
AuthApi(
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
),
),
AuthSessionStore(),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
Provider<Haptics>.value(value: FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AuthPage(),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
await tester.tap(find.byKey(const Key('auth-entry-google')));
await tester.pumpAndSettle();
expect(find.text('Coming soon'), findsOneWidget);
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('auth-email')), findsOneWidget);
expect(find.byKey(const Key('auth-password')), findsOneWidget);
});
}
+3
View File
@@ -59,6 +59,9 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
}
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (
+21 -5
View File
@@ -29,6 +29,7 @@ void main() {
WidgetTester tester, {
AuthApi? api,
FakeHaptics? haptics,
bool openEmail = true,
}) async {
final auth = AuthController(
api ??
@@ -57,20 +58,32 @@ void main() {
),
);
await tester.pump(const Duration(milliseconds: 220));
if (openEmail) {
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
}
}
testWidgets(
'should_render_branded_dark_layout_with_approved_tokens_when_page_opens',
(tester) async {
await pumpAuthPage(tester);
await pumpAuthPage(tester, openEmail: false);
expect(find.byKey(const Key('auth-brand')), findsOneWidget);
expect(find.byKey(const Key('auth-landing')), findsOneWidget);
expect(find.text('FruitShorts'), findsOneWidget);
expect(find.text('Short dramas made for Indonesia'), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('auth-brand-mark'))),
const Size(48, 48),
find.text('Sign in to sync progress, favorites and coins'),
findsOneWidget,
);
expect(
tester.getSize(find.byKey(const Key('auth-brand-logo'))),
const Size(64, 64),
);
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
final glow = tester.widget<Container>(
find.byKey(const Key('auth-background-glow')),
@@ -81,6 +94,9 @@ void main() {
expect(color.a, lessThanOrEqualTo(0.18));
}
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
final card = tester.widget<Container>(find.byKey(const Key('auth-card')));
final cardDecoration = card.decoration! as BoxDecoration;
expect(cardDecoration.color, AppColors.card);
+15 -17
View File
@@ -39,44 +39,42 @@ void main() {
expect(find.text('Continue Watching'), findsNothing);
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
expect(find.text('All'), findsOneWidget);
expect(find.text('Recommended'), findsOneWidget);
expect(find.text('For You'), findsNothing);
expect(
tester.getCenter(find.text('All')).dx,
lessThan(tester.getCenter(find.text('Hot')).dx),
);
final grid = tester.widget<SliverGrid>(
find.byKey(const Key('home-all-dramas-grid')),
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 2);
expect(delegate.crossAxisSpacing, 12);
expect(delegate.mainAxisSpacing, 14);
expect(delegate.childAspectRatio, closeTo(0.55, 0.001));
expect(delegate.crossAxisSpacing, 10);
expect(delegate.mainAxisSpacing, 10);
expect(delegate.mainAxisExtent, 164);
expect(
tester
.widget<SliverPadding>(
find.byKey(const Key('home-all-dramas-section')),
)
.padding,
const EdgeInsets.symmetric(horizontal: 16),
const EdgeInsets.symmetric(horizontal: 14),
);
expect(find.byType(GridView), findsNothing);
expect(find.byType(CustomScrollView), findsOneWidget);
expect(find.byType(RemoteImage), findsWidgets);
final cover = tester.widget<AspectRatio>(
find.byKey(const Key('home-drama-cover-city-lights-demo')),
expect(
tester
.getSize(find.byKey(const Key('home-drama-cover-city-lights-demo')))
.height,
120,
);
expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001));
expect(
tester
.widget<Text>(
find.byKey(const Key('home-drama-title-city-lights-demo')),
)
.maxLines,
2,
1,
);
expect(
tester
@@ -100,14 +98,14 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
expect(find.text('The Signal'), findsWidgets);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
await tester.tap(find.text('Romance'));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsNothing);
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
@@ -182,7 +180,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(tester.takeException(), isNull);
});
+13 -34
View File
@@ -21,7 +21,7 @@ import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
void main() {
testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', (
testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
@@ -32,45 +32,24 @@ void main() {
await tester.pumpWidget(_host(_detailDrama()));
await tester.pumpAndSettle();
final hero = tester.widget<AspectRatio>(
find.byKey(const Key('detail-hero')),
);
expect(hero.aspectRatio, closeTo(16 / 9, 0.001));
expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190);
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
expect(find.byKey(const Key('detail-download')), findsOneWidget);
expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40);
expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('detail-episode-chip-1'))),
const Size(46, 44),
);
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
final synopsis = tester.widget<Text>(
find.byKey(const Key('detail-synopsis')),
);
expect(synopsis.maxLines, 2);
await tester.tap(find.byKey(const Key('detail-synopsis-toggle')));
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.byKey(const Key('detail-synopsis'))).maxLines,
100,
);
expect(synopsis.style?.fontSize, 11);
expect(synopsis.style?.height, 1.6);
final grid = tester.widget<SliverGrid>(
find.descendant(
of: find.byKey(const Key('detail-episode-grid')),
matching: find.byType(SliverGrid),
),
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 2);
expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))),
const Size(96, 64),
);
await tester.tap(find.byKey(const Key('detail-more')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
expect(find.byKey(const Key('detail-download')), findsOneWidget);
expect(find.byKey(const Key('detail-share')), findsOneWidget);
expect(find.byKey(const Key('detail-report')), findsOneWidget);
expect(tester.takeException(), isNull);
});
@@ -90,7 +69,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
+8 -7
View File
@@ -52,15 +52,14 @@ void main() {
final decoration = bar.decoration as BoxDecoration;
final border = decoration.border as Border;
expect(decoration.color, AppColors.bar.withValues(alpha: 0.95));
expect(border.top.color, AppColors.border);
expect(border.top.color.a, lessThan(0.2));
expect(border.top.color, AppColors.outlineVariantDark);
expect(border.top.color, isNot(Colors.white));
expect(
tester
.widget<AnimatedContainer>(
.widget<AnimatedScale>(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byType(AnimatedContainer),
matching: find.byType(AnimatedScale),
),
)
.duration,
@@ -69,7 +68,7 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_rounded),
matching: find.byIcon(Icons.play_circle_fill_rounded),
),
findsOneWidget,
);
@@ -90,7 +89,7 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.home_outlined),
matching: find.byIcon(Icons.play_circle_outline_rounded),
),
findsOneWidget,
);
@@ -100,7 +99,9 @@ void main() {
tester,
) async {
await _pumpApp(tester);
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();
for (final key in _keys) {
+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);
});
}
+69
View File
@@ -0,0 +1,69 @@
// 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/models/watch_progress.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_me_user_wallet_continue_and_language_rows', (
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(
seed: const [
WatchProgress(
dramaId: 'city-lights-demo',
episodeId: 'city-lights-demo-1',
position: Duration(minutes: 2),
duration: Duration(minutes: 5),
),
],
),
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-my')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-user-row')), findsOneWidget);
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget);
expect(find.byKey(const Key('mine-language')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
+2
View File
@@ -46,6 +46,8 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
await tester.tap(find.byKey(const Key('mine-notices')));
+18 -9
View File
@@ -29,11 +29,15 @@ void main() {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.tap(find.text('City Lights & Velvet Nights'));
await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo')));
await tester.pumpAndSettle();
_expectFullscreenOverlay();
expect(find.byType(PageView), findsOneWidget);
await tester.tap(find.byKey(const Key('player-more')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-more-download')), findsOneWidget);
expect(find.byKey(const Key('player-more-pip')), findsOneWidget);
expect(tester.takeException(), isNull);
});
@@ -57,24 +61,29 @@ void main() {
await tester.tap(find.byKey(const Key('detail-episode-chip-2')));
await tester.pumpAndSettle();
_expectFullscreenOverlay();
expect(find.text('Episode 2 · Playing now'), findsOneWidget);
expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
contains('Episode 2'),
);
expect(tester.takeException(), isNull);
});
}
void _expectFullscreenOverlay() {
expect(find.byKey(const Key('rail-like')), findsOneWidget);
expect(find.byKey(const Key('rail-save')), findsOneWidget);
expect(find.byKey(const Key('rail-comments')), findsOneWidget);
expect(find.byKey(const Key('rail-share')), findsOneWidget);
expect(find.byTooltip('Picture in picture'), findsOneWidget);
expect(find.byTooltip('Clear screen'), findsOneWidget);
expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(find.byKey(const Key('player-title')), findsOneWidget);
expect(find.byKey(const Key('player-more')), findsOneWidget);
expect(find.byKey(const Key('player-play-toggle')), findsOneWidget);
expect(find.byKey(const Key('player-episodes')), findsOneWidget);
expect(find.byKey(const Key('player-comments')), findsOneWidget);
expect(find.byKey(const Key('player-next')), findsOneWidget);
expect(find.byType(Slider), findsOneWidget);
expect(find.byKey(const Key('rail-like')), findsNothing);
expect(find.byKey(const Key('rail-save')), findsNothing);
expect(find.byKey(const Key('card-player-video-frame')), findsNothing);
expect(find.byKey(const Key('card-player-progress')), findsNothing);
expect(find.byKey(const Key('card-player-next')), findsNothing);
expect(find.byKey(const Key('player-title')), findsNothing);
}
Future<void> _pumpApp(WidgetTester tester) async {
+5 -5
View File
@@ -6,18 +6,18 @@ import 'package:fruitshorts/core/theme/app_dimens.dart';
void main() {
test('should_expose_the_k20_spacing_and_type_scale', () {
expect(AppDimens.pageHorizontal, 16);
expect(AppDimens.cardRadius, 16);
expect(AppDimens.innerRadius, 12);
expect(AppDimens.cardRadius, 12);
expect(AppDimens.innerRadius, 10);
expect(AppDimens.caption, 12);
expect(AppDimens.body, 14);
expect(AppDimens.listTitle, 15);
expect(AppDimens.sectionTitle, 16);
expect(AppDimens.pageTitle, 20);
expect(AppDimens.navLabel, 11);
expect(AppDimens.navLabel, 9);
expect(AppDimens.buttonHeight, 48);
expect(AppDimens.primaryButtonHeight, 52);
expect(AppDimens.tapTarget, 44);
expect(AppDimens.navHeight, 64);
expect(AppDimens.progressHeight, 4);
expect(AppDimens.navHeight, 54);
expect(AppDimens.progressHeight, 3);
});
}
+4 -2
View File
@@ -21,8 +21,10 @@ void main() {
expect(AppColors.secondary, const Color(0xFF9278EA));
expect(AppColors.gold, const Color(0xFFFBBF24));
expect(AppColors.ink, const Color(0xFF0B0C10));
expect(AppColors.card, const Color(0xFF16181F));
expect(AppColors.raised, const Color(0xFF1D1E28));
expect(AppColors.card, const Color(0xFF1B1C21));
expect(AppColors.cardAlt, const Color(0xFF1C1D21));
expect(AppColors.raised, const Color(0xFF1C1D21));
expect(AppColors.bar, const Color(0xFF0D0D11));
expect(AppColors.success, const Color(0xFF34D399));
expect(AppColors.onPrimary, const Color(0xFFFFFFFF));
expect(
@@ -42,6 +42,8 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520));
await tester.pumpAndSettle();
await tester.tap(find.text('Watch History'));
await tester.pumpAndSettle();
+35 -27
View File
@@ -110,17 +110,19 @@ void main() {
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
expect(find.byType(Slider), findsOneWidget);
expect(find.byType(Slider), findsNothing);
expect(find.text('Home'), findsWidgets);
expect(find.textContaining('Playing now'), findsOneWidget);
expect(find.text('Recommended'), findsNothing);
expect(find.text('Following'), findsNothing);
expect(find.byTooltip('Episodes'), findsWidgets);
expect(find.byTooltip('Clear screen'), findsOneWidget);
expect(find.byTooltip('Picture in picture'), findsNothing);
await tester.tap(find.byTooltip('More'));
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
expect(find.byKey(const Key('rail-like')), findsOneWidget);
expect(find.byKey(const Key('rail-comments')), findsOneWidget);
expect(find.byKey(const Key('rail-share')), findsOneWidget);
expect(find.byKey(const Key('rail-save')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-more')));
await tester.pumpAndSettle();
expect(find.text('Picture in picture'), findsOneWidget);
expect(find.byKey(const Key('home-feed-download')), findsOneWidget);
expect(find.byKey(const Key('home-feed-pip')), findsOneWidget);
expect(find.byKey(const Key('home-feed-clear')), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Watch Full'), findsNothing);
@@ -139,14 +141,16 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Clear screen'));
await tester.tap(find.byKey(const Key('home-feed-more')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('home-feed-clear')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-tabs-bar')), findsNothing);
expect(find.byTooltip('Exit clear screen'), findsOneWidget);
await tester.tap(find.byTooltip('Exit clear screen'));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
expect(find.byTooltip('Clear screen'), findsOneWidget);
expect(find.byKey(const Key('home-feed-more')), findsOneWidget);
expect(tester.takeException(), isNull);
});
@@ -233,12 +237,11 @@ void main() {
expect(find.text('我的'), findsWidgets);
await tester.drag(find.byType(ListView), const Offset(0, 700));
await tester.pumpAndSettle();
expect(find.text('我的账户'), findsOneWidget);
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
expect(find.text('首页'), findsOneWidget);
await tester.tap(find.text('找剧'));
await tester.tap(find.text('发现'));
await tester.pumpAndSettle();
expect(find.text('全部'), findsOneWidget);
expect(find.text('热门'), findsOneWidget);
expect(find.text('推荐'), findsOneWidget);
});
testWidgets(
@@ -259,7 +262,9 @@ void main() {
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.tap(find.text('City Lights & Velvet Nights'));
await tester.tap(
find.byKey(const Key('home-drama-card-city-lights-demo')),
);
await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget);
expect(tester.takeException(), isNull);
@@ -324,7 +329,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('All'), findsOneWidget);
expect(find.text('Recommended'), findsOneWidget);
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byKey(const Key('discover-profile')), findsOneWidget);
expect(
@@ -341,7 +346,7 @@ void main() {
),
findsOneWidget,
);
expect(find.byTooltip('Search'), findsNothing);
expect(find.byTooltip('Search'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const Key('tab-my')));
@@ -372,7 +377,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byTooltip('Search'), findsNothing);
expect(find.byTooltip('Search'), findsOneWidget);
expect(
Navigator.of(
tester.element(find.byKey(const Key('home-all-dramas-grid'))),
@@ -396,7 +401,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byTooltip('Search'), findsNothing);
expect(find.byTooltip('Search'), findsOneWidget);
expect(
Navigator.of(
tester.element(find.byKey(const Key('home-all-dramas-grid'))),
@@ -440,12 +445,17 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.tap(find.text('City Lights & Velvet Nights'));
await tester.tap(
find.byKey(const Key('home-drama-card-city-lights-demo')),
);
await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -550));
await tester.pumpAndSettle();
expect(find.text('Episode 2 · Playing now'), findsOneWidget);
expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
contains('Episode 2'),
);
expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets);
},
);
@@ -507,9 +517,9 @@ void main() {
await tester.pumpAndSettle();
final keys = [
const Key('rail-like'),
const Key('rail-save'),
const Key('rail-comments'),
const Key('rail-share'),
const Key('rail-save'),
];
final tops = keys
.map((key) => tester.getTopLeft(find.byKey(key)).dy)
@@ -644,10 +654,8 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.byKey(const Key('mine-library-tab-1')),
300,
);
await tester.drag(find.byType(ListView), const Offset(0, -320));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);