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