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