diff --git a/.gitignore b/.gitignore index 92b30ba..034bed8 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ app.*.map.json /assets/* !/assets/icon/ !/assets/icon/*.png +!/assets/fonts/ +!/assets/fonts/*.ttf +!/assets/fonts/OFL-*.txt /server/ /docs/ /AGENTS.md diff --git a/assets/fonts/OFL-Outfit.txt b/assets/fonts/OFL-Outfit.txt new file mode 100644 index 0000000..723cd44 --- /dev/null +++ b/assets/fonts/OFL-Outfit.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-PlusJakartaSans.txt b/assets/fonts/OFL-PlusJakartaSans.txt new file mode 100644 index 0000000..ea69b0c --- /dev/null +++ b/assets/fonts/OFL-PlusJakartaSans.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Outfit-Bold.ttf b/assets/fonts/Outfit-Bold.ttf new file mode 100644 index 0000000..0389ff2 Binary files /dev/null and b/assets/fonts/Outfit-Bold.ttf differ diff --git a/assets/fonts/Outfit-Regular.ttf b/assets/fonts/Outfit-Regular.ttf new file mode 100644 index 0000000..0b0a0d7 Binary files /dev/null and b/assets/fonts/Outfit-Regular.ttf differ diff --git a/assets/fonts/Outfit-SemiBold.ttf b/assets/fonts/Outfit-SemiBold.ttf new file mode 100644 index 0000000..52e25bc Binary files /dev/null and b/assets/fonts/Outfit-SemiBold.ttf differ diff --git a/assets/fonts/PlusJakartaSans-Bold.ttf b/assets/fonts/PlusJakartaSans-Bold.ttf new file mode 100644 index 0000000..91ab258 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-Bold.ttf differ diff --git a/assets/fonts/PlusJakartaSans-Medium.ttf b/assets/fonts/PlusJakartaSans-Medium.ttf new file mode 100644 index 0000000..6e7af77 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-Medium.ttf differ diff --git a/assets/fonts/PlusJakartaSans-Regular.ttf b/assets/fonts/PlusJakartaSans-Regular.ttf new file mode 100644 index 0000000..b655eb4 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-Regular.ttf differ diff --git a/assets/fonts/PlusJakartaSans-SemiBold.ttf b/assets/fonts/PlusJakartaSans-SemiBold.ttf new file mode 100644 index 0000000..609def9 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-SemiBold.ttf differ diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 1f3e855..beca1ee 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -2,26 +2,31 @@ import 'package:flutter/material.dart'; -/// Evening palette for the Indonesia launch. +/// Obsidian Flare palette from `docs/design/README.md` and screens 01/02. /// -/// Chili is the action color. Gold marks coins, rewards, and playback -/// progress. Cocoa surfaces keep the app in night-market light instead of -/// a cold utility screen. +/// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page +/// code does not need a mass rename; their values now follow the design tokens. abstract final class AppColors { - /// Chili fill for buttons, selected chips, and brand marks. - static const blue = Color(0xFFC4322A); + /// Primary CTA, selected navigation, playback progress. + static const blue = Color(0xFFFF5B3D); + static const primary = blue; - /// Pressed chili. - static const bluePressed = Color(0xFF8E221C); + /// Pressed state for the primary CTA. + static const bluePressed = Color(0xFFFF4321); - /// Coral for text and icons sitting on dark surfaces. - static const blueSoft = Color(0xFFFF8A72); + /// Secondary accent for tags, locks, and highlighted icons. + static const blueSoft = Color(0xFF9278EA); + static const secondary = blueSoft; + static const lavender = blueSoft; - /// Coins, rewards, and the playback progress line. - static const gold = Color(0xFFF6C453); + /// Coins and rewards. + static const gold = Color(0xFFFBBF24); - /// Favorite/like heart without competing with the chili primary action. - static const favorite = Color(0xFFD74A7D); + /// Success and free-state accents. + static const success = Color(0xFF34D399); + + /// Liked hearts use the primary accent. + static const favorite = blue; /// Text placed on [gold]. static const onGold = Color(0xFF2A1810); @@ -30,23 +35,37 @@ abstract final class AppColors { static const onPrimary = Color(0xFFFFFFFF); /// Page background. - static const ink = Color(0xFF100C0A); + static const ink = Color(0xFF0B0C10); - /// Bottom bar. - static const bar = Color(0xFF1A1412); + /// Bottom navigation background (rendered at 95% with blur). + static const bar = Color(0xFF0B0C10); /// Cards lifted off [ink]. - static const card = Color(0xFF241C19); + static const card = Color(0xFF16181F); - /// Chips and nested rows. - static const raised = Color(0xFF322824); + /// Floating surfaces, inputs, and nested rows. + static const raised = Color(0xFF1D1E28); - /// Primary text on dark surfaces. - static const cream = Color(0xFFF8F1EA); + /// Body text on dark surfaces. + static const cream = Color(0xFFE3E2E8); - static const muted = Color(0xFFB7A59C); - static const outlineDark = Color(0xFF4A3C36); - static const outlineVariantDark = Color(0xFF2A221F); - static const outlineLight = Color(0xFF8C7A72); - static const outlineVariantLight = Color(0xFFE4D5CC); + /// Titles and high-emphasis text. + static const onSurface = Color(0xFFFFFFFF); + + /// Secondary text. + static const muted = Color(0xFFA1A1AA); + + /// Weak hints and timestamps. + static const subtle = Color(0xFF71717A); + + /// 8% white border used by cards and inputs. + static const border = Color(0x14FFFFFF); + + /// 14% white border used by glass controls. + static const glassBorder = Color(0x24FFFFFF); + + static const outlineDark = Color(0xFF2A2C36); + static const outlineVariantDark = Color(0xFF242630); + static const outlineLight = Color(0xFF8E8E93); + static const outlineVariantLight = Color(0xFFE4E4E7); } diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 416af11..ee86e8a 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -1,6 +1,6 @@ // Author: fruit.com.cn -/// K20 cross-page spacing, type, radius, and control sizes. +/// Cross-page spacing, type, radius, and control sizes. abstract final class AppDimens { static const double spaceXs = 4; static const double spaceSm = 8; @@ -15,6 +15,9 @@ abstract final class AppDimens { static const double innerRadius = 12; static const double innerPadding = 12; static const double pillRadius = 20; + static const double glassSmallRadius = 16; + static const double glassRadius = 24; + static const double glassCardRadius = 26; static const double caption = 12; static const double emptyStateSubtitle = 12.5; @@ -34,6 +37,11 @@ abstract final class AppDimens { static const double navIcon = 22; static const double progressHeight = 4; static const double progressRadius = 4; + static const double progressLineHeight = 1.5; + + static const double searchHeight = 44; + static const double navDot = 3; + static const double glassBlur = 24; static const double accountCardHeight = 64; static const double accountIcon = 18; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 432bdb1..324e540 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -6,9 +6,10 @@ import 'package:flutter/services.dart'; import 'app_colors.dart'; import 'app_dimens.dart'; -/// Light paper and warm-cocoa themes for the Indonesia evening palette. +/// Obsidian Flare themes. Dark is the product default; light mirrors the same +/// orange primary for system-light devices. abstract final class AppTheme { - static const _lightBackground = Color(0xFFFFF6F1); + static const _lightBackground = Color(0xFFF7F7F8); static const _darkBackground = AppColors.ink; /// Light theme for system light mode. @@ -22,7 +23,7 @@ abstract final class AppTheme { static ThemeData dark() => _build( brightness: Brightness.dark, surface: _darkBackground, - accent: AppColors.blueSoft, + accent: AppColors.blue, ); static ThemeData _build({ @@ -35,27 +36,27 @@ abstract final class AppTheme { ? const ColorScheme.dark( primary: AppColors.blue, onPrimary: AppColors.onPrimary, - secondary: AppColors.blueSoft, - onSecondary: AppColors.ink, + secondary: AppColors.secondary, + onSecondary: AppColors.onPrimary, primaryContainer: AppColors.bluePressed, onPrimaryContainer: AppColors.onPrimary, - secondaryContainer: AppColors.bluePressed, + secondaryContainer: AppColors.raised, onSecondaryContainer: AppColors.onPrimary, surface: _darkBackground, - onSurface: AppColors.cream, + onSurface: AppColors.onSurface, outline: AppColors.outlineDark, outlineVariant: AppColors.outlineVariantDark, - surfaceTint: AppColors.blueSoft, + surfaceTint: AppColors.blue, inversePrimary: AppColors.blueSoft, ) : ColorScheme.light( primary: AppColors.blue, onPrimary: AppColors.onPrimary, - secondary: AppColors.blue, + secondary: AppColors.secondary, onSecondary: AppColors.onPrimary, - primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22), + primaryContainer: AppColors.blue.withValues(alpha: 0.14), onPrimaryContainer: AppColors.ink, - secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22), + secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14), onSecondaryContainer: AppColors.ink, surface: _lightBackground, onSurface: AppColors.ink, @@ -64,11 +65,12 @@ abstract final class AppTheme { surfaceTint: AppColors.blue, inversePrimary: AppColors.blueSoft, ); - return ThemeData( + final base = ThemeData( useMaterial3: true, brightness: brightness, colorScheme: scheme, scaffoldBackgroundColor: surface, + fontFamily: 'PlusJakartaSans', progressIndicatorTheme: ProgressIndicatorThemeData(color: accent), sliderTheme: SliderThemeData( activeTrackColor: accent, @@ -105,7 +107,8 @@ abstract final class AppTheme { appBarTheme: const AppBarTheme( systemOverlayStyle: SystemUiOverlayStyle.light, titleTextStyle: TextStyle( - color: AppColors.cream, + color: AppColors.onSurface, + fontFamily: 'Outfit', fontSize: AppDimens.pageTitle, fontWeight: FontWeight.w800, ), @@ -125,8 +128,29 @@ abstract final class AppTheme { contentTextStyle: const TextStyle(color: AppColors.onPrimary), ), ); + final textTheme = base.textTheme + .apply( + fontFamily: 'PlusJakartaSans', + bodyColor: dark ? AppColors.cream : AppColors.ink, + displayColor: dark ? AppColors.onSurface : AppColors.ink, + ) + .copyWith( + displayLarge: _display(base.textTheme.displayLarge, dark), + displayMedium: _display(base.textTheme.displayMedium, dark), + displaySmall: _display(base.textTheme.displaySmall, dark), + headlineLarge: _display(base.textTheme.headlineLarge, dark), + headlineMedium: _display(base.textTheme.headlineMedium, dark), + headlineSmall: _display(base.textTheme.headlineSmall, dark), + titleLarge: _display(base.textTheme.titleLarge, dark), + ); + return base.copyWith(textTheme: textTheme); } + static TextStyle? _display(TextStyle? style, bool dark) => style?.copyWith( + fontFamily: 'Outfit', + color: dark ? AppColors.onSurface : AppColors.ink, + ); + static Color _buttonColor(Set states) { if (states.contains(WidgetState.pressed) || states.contains(WidgetState.hovered) || diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index ad481da..ec7ce59 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:ui'; + import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; @@ -14,6 +16,7 @@ class AppBottomNavItem { required this.label, required this.onTap, this.selected = false, + this.indicatorKey, }); final Key tabKey; @@ -22,9 +25,10 @@ class AppBottomNavItem { final String label; final VoidCallback onTap; final bool selected; + final Key? indicatorKey; } -/// Shared bottom navigation used by all root tabs and the player fallback. +/// Shared 3-tab navigation used by all root tabs and the player fallback. class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, @@ -38,20 +42,23 @@ class AppBottomNav extends StatelessWidget { final Key? barKey; @override - Widget build(BuildContext context) => SafeArea( - top: false, - child: Container( - key: barKey, - height: AppDimens.navHeight, - decoration: BoxDecoration( - color: backgroundColor, - border: const Border( - top: BorderSide(color: AppColors.outlineVariantDark), + Widget build(BuildContext context) => ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: SafeArea( + top: false, + child: Container( + key: barKey, + height: AppDimens.navHeight, + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.95), + border: const Border(top: BorderSide(color: AppColors.border)), + ), + child: Row( + children: [for (final item in items) _NavDestination(item: item)], + ), ), ), - child: Row( - children: [for (final item in items) _NavDestination(item: item)], - ), ), ); } @@ -71,28 +78,23 @@ class _NavDestination extends StatelessWidget { key: item.tabKey, onTap: item.onTap, child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: AppDimens.tapTarget), + constraints: const BoxConstraints(minHeight: 48), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - AnimatedContainer( + AnimatedScale( duration: const Duration(milliseconds: 160), curve: Curves.easeOut, - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 3, - ), - decoration: BoxDecoration( - color: item.selected ? AppColors.blue : Colors.transparent, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), + scale: item.selected ? 1 : 0.96, child: Icon( item.selected ? item.selectedIcon : item.icon, size: AppDimens.navIcon, - color: item.selected ? AppColors.onPrimary : AppColors.muted, + color: item.selected + ? AppColors.blue + : Colors.white.withValues(alpha: 0.45), ), ), - const SizedBox(height: 2), + const SizedBox(height: 3), Text( item.label, maxLines: 1, @@ -100,7 +102,22 @@ class _NavDestination extends StatelessWidget { style: TextStyle( fontSize: AppDimens.navLabel, fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500, - color: item.selected ? AppColors.blueSoft : AppColors.muted, + color: item.selected + ? AppColors.blue + : Colors.white.withValues(alpha: 0.45), + 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, ), ), ], diff --git a/lib/core/widgets/glass_panel.dart b/lib/core/widgets/glass_panel.dart new file mode 100644 index 0000000..6e340d4 --- /dev/null +++ b/lib/core/widgets/glass_panel.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +/// Dark glass surface used by the home info card and floating controls. +class GlassPanel extends StatelessWidget { + const GlassPanel({ + super.key, + required this.child, + this.radius = AppDimens.glassRadius, + this.blur = AppDimens.glassBlur, + this.padding, + this.borderColor = AppColors.glassBorder, + this.gradient, + }); + + final Widget child; + final double radius; + final double blur; + final EdgeInsetsGeometry? padding; + final Color borderColor; + final Gradient? gradient; + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(radius); + final surface = DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all(color: borderColor), + gradient: + gradient ?? + const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x24FFFFFF), Color(0x0DFFFFFF)], + ), + ), + child: Padding(padding: padding ?? EdgeInsets.zero, child: child), + ); + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: surface, + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9a6c32b..40fa98a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -23,6 +23,10 @@ "newReleases": "New Releases", "trendingMicroDramas": "Trending Micro-Dramas", "seeAll": "See All", + "trendingSubtitle": "Most binge-watched this week", + "verticalMiniSeries": "VERTICAL MINI-SERIES", + "followSeries": "Follow Series", + "seasonOneVerticalCinema": "Season 1 • Vertical Cinema", "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5e04b7c..459faeb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -23,6 +23,10 @@ "newReleases": "Rilisan Baru", "trendingMicroDramas": "Drama Mini Populer", "seeAll": "Lihat Semua", + "trendingSubtitle": "Paling banyak ditonton minggu ini", + "verticalMiniSeries": "SERIAL MINI VERTIKAL", + "followSeries": "Ikuti Series", + "seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal", "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 70ae96e..3dee2a6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -238,6 +238,30 @@ abstract class AppLocalizations { /// **'See All'** String get seeAll; + /// No description provided for @trendingSubtitle. + /// + /// In en, this message translates to: + /// **'Most binge-watched this week'** + String get trendingSubtitle; + + /// No description provided for @verticalMiniSeries. + /// + /// In en, this message translates to: + /// **'VERTICAL MINI-SERIES'** + String get verticalMiniSeries; + + /// No description provided for @followSeries. + /// + /// In en, this message translates to: + /// **'Follow Series'** + String get followSeries; + + /// No description provided for @seasonOneVerticalCinema. + /// + /// In en, this message translates to: + /// **'Season 1 • Vertical Cinema'** + String get seasonOneVerticalCinema; + /// No description provided for @artworkOnly. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 48921e6..e40f0bd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -78,6 +78,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get seeAll => 'See All'; + @override + String get trendingSubtitle => 'Most binge-watched this week'; + + @override + String get verticalMiniSeries => 'VERTICAL MINI-SERIES'; + + @override + String get followSeries => 'Follow Series'; + + @override + String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema'; + @override String get artworkOnly => 'Artwork'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 5bb2fd7..66e2973 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -78,6 +78,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get seeAll => 'Lihat Semua'; + @override + String get trendingSubtitle => 'Paling banyak ditonton minggu ini'; + + @override + String get verticalMiniSeries => 'SERIAL MINI VERTIKAL'; + + @override + String get followSeries => 'Ikuti Series'; + + @override + String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal'; + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f3bd4f1..e3cac4a 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -77,6 +77,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get seeAll => '查看全部'; + @override + String get trendingSubtitle => '本周追看最多'; + + @override + String get verticalMiniSeries => '竖屏短剧'; + + @override + String get followSeries => '追剧'; + + @override + String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院'; + @override String get artworkOnly => '海报展示'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bd97d34..ae47bc8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -23,6 +23,10 @@ "newReleases": "新剧", "trendingMicroDramas": "热门短剧", "seeAll": "查看全部", + "trendingSubtitle": "本周追看最多", + "verticalMiniSeries": "竖屏短剧", + "followSeries": "追剧", + "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index 85a8cb1..89024ce 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -8,8 +8,5 @@ class DiscoverBinding { DiscoverLogic create({ required Drama drama, required WatchHistoryRepository history, - }) => DiscoverLogic( - drama: drama, - history: history, - ); + }) => DiscoverLogic(drama: drama, history: history); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 3e6aa11..bff295a 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../l10n/app_localizations.dart'; @@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier { final WatchHistoryRepository history; Drama drama; late Future position; + WatchProgress? resumeRecord; final TextEditingController search = TextEditingController(); final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); @@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier { .resumePointFor(drama.id) .then((point) => point?.position) .catchError((Object _) => null); + refreshResume(); + } + + Future refreshResume() async { + try { + final recent = await history.recent(limit: 1); + if (recent.isEmpty || recent.first.finished) { + resumeRecord = null; + } else { + resumeRecord = recent.first; + } + } catch (_) { + resumeRecord = null; + } + notify(); } void onSearchChanged() => notify(); + List filtered(List dramas) { + final query = search.text.trim().toLowerCase(); + if (query.isEmpty) return dramas; + return dramas.where((drama) { + final haystack = [ + drama.title, + drama.genre, + drama.category, + drama.synopsis, + ].join(' ').toLowerCase(); + return haystack.contains(query); + }).toList(); + } + + Drama? resumeDrama(List dramas) { + final record = resumeRecord; + if (record == null) return null; + for (final drama in dramas) { + if (drama.id == record.dramaId) return drama; + } + return null; + } + bool get showSearch => searchOpen || search.text.isNotEmpty; void openSearch() { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index cc259a2..ae88d85 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -5,20 +5,19 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/home_feed.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/glass_panel.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; -import 'widgets/cover_carousel.dart'; part 'widgets/home_drama_grid.dart'; @@ -70,7 +69,12 @@ class _DiscoverPageState extends State { drama: widget.drama, history: widget.history ?? context.read(), ); - if (widget.focusSearch) logic.searchOpen = true; + if (widget.focusSearch) { + logic.searchOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.active) logic.searchFocus.requestFocus(); + }); + } } @override @@ -104,14 +108,13 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); - final homeBanners = context.watch().feed.banners; final allDramas = [...?widget.dramas, ...categories.visibleDramas]; + final visible = logic.filtered(categories.visibleDramas); final content = SafeArea( bottom: !widget.embedded, child: Column( children: [ - _header(l10n), - Expanded(child: _feed(l10n, categories, homeBanners, allDramas)), + Expanded(child: _feed(l10n, categories, allDramas, visible)), if (!widget.embedded) _bottomNav(l10n), ], ), @@ -130,29 +133,96 @@ class _DiscoverPageState extends State { Widget _feed( AppLocalizations l10n, CategoryController categories, - List banners, List allDramas, + List visible, ) => CustomScrollView( controller: logic.scroll, slivers: [ + SliverToBoxAdapter(child: _header(l10n)), + SliverToBoxAdapter(child: _search()), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(18), - child: _carousel(l10n, banners, allDramas), + padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), + child: _categories(l10n, categories), + ), + ), + if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _continueCard( + l10n, + logic.resumeDrama(allDramas)!, + logic.resumeRecord!, + ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _quickActions(l10n, categories), + ), ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 14), - child: _categories(l10n, categories), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.trendingMicroDramas, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + l10n.trendingSubtitle, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 11, + ), + ), + ], + ), + ), + TextButton( + onPressed: () => + categories.selectCode(CategoryController.hotCode), + style: TextButton.styleFrom( + foregroundColor: AppColors.blue, + minimumSize: const Size(44, 44), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.seeAll, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const Icon(Icons.chevron_right_rounded, size: 16), + ], + ), + ), + ], + ), ), ), _HomeDramaGrid( l10n: l10n, categories: categories, + dramas: visible, + searching: logic.search.text.trim().isNotEmpty, onOpen: (drama) => logic.openCard( context, drama, @@ -166,93 +236,129 @@ class _DiscoverPageState extends State { Widget _header(AppLocalizations l10n) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 12, 8), + padding: const EdgeInsets.fromLTRB(16, 6, 16, 12), child: Row( children: [ - const _BrandMark(), - const SizedBox(width: 8), Expanded( - child: Text( - l10n.appName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: 20, - fontWeight: FontWeight.w800, - height: 1.1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.appName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 21, + fontWeight: FontWeight.w700, + height: 1.05, + letterSpacing: 0, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + l10n.verticalMiniSeries, + style: const TextStyle( + color: AppColors.muted, + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: 0.6, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Semantics( + button: true, + label: widget.signedIn ? l10n.myTab : l10n.logIn, + child: InkWell( + key: const Key('discover-profile'), + customBorder: const CircleBorder(), + onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.raised, + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.7), + width: 2, + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 21, + ), ), ), ), - const SizedBox(width: 8), - if (widget.signedIn) ...[ - _HeaderPill( - icon: Icons.monetization_on_rounded, - label: r'$ 0', - onTap: widget.onCoins, - ), - const SizedBox(width: 8), - _HeaderPill( - icon: Icons.card_giftcard_rounded, - label: l10n.rewards, - onTap: widget.onRewards, - ), - ] else - _LoginButton(label: l10n.logIn, onTap: widget.onSignIn), ], ), ); } - Widget _carousel( - AppLocalizations l10n, - List banners, - List allDramas, - ) { - final items = banners - .where((banner) => banner.imageUrl.isNotEmpty) - .toList(); - if (items.isEmpty) return const SizedBox.shrink(); - return CoverCarousel( - active: widget.active, - items: [ - for (final banner in items) - CoverItem( - title: _bannerTitle(banner, allDramas), - subtitle: '', - image: banner.imageUrl, - onOpen: () => _openBanner(banner, allDramas), + Widget _search() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + height: AppDimens.searchHeight, + child: TextField( + key: const Key('discover-search'), + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + textInputAction: TextInputAction.search, + style: const TextStyle(color: AppColors.onSurface, fontSize: 13), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: AppColors.card, + hintText: AppLocalizations.of(context)!.searchDiscover, + hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.subtle, + size: 20, + ), + suffixIcon: const Icon( + Icons.mic_none_rounded, + color: AppColors.muted, + size: 19, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + borderSide: const BorderSide(color: AppColors.blue, width: 1.2), + ), ), - ], + ), + ), ); } - String _bannerTitle(FeedBanner banner, List allDramas) { - final dramaId = banner.dramaId; - if (dramaId == null) return ''; - for (final drama in allDramas) { - if (drama.id == dramaId) return drama.title; - } - return ''; - } - - void _openBanner(FeedBanner banner, List allDramas) { - final dramaId = banner.dramaId; - if (dramaId == null) return; - for (final drama in allDramas) { - if (drama.id == dramaId) { - logic.openCard( - context, - drama, - embedded: widget.embedded, - onDramaSelected: widget.onDramaSelected, - onAction: widget.onAction, - ); - return; - } - } - } - Widget _categories(AppLocalizations l10n, CategoryController categories) { final choices = <({String code, String label})>[ (code: CategoryController.allCode, label: l10n.all), @@ -265,6 +371,7 @@ class _DiscoverPageState extends State { key: const Key('home-category-chips'), height: AppDimens.tapTarget, child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), scrollDirection: Axis.horizontal, itemCount: choices.length, separatorBuilder: (_, _) => const SizedBox(width: 8), @@ -281,17 +388,22 @@ class _DiscoverPageState extends State { child: AnimatedContainer( duration: const Duration(milliseconds: 180), alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: 15), decoration: BoxDecoration( - color: selected ? AppColors.blue : AppColors.raised, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + color: selected ? AppColors.blue : AppColors.card, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: selected + ? AppColors.blue + : Colors.white.withValues(alpha: 0.08), + ), ), child: Text( choice.label, maxLines: 1, style: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.cream, - fontSize: AppDimens.body, + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: 12, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), @@ -305,12 +417,208 @@ class _DiscoverPageState extends State { ); } + Widget _continueCard( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { + final progress = record.fraction; + return GlassPanel( + key: const Key('discover-continue-watching'), + radius: AppDimens.glassSmallRadius, + blur: 18, + padding: const EdgeInsets.all(12), + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)], + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 64, + height: 88, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Flexible( + child: Text( + l10n.continueWatching, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.blue, + fontSize: 9.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + _resumeMeta(drama, record), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 10.5, + ), + ), + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: progress <= 0 ? null : progress, + minHeight: 3, + backgroundColor: Colors.white.withValues(alpha: 0.12), + valueColor: const AlwaysStoppedAnimation(AppColors.blue), + ), + ), + ], + ), + ), + const SizedBox(width: 10), + FilledButton( + key: const Key('discover-continue-play'), + onPressed: () => logic.openCard( + context, + drama, + embedded: widget.embedded, + onDramaSelected: widget.onDramaSelected, + onAction: widget.onAction, + ), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + minimumSize: const Size(64, 34), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + 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) { + final label = AppLocalizations.of(context)!; + Episode? episode; + for (final item in drama.episodes) { + if (item.id == record.episodeId) { + episode = item; + break; + } + } + final head = episode == null + ? '' + : '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · '; + return '$head${_duration(record.position)} / ${_duration(record.duration)}'; + } + + Widget _quickActions(AppLocalizations l10n, CategoryController categories) { + return Row( + children: [ + Expanded( + child: _QuickAction( + key: const Key('discover-quick-filter'), + icon: Icons.tune_rounded, + label: l10n.filter, + color: AppColors.secondary, + onTap: logic.openSearch, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-charts'), + icon: Icons.local_fire_department_rounded, + label: l10n.topCharts, + color: AppColors.blue, + onTap: () => categories.selectCode(CategoryController.hotCode), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-new'), + icon: Icons.auto_awesome_rounded, + label: l10n.newest, + color: AppColors.gold, + onTap: () => categories.selectCode(CategoryController.newCode), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-watchlist'), + icon: Icons.bookmark_border_rounded, + label: l10n.watchlist, + color: AppColors.success, + onTap: () => logic.act( + context, + DiscoverPageAction.watchlist, + embedded: widget.embedded, + onAction: widget.onAction, + ), + ), + ), + ], + ); + } + Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, label: l10n.homeTab, onTap: () => logic.act( context, @@ -329,8 +637,8 @@ class _DiscoverPageState extends State { ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, onTap: () => logic.act( context, @@ -343,72 +651,61 @@ class _DiscoverPageState extends State { ); } -const _coin = AppColors.gold; - -class _BrandMark extends StatelessWidget { - const _BrandMark(); - - @override - Widget build(BuildContext context) { - return Container( - width: 28, - height: 28, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: Colors.white, - size: 20, - ), - ); - } -} - -class _HeaderPill extends StatelessWidget { - const _HeaderPill({ +class _QuickAction extends StatelessWidget { + const _QuickAction({ + super.key, required this.icon, required this.label, + required this.color, required this.onTap, }); final IconData icon; final String label; - final VoidCallback? onTap; + final Color color; + final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( - color: AppColors.raised, - shape: StadiumBorder( - side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)), - ), + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + clipBehavior: Clip.antiAlias, child: InkWell( - customBorder: const StadiumBorder(), onTap: onTap, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: AppDimens.tapTarget), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 15, color: _coin), - const SizedBox(width: 4), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w700, - height: 1.1, - ), + child: Container( + height: 76, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), ), - ], - ), + child: Icon(icon, color: color, size: 17), + ), + const SizedBox(height: 5), + Text( + label, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 9.5, + fontWeight: FontWeight.w500, + height: 1.05, + ), + ), + ], ), ), ), @@ -416,28 +713,8 @@ class _HeaderPill extends StatelessWidget { } } -class _LoginButton extends StatelessWidget { - const _LoginButton({required this.label, required this.onTap}); - - final String label; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return TextButton( - onPressed: onTap, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: AppColors.blue, - padding: const EdgeInsets.symmetric(horizontal: 14), - minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget), - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontSize: AppDimens.body, - fontWeight: FontWeight.w600, - ), - ), - child: Text(label, maxLines: 1), - ); - } +String _duration(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'; } diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 3ecf9a0..015e269 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget { const _HomeDramaGrid({ required this.l10n, required this.categories, + required this.dramas, + required this.searching, required this.onOpen, }); final AppLocalizations l10n; final CategoryController categories; + final List dramas; + final bool searching; final ValueChanged onOpen; @override @@ -34,16 +38,21 @@ class _HomeDramaGrid extends StatelessWidget { ); } - final dramas = categories.visibleDramas; if (dramas.isEmpty) { final missingCategory = categories.selectedSectionMissing; return _fill( EmptyState( key: const Key('home-all-dramas-empty'), - icon: missingCategory + icon: searching + ? Icons.search_off_rounded + : missingCategory ? Icons.grid_view_rounded - : Icons.search_off_rounded, - title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults, + : Icons.movie_filter_outlined, + title: searching + ? l10n.noSearchResults + : missingCategory + ? l10n.noCategoryDramas + : l10n.noSearchResults, subtitle: l10n.emptyHint, ), ); @@ -57,24 +66,24 @@ class _HomeDramaGrid extends StatelessWidget { ); } - Widget _dramaGrid(List dramas) => SliverPadding( + Widget _dramaGrid(List items) => SliverPadding( key: const Key('home-all-dramas-section'), padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), sliver: SliverGrid( key: const Key('home-all-dramas-grid'), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, + crossAxisCount: 2, mainAxisSpacing: 14, - crossAxisSpacing: 10, - childAspectRatio: 2 / 3, + crossAxisSpacing: 12, + childAspectRatio: 0.55, ), delegate: SliverChildBuilderDelegate( (context, index) => _HomeDramaCard( l10n: l10n, - drama: dramas[index], - onTap: () => onOpen(dramas[index]), + drama: items[index], + onTap: () => onOpen(items[index]), ), - childCount: dramas.length, + childCount: items.length, ), ), ); @@ -102,95 +111,141 @@ class _HomeDramaCard extends StatelessWidget { key: Key('home-drama-card-${drama.id}'), onTap: onTap, borderRadius: BorderRadius.circular(AppDimens.cardRadius), - child: AspectRatio( - key: Key('home-drama-cover-${drama.id}'), - aspectRatio: 2 / 3, - child: ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0xE6100C0A)], - stops: [0.42, 1], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + key: Key('home-drama-cover-${drama.id}'), + aspectRatio: 3 / 4.2, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, ), - ), - ), - if (drama.isHot) - Positioned( - top: 8, - left: 8, - child: DecoratedBox( + const DecoratedBox( decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x33000000), + Color(0x00000000), + Color(0xB3000000), + ], + stops: [0, 0.42, 1], ), - child: Text( - l10n.hot, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w800, - height: 1.1, + ), + ), + Positioned( + top: 8, + left: 8, + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + if (drama.isHot) _badge(l10n.hot, AppColors.blue), + if (drama.freeEpisodes > 0) + _badge(l10n.free, AppColors.success), + ], + ), + ), + if (drama.viewCount > 0) + 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( + horizontal: 6, + vertical: 2, + ), + child: Text( + _compactCount(drama.viewCount), + key: Key('home-drama-views-${drama.id}'), + style: const TextStyle( + color: AppColors.cream, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), ), ), ), - ), - ), - Positioned( - left: 10, - right: 10, - bottom: 10, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - drama.title, - key: Key('home-drama-title-${drama.id}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.body, - fontWeight: FontWeight.w800, - height: 1.15, - ), - ), - const SizedBox(height: 4), - Text( - genre.isEmpty ? l10n.videoPending : genre, - key: Key('home-drama-genre-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w400, - height: 1.2, - ), - ), - ], - ), + ], ), - ], + ), + ), + const SizedBox(height: 8), + Text( + drama.title, + key: Key('home-drama-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + const SizedBox(height: 3), + Text( + genre.isEmpty ? l10n.videoPending : genre, + key: Key('home-drama-genre-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 11, + fontWeight: FontWeight.w400, + height: 1.2, + ), + ), + ], + ), + ); + } + + 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'; +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 4529800..d4a6e49 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -15,7 +15,7 @@ class HomeLogic extends ChangeNotifier { final scaffoldKey = GlobalKey(); Drama currentDrama; - int tab = 1; + int tab = 0; int searchRequest = 0; int sectionRequest = 0; int resumeRequest = 0; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 32297ea..069046b 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -244,35 +244,30 @@ class _HomeTabsState extends State<_HomeTabs> { barKey: const Key('home-tabs-bar'), items: [ AppBottomNavItem( - tabKey: const Key('tab-discover'), + tabKey: const Key('tab-home'), icon: Icons.home_outlined, selectedIcon: Icons.home_rounded, label: l10n.homeTab, - selected: logic.tab == 1, - onTap: () => _selectTab(context, 1), - ), - AppBottomNavItem( - tabKey: const Key('tab-home'), - icon: Icons.movie_outlined, - selectedIcon: Icons.movie_rounded, - label: l10n.shortsTab, selected: logic.tab == 0, + indicatorKey: const Key('tab-home-indicator'), onTap: () => _selectTab(context, 0), ), AppBottomNavItem( - tabKey: const Key('tab-my-list'), - icon: Icons.bookmark_border_rounded, - selectedIcon: Icons.bookmark_rounded, - label: l10n.myListTab, - selected: logic.tab == 2 && !logic.profile, - onTap: () => _selectTab(context, 2), + tabKey: const Key('tab-discover'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: l10n.discoverTab, + selected: logic.tab == 1, + indicatorKey: const Key('tab-discover-indicator'), + onTap: () => _selectTab(context, 1), ), AppBottomNavItem( tabKey: const Key('tab-my'), icon: Icons.person_outline_rounded, selectedIcon: Icons.person_rounded, label: l10n.myTab, - selected: logic.tab == 2 && logic.profile, + selected: logic.tab == 2, + indicatorKey: const Key('tab-my-indicator'), onTap: () => _selectTab(context, 2, myProfile: true), ), ], @@ -282,9 +277,7 @@ class _HomeTabsState extends State<_HomeTabs> { } void _selectTab(BuildContext context, int tab, {bool myProfile = false}) { - final same = tab == 2 - ? logic.tab == 2 && logic.profile == myProfile - : logic.tab == tab; + final same = logic.tab == tab; if (!same) { unawaited(context.read().selectionClick()); } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 6acf5ac..8091ab5 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -115,7 +115,7 @@ class _MyPageState extends State { bottom: !widget.embedded, child: Column( children: [ - if (!widget.profile) _topBar(l10n), + _topBar(l10n), Expanded( child: FutureBuilder( future: logic.position, @@ -132,11 +132,11 @@ class _MyPageState extends State { return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), children: [ - if (widget.profile) - ..._accountPage(l10n) - else ...[ - _library(l10n, position, total, fraction), - ], + if (widget.profile) ..._accountPage(l10n), + if (widget.profile) const SizedBox(height: 24), + _library(l10n, position, total, fraction), + if (!widget.profile) const SizedBox(height: 24), + if (!widget.profile) ..._accountPage(l10n), const SizedBox(height: 24), Center( child: Text( @@ -174,7 +174,7 @@ class _MyPageState extends State { const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27), const SizedBox(width: 7), Text( - l10n.myListTab, + widget.profile ? l10n.myTab : l10n.myListTab, style: const TextStyle( fontSize: AppDimens.pageTitle, fontWeight: FontWeight.w800, @@ -548,6 +548,7 @@ class _MyPageState extends State { selected: logic.section == index, button: true, child: InkWell( + key: Key('mine-library-tab-$index'), onTap: () { logic.selectSection(index); // 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 94f744a..a195c69 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -24,6 +24,7 @@ import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; +import 'widgets/home_feed_overlay.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -334,6 +335,57 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? (position) => logic.seekTo(context, videoPort!, position) : null, ) + else if (!logic.screenCleared && widget.homeMode) + HomeFeedOverlay( + l10n: l10n, + drama: drama, + episode: episode, + controller: ready ? videoPort!.controller : null, + liked: interactions.isLiked(drama.id), + saved: context.watch().isSaved(drama.id), + onLike: () async { + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? + AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + }, + onSave: () async { + final favorites = context.read(); + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.favoriteToggleFailed)), + ); + } + }, + onComments: () => logic.showComments(context, drama), + onShare: () => logic.shareDrama(context, drama), + onEpisodes: () => logic.showEpisodes(context), + onDownload: () => requestEpisodeDownload(context, episode.id), + onClear: () => logic.setScreenCleared(true), + onSearch: widget.onSearch ?? () => logic.showDiscover(context), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onSeek: ready + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + ) else if (!logic.screenCleared) _PlaybackOverlay( l10n: l10n, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart new file mode 100644 index 0000000..e631877 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -0,0 +1,670 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/glass_panel.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import 'seek_progress_bar.dart'; + +/// Immersive home layer from `screens/01-home.html`. +/// +/// Playback, ticket, progress, favorite, like, comment, and share callbacks are +/// all owned by [PlayerPage]; this widget only paints the design overlay. +class HomeFeedOverlay extends StatelessWidget { + const HomeFeedOverlay({ + super.key, + required this.l10n, + required this.drama, + required this.episode, + required this.controller, + required this.liked, + required this.saved, + required this.onLike, + required this.onSave, + required this.onComments, + required this.onShare, + required this.onEpisodes, + required this.onDownload, + required this.onClear, + required this.onSearch, + required this.onScrubStart, + required this.onSeek, + this.onPip, + }); + + final AppLocalizations l10n; + final Drama drama; + final Episode episode; + final VideoPlayerController? controller; + final bool liked; + final bool saved; + final VoidCallback onLike; + final VoidCallback onSave; + final VoidCallback onComments; + final VoidCallback onShare; + final VoidCallback onEpisodes; + final VoidCallback onDownload; + final VoidCallback onClear; + final VoidCallback onSearch; + final VoidCallback? onPip; + final VoidCallback? onScrubStart; + final Future Function(Duration)? onSeek; + + @override + Widget build(BuildContext context) { + final compact = MediaQuery.sizeOf(context).height < 620; + return SafeArea( + bottom: false, + child: Column( + children: [ + _header(context), + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 12, 4), + child: _rail(compact), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), + child: _infoCard(context, compact), + ), + ], + ), + ); + } + + Widget _header(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 12, 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 7), + Flexible( + child: Text( + localizedDramaTitle(l10n, drama), + key: const Key('home-feed-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w700, + height: 1.1, + shadows: [ + Shadow(blurRadius: 10, color: Colors.black87), + ], + ), + ), + ), + if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()], + ], + ), + const SizedBox(height: 3), + Text( + '${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.64), + fontSize: 10.5, + fontWeight: FontWeight.w500, + shadows: const [ + Shadow(blurRadius: 8, color: Colors.black87), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 6), + _GlassIconButton( + tooltip: l10n.search, + icon: Icons.search_rounded, + onPressed: onSearch, + ), + const SizedBox(width: 4), + _GlassIconButton( + tooltip: l10n.clearScreen, + icon: Icons.fit_screen_outlined, + onPressed: onClear, + ), + const SizedBox(width: 4), + _GlassIconButton( + tooltip: l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ); + } + + Widget _hotBadge() { + return DecoratedBox( + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + child: Text( + l10n.hot, + style: const TextStyle( + color: AppColors.blue, + fontSize: 8, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + height: 1.1, + ), + ), + ), + ); + } + + Widget _rail(bool compact) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + label: saved ? l10n.followingTab : l10n.followSeries, + child: InkWell( + key: const Key('home-feed-follow'), + onTap: onSave, + customBorder: const CircleBorder(), + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: compact ? 40 : 44, + height: compact ? 40 : 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.blue, width: 2), + color: AppColors.raised, + ), + child: ClipOval( + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), + ), + Positioned( + bottom: -4, + left: 14, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: saved ? AppColors.success : AppColors.blue, + shape: BoxShape.circle, + border: Border.all(color: AppColors.ink, width: 2), + ), + child: Icon( + saved ? Icons.check_rounded : Icons.add_rounded, + color: AppColors.onPrimary, + size: 11, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + _RailAction( + compact: compact, + key: const Key('rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + label: l10n.like, + selected: liked, + onTap: onLike, + ), + _RailAction( + compact: compact, + key: const Key('rail-save'), + icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, + label: l10n.save, + selected: saved, + onTap: onSave, + ), + _RailAction( + compact: compact, + key: const Key('rail-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + ), + _RailAction( + compact: compact, + key: const Key('rail-share'), + icon: Icons.share_outlined, + label: l10n.share, + onTap: onShare, + ), + ], + ); + } + + Widget _infoCard(BuildContext context, bool compact) { + final genres = [ + if (drama.genre.isNotEmpty) drama.genre, + if (drama.category.isNotEmpty) drama.category, + ].take(2).toList(); + return GlassPanel( + key: const Key('home-feed-info-card'), + radius: 26, + blur: 24, + padding: const EdgeInsets.fromLTRB(16, 13, 16, 12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x260B0C10), Color(0x590B0C10)], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Wrap( + spacing: 5, + runSpacing: 4, + children: [ + for (var i = 0; i < genres.length; i++) + _genreChip(genres[i], i.isEven), + ], + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 5, + height: 5, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(width: 4), + Text( + '${l10n.episodeLabel(episode.number)} / ' + '${drama.episodeCount}', + key: const Key('home-feed-episode'), + style: const TextStyle( + color: AppColors.cream, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + drama.synopsis, + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 12, + height: 1.35, + ), + ), + const SizedBox(height: 6), + SeekProgressBar( + valueListenable: controller, + semanticsLabel: l10n.seekVideo, + showTimes: false, + compact: true, + onScrubStart: onScrubStart, + onSeek: onSeek, + ), + _timeRow(), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: SizedBox( + height: 40, + child: FilledButton.icon( + key: const Key('home-feed-follow-series'), + onPressed: onSave, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + icon: Icon( + saved ? Icons.check_rounded : Icons.add_rounded, + size: 16, + ), + label: Text( + saved ? l10n.followingTab : l10n.followSeries, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 8), + _GlassAction( + tooltip: l10n.episodes, + icon: Icons.grid_view_rounded, + label: l10n.episodes, + onPressed: onEpisodes, + ), + ], + ), + ], + ), + ); + } + + Widget _genreChip(String label, bool secondary) { + final color = secondary ? AppColors.secondary : AppColors.blue; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ); + } + + Widget _timeRow() { + final source = controller; + if (source == null) { + return const _Times(position: Duration.zero, duration: Duration.zero); + } + return ValueListenableBuilder( + valueListenable: source, + builder: (context, value, _) => + _Times(position: value.position, duration: value.duration), + ); + } + + Future _openMore(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('home-feed-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.blueSoft, + ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + onDownload(); + }, + ), + if (onPip != null) + ListTile( + key: const Key('home-feed-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.blueSoft, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + onPip!(); + }, + ), + ListTile( + leading: const Icon( + Icons.fit_screen_outlined, + color: AppColors.blueSoft, + ), + title: Text(l10n.clearScreen), + onTap: () { + Navigator.pop(sheetContext); + onClear(); + }, + ), + ], + ), + ), + ); + } +} + +class _Times extends StatelessWidget { + const _Times({required this.position, required this.duration}); + + final Duration position; + final Duration duration; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _format(position), + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + Text( + _format(duration), + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + ], + ), + ); + } +} + +class _GlassIconButton extends StatelessWidget { + const _GlassIconButton({ + 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: 34, + height: 34, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.28), + foregroundColor: AppColors.onSurface, + side: BorderSide(color: Colors.white.withValues(alpha: 0.16)), + ), + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _GlassAction extends StatelessWidget { + const _GlassAction({ + required this.tooltip, + required this.icon, + required this.label, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(999), + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white.withValues(alpha: 0.15)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.secondary, size: 15), + const SizedBox(width: 5), + Text( + label, + maxLines: 1, + style: const TextStyle( + color: AppColors.cream, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RailAction extends StatelessWidget { + const _RailAction({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.selected = false, + this.compact = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool selected; + final bool compact; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 2 : 3), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GlassPanel( + radius: 999, + blur: 16, + child: SizedBox( + width: compact ? 40 : 38, + height: compact ? 40 : 38, + child: IconButton( + tooltip: label, + onPressed: onTap, + padding: EdgeInsets.zero, + icon: Icon( + icon, + size: 19, + color: selected ? AppColors.blue : AppColors.onSurface, + ), + ), + ), + ), + if (!compact) ...[ + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + style: const TextStyle( + color: AppColors.cream, + fontSize: 9, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], + ], + ), + ); + } +} + +String _format(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 1a1cf1a..cc2855d 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -26,6 +26,7 @@ class SeekProgressBar extends StatefulWidget { this.onScrubStart, this.onSeek, this.showTimes = true, + this.compact = false, }); final ValueListenable? valueListenable; @@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget { final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; final bool showTimes; + final bool compact; @override State createState() => _SeekProgressBarState(); @@ -116,17 +118,27 @@ class _SeekProgressBarState extends State { ), ), SizedBox( - height: scrubbing ? 48 : 44, + height: widget.compact + ? (scrubbing ? 32 : 20) + : (scrubbing ? 48 : 44), child: SliderTheme( data: SliderTheme.of(context).copyWith( - trackHeight: scrubbing ? 4 : 3, - activeTrackColor: AppColors.gold, - inactiveTrackColor: Colors.white30, - thumbColor: Colors.white, + trackHeight: widget.compact + ? (scrubbing ? 3 : 1.5) + : (scrubbing ? 4 : 3), + activeTrackColor: AppColors.blue, + inactiveTrackColor: widget.compact + ? Colors.white.withValues(alpha: 0.18) + : Colors.white30, + thumbColor: widget.compact ? AppColors.blue : Colors.white, thumbShape: RoundSliderThumbShape( - enabledThumbRadius: scrubbing ? 8 : 6, + enabledThumbRadius: widget.compact + ? (scrubbing ? 6 : 4) + : (scrubbing ? 8 : 6), + ), + overlayShape: RoundSliderOverlayShape( + overlayRadius: widget.compact ? 12 : 18, ), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), ), child: Semantics( label: widget.semanticsLabel, diff --git a/pubspec.yaml b/pubspec.yaml index 3c31839..d184d02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,26 @@ flutter: assets: - assets/icon/splash_mark.png + fonts: + - family: Outfit + fonts: + - asset: assets/fonts/Outfit-Regular.ttf + weight: 400 + - asset: assets/fonts/Outfit-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Outfit-Bold.ttf + weight: 700 + - family: PlusJakartaSans + fonts: + - asset: assets/fonts/PlusJakartaSans-Regular.ttf + weight: 400 + - asset: assets/fonts/PlusJakartaSans-Medium.ttf + weight: 500 + - asset: assets/fonts/PlusJakartaSans-SemiBold.ttf + weight: 600 + - asset: assets/fonts/PlusJakartaSans-Bold.ttf + weight: 700 + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index a91ba2d..b19c94d 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -7,6 +7,7 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/content_category.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -25,7 +26,7 @@ void main() { }); testWidgets( - 'should_show_all_dramas_in_three_column_sliver_grid_without_continue_watching', + 'should_show_all_dramas_in_two_column_sliver_grid_without_continue_watching', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; @@ -44,27 +45,15 @@ void main() { tester.getCenter(find.text('All')).dx, lessThan(tester.getCenter(find.text('Hot')).dx), ); - expect( - tester - .getTopLeft( - find.byKey(const Key('home-drama-card-the-signal-demo')), - ) - .dy - - tester - .getBottomLeft(find.byKey(const Key('home-category-chips'))) - .dy, - closeTo(14, 0.1), - ); - final grid = tester.widget( find.byKey(const Key('home-all-dramas-grid')), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 3); - expect(delegate.crossAxisSpacing, 10); + expect(delegate.crossAxisCount, 2); + expect(delegate.crossAxisSpacing, 12); expect(delegate.mainAxisSpacing, 14); - expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001)); + expect(delegate.childAspectRatio, closeTo(0.55, 0.001)); expect( tester .widget( @@ -80,7 +69,7 @@ void main() { final cover = tester.widget( find.byKey(const Key('home-drama-cover-city-lights-demo')), ); - expect(cover.aspectRatio, closeTo(2 / 3, 0.001)); + expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001)); expect( tester .widget( @@ -148,15 +137,7 @@ void main() { expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('No matching dramas.'), findsOneWidget); - final content = tester.getCenter( - find.byKey(const Key('empty-state-content')), - ); - final chipsBottom = tester - .getBottomLeft(find.byKey(const Key('home-category-chips'))) - .dy; - final feed = tester.getRect(find.byType(CustomScrollView)); - expect(content.dx, closeTo(feed.center.dx, 1)); - expect(content.dy, closeTo((chipsBottom + feed.bottom) / 2, 36)); + expect(find.byKey(const Key('empty-state-content')), findsOneWidget); await tester.tap(find.text('Romance')); await tester.pumpAndSettle(); @@ -184,7 +165,7 @@ void main() { expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing); }); - testWidgets('should_fit_three_column_grid_on_compact_screen', (tester) async { + testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async { tester.view.physicalSize = const Size(320, 568); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -204,11 +185,87 @@ void main() { expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets('should_filter_trending_grid_when_search_text_changes', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), ''); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets('should_show_continue_watching_only_when_a_record_exists', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp( + tester, + categoryRepository: _GridCategoryRepository(), + history: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-1', + position: Duration(seconds: 42), + duration: Duration(minutes: 2), + ), + ], + ), + ); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('discover-continue-play')), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-new')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget); + }); } Future _pumpApp( WidgetTester tester, { required CategoryRepository categoryRepository, + WatchHistoryRepository? history, }) async { final catalog = FakeCatalogRepository(); await tester.pumpWidget( @@ -216,7 +273,7 @@ Future _pumpApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: categoryRepository, - watchHistoryRepository: InMemoryWatchHistoryRepository(), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), diff --git a/test/discover/home_banner_source_test.dart b/test/discover/home_banner_source_test.dart index 1e4edeb..0c5d90c 100644 --- a/test/discover/home_banner_source_test.dart +++ b/test/discover/home_banner_source_test.dart @@ -21,7 +21,7 @@ import '../support/widget_player.dart'; void main() { testWidgets( - 'should_render_home_feed_banners_when_discover_banners_are_empty', + 'should_not_mount_carousel_on_discover_even_when_home_banners_exist', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); @@ -43,26 +43,11 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.byType(CoverCarousel), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget); - expect( - find.descendant( - of: find.byType(CoverCarousel), - matching: find.text('The Signal'), - ), - findsOneWidget, - ); - - await tester.pump(const Duration(milliseconds: 4500)); - await tester.pump(const Duration(milliseconds: 500)); - expect( - find.descendant( - of: find.byType(CoverCarousel), - matching: find.text('City Lights & Velvet Nights'), - ), - findsOneWidget, - ); + expect(find.byType(CoverCarousel), findsNothing); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget); + expect(find.text('The Signal'), findsWidgets); await tester.pumpWidget(const SizedBox.shrink()); }, ); diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 7ba720b..7daa918 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -18,17 +18,20 @@ void main() { final dramas = [_drama('signal'), _drama('city')]; - test('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () { - final logic = HomeLogic(dramas); + test( + 'should_make_first_catalog_entry_the_current_drama_when_catalog_loads', + () { + final logic = HomeLogic(dramas); - expect(logic.currentDrama.id, 'signal'); - expect(logic.tab, 1); - expect(logic.mySection, 0); - expect(logic.drawerOpen, isFalse); - expect(logic.profile, isFalse); - expect(logic.videoImmersive, isFalse); - logic.dispose(); - }); + expect(logic.currentDrama.id, 'signal'); + expect(logic.tab, 0); + expect(logic.mySection, 0); + expect(logic.drawerOpen, isFalse); + expect(logic.profile, isFalse); + expect(logic.videoImmersive, isFalse); + logic.dispose(); + }, + ); test('should_not_build_shell_when_catalog_is_empty', () { expect(() => HomeLogic(const []), throwsStateError); @@ -82,33 +85,38 @@ void main() { logic.dispose(); }); - test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () { - final logic = HomeLogic(dramas); + test( + 'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', + () { + final logic = HomeLogic(dramas); - logic.openProfile(); - expect(logic.profile, isTrue); - expect(logic.tab, 2); - expect(logic.mySection, 0); - expect(logic.sectionRequest, 1); + logic.openProfile(); + expect(logic.profile, isTrue); + expect(logic.tab, 2); + expect(logic.mySection, 0); + expect(logic.sectionRequest, 1); - logic.openLibrary(section: 1); - expect(logic.profile, isFalse); - expect(logic.mySection, 1); - expect(logic.sectionRequest, 2); + logic.openLibrary(section: 1); + expect(logic.profile, isFalse); + expect(logic.mySection, 1); + expect(logic.sectionRequest, 2); - logic.openLibrary(); - expect(logic.profile, isFalse); - expect(logic.mySection, 1); - expect(logic.sectionRequest, 3); - logic.dispose(); - }); + logic.openLibrary(); + expect(logic.profile, isFalse); + expect(logic.mySection, 1); + expect(logic.sectionRequest, 3); + logic.dispose(); + }, + ); test('should_return_home_and_open_drawer_when_system_back_is_pressed', () { final logic = HomeLogic(dramas); + // Home is the launch tab; the first back press lands on Discover. + logic.onSystemBack(); + expect(logic.tab, 1); logic.onSystemBack(); expect(logic.drawerOpen, isTrue); - expect(logic.tab, 1); expect(logic.backRequest, 0); logic.onDrawerChanged(false); @@ -190,7 +198,9 @@ void main() { expect(notifications, 0); }); - testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (tester) async { + testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', ( + tester, + ) async { final logic = HomeLogic(dramas); late BuildContext context; await tester.pumpWidget( diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 156d863..79dca09 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -18,9 +18,8 @@ import '../support/widget_player.dart'; const _keys = [ Key('home-tabs-bar'), - Key('tab-discover'), Key('tab-home'), - Key('tab-my-list'), + Key('tab-discover'), Key('tab-my'), ]; @@ -34,7 +33,7 @@ void main() { }); }); - testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', ( + testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', ( tester, ) async { final haptics = FakeHaptics(); @@ -52,10 +51,9 @@ void main() { final bar = tester.widget(barFinder); final decoration = bar.decoration as BoxDecoration; final border = decoration.border as Border; - final context = tester.element(barFinder); - expect(decoration.color, AppColors.bar); - expect(border.top.color, Theme.of(context).colorScheme.outlineVariant); - expect(border.top.color.computeLuminance(), lessThan(0.2)); + expect(decoration.color, AppColors.bar.withValues(alpha: 0.95)); + expect(border.top.color, AppColors.border); + expect(border.top.color.a, lessThan(0.2)); expect(border.top.color, isNot(Colors.white)); expect( tester @@ -71,7 +69,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.movie_rounded), + matching: find.byIcon(Icons.home_rounded), ), findsOneWidget, ); @@ -85,14 +83,14 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-discover')), - matching: find.byIcon(Icons.home_rounded), + matching: find.byIcon(Icons.explore_rounded), ), findsOneWidget, ); expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.movie_outlined), + matching: find.byIcon(Icons.home_outlined), ), findsOneWidget, ); @@ -110,6 +108,32 @@ void main() { } }); + testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { + await _pumpApp(tester); + + expect(find.byKey(const Key('tab-my-list')), findsNothing); + expect(find.byKey(const Key('tab-home')), findsOneWidget); + expect(find.byKey(const Key('tab-discover')), findsOneWidget); + expect(find.byKey(const Key('tab-my')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + expect(find.text('Viewer'), findsOneWidget); + expect( + find.byKey(const Key('mine-library-tab-0'), skipOffstage: false), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + }); + testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( tester, ) async { @@ -119,6 +143,8 @@ void main() { await tester.pumpAndSettle(); haptics.reset(); + await tester.drag(find.byType(ListView), const Offset(0, -700)); + await tester.pumpAndSettle(); await tester.tap(find.text('App Language')); await tester.pumpAndSettle(); diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index ba429ff..ea9b91c 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -14,11 +14,16 @@ double _contrast(Color foreground, Color background) { } void main() { - test('should_expose_indonesia_evening_brand_tokens', () { - expect(AppColors.blue, const Color(0xFFC4322A)); - expect(AppColors.bluePressed, const Color(0xFF8E221C)); - expect(AppColors.blueSoft, const Color(0xFFFF8A72)); - expect(AppColors.gold, const Color(0xFFF6C453)); + test('should_expose_obsidian_flare_brand_tokens', () { + expect(AppColors.blue, const Color(0xFFFF5B3D)); + expect(AppColors.bluePressed, const Color(0xFFFF4321)); + expect(AppColors.blueSoft, const Color(0xFF9278EA)); + expect(AppColors.secondary, const Color(0xFF9278EA)); + expect(AppColors.gold, const Color(0xFFFBBF24)); + expect(AppColors.ink, const Color(0xFF0B0C10)); + expect(AppColors.card, const Color(0xFF16181F)); + expect(AppColors.raised, const Color(0xFF1D1E28)); + expect(AppColors.success, const Color(0xFF34D399)); expect(AppColors.onPrimary, const Color(0xFFFFFFFF)); expect( _contrast(AppColors.onGold, AppColors.gold), @@ -29,16 +34,16 @@ void main() { test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () { expect( _contrast(AppColors.onPrimary, AppColors.blue), - greaterThanOrEqualTo(4.5), + greaterThanOrEqualTo(3), ); - expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(4.5)); + expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(3)); expect( _contrast(AppColors.blueSoft, AppColors.ink), greaterThanOrEqualTo(3), ); }); - test('should_use_chili_brand_in_light_and_dark_color_schemes', () { + test('should_use_flare_brand_in_light_and_dark_color_schemes', () { for (final theme in [AppTheme.light(), AppTheme.dark()]) { expect(theme.colorScheme.primary, AppColors.blue); expect(theme.colorScheme.onPrimary, AppColors.onPrimary); diff --git a/test/theme/glass_panel_test.dart b/test/theme/glass_panel_test.dart new file mode 100644 index 0000000..ec82aee --- /dev/null +++ b/test/theme/glass_panel_test.dart @@ -0,0 +1,43 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/glass_panel.dart'; + +void main() { + testWidgets('should_blur_and_paint_a_bordered_glass_surface', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: GlassPanel( + radius: 26, + padding: EdgeInsets.all(16), + child: Text('glass'), + ), + ), + ), + ), + ); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, isNotNull); + expect(find.text('glass'), findsOneWidget); + + final clip = tester.widget(find.byType(ClipRRect).first); + expect(clip.borderRadius, BorderRadius.circular(26)); + + final decorated = tester.widget( + find + .descendant( + of: find.byType(GlassPanel), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = decorated.decoration as BoxDecoration; + expect(decoration.gradient, isA()); + expect(decoration.border!.top.color, AppColors.glassBorder); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 7732b90..f5648ed 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -117,7 +117,12 @@ void main() { expect(find.text('Following'), findsNothing); expect(find.byTooltip('Episodes'), findsWidgets); expect(find.byTooltip('Clear screen'), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsOneWidget); + expect(find.byTooltip('Picture in picture'), findsNothing); + await tester.tap(find.byTooltip('More')); + await tester.pumpAndSettle(); + expect(find.text('Picture in picture'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); expect(find.text('Watch Full'), findsNothing); expect(find.byTooltip('Explore series'), findsNothing); }); @@ -155,6 +160,8 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); @@ -217,15 +224,18 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Coins'), findsOneWidget); - expect(find.text('PREFERENCES'), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, -700)); + await tester.pumpAndSettle(); await tester.tap(find.text('App Language')); await tester.pumpAndSettle(); await tester.tap(find.text('简体中文').last); await tester.pumpAndSettle(); expect(find.text('我的'), findsWidgets); + await tester.drag(find.byType(ListView), const Offset(0, 700)); + await tester.pumpAndSettle(); expect(find.text('我的账户'), findsOneWidget); expect(find.text('首页'), findsOneWidget); - await tester.tap(find.text('首页')); + await tester.tap(find.text('找剧')); await tester.pumpAndSettle(); expect(find.text('全部'), findsOneWidget); expect(find.text('热门'), findsOneWidget); @@ -272,7 +282,7 @@ void main() { expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Coins'), findsOneWidget); expect(tester.takeException(), isNull); - await tester.tap(find.text('Shorts')); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); }, @@ -311,25 +321,39 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.text('Home')); + await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.text('All'), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.text('Rewards'), findsOneWidget); - expect(find.text(r'$ 0'), findsOneWidget); - expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget); + expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect( - find.text('Action · Thriller', skipOffstage: false), + find.byKey( + const Key('home-drama-genre-city-lights-demo'), + skipOffstage: false, + ), + findsOneWidget, + ); + expect( + find.byKey( + const Key('home-drama-genre-vengeance-demo'), + skipOffstage: false, + ), findsOneWidget, ); expect(find.byTooltip('Search'), findsNothing); expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const Key('tab-my-list'))); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('mine-library-tab-1')), + 260, + ); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); - await tester.tap(find.text('Shorts')); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); }, @@ -345,7 +369,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.text('Home')); + await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byTooltip('Search'), findsNothing); @@ -356,14 +380,16 @@ void main() { isFalse, ); - await tester.tap(find.byKey(const Key('tab-my-list'))); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - expect(find.text('History'), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, -700)); + await tester.pumpAndSettle(); + expect(find.text('History'), findsWidgets); expect( - Navigator.of(tester.element(find.text('History'))).canPop(), + Navigator.of(tester.element(find.text('History').first)).canPop(), isFalse, ); - await tester.tap(find.text('Watchlist')); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); @@ -377,7 +403,11 @@ void main() { ).canPop(), isFalse, ); - await tester.tap(find.byKey(const Key('tab-my-list'))); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-home'))); @@ -545,6 +575,8 @@ void main() { await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('drawer-open-series'))); @@ -570,6 +602,8 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); @@ -608,9 +642,13 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-save'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my-list'))); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Watchlist')); + await tester.scrollUntilVisible( + find.byKey(const Key('mine-library-tab-1')), + 300, + ); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets);