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