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/config/server_endpoint.dart b/lib/core/config/server_endpoint.dart index 37355fc..332eb6b 100644 --- a/lib/core/config/server_endpoint.dart +++ b/lib/core/config/server_endpoint.dart @@ -39,11 +39,11 @@ abstract final class ServerEndpoint { /// In release builds this returns immediately without reading preferences. static Future load({ bool debug = kDebugMode, - SharedPreferences? preferences, + SharedPreferencesAsync? preferences, }) async { if (!debug) return; try { - final prefs = preferences ?? await SharedPreferences.getInstance(); + final prefs = preferences ?? SharedPreferencesAsync(); final stored = await prefs.getString(preferenceKey); AppConfig.applyRuntimeBaseUrl( resolve( @@ -60,12 +60,12 @@ abstract final class ServerEndpoint { /// Persists a normalized runtime endpoint. static Future save( String raw, { - SharedPreferences? preferences, + SharedPreferencesAsync? preferences, }) async { final normalized = normalize(raw); if (normalized == null) return false; try { - await (preferences ?? await SharedPreferences.getInstance()).setString( + await (preferences ?? SharedPreferencesAsync()).setString( preferenceKey, normalized, ); @@ -76,9 +76,9 @@ abstract final class ServerEndpoint { } /// Removes the persisted runtime endpoint. - static Future clear({SharedPreferences? preferences}) async { + static Future clear({SharedPreferencesAsync? preferences}) async { try { - await (preferences ?? await SharedPreferences.getInstance()).remove(preferenceKey); + await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey); } catch (_) { // Clearing an optional developer override is best effort. } diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart index e03cc6b..1586759 100644 --- a/lib/core/data/services/auth_session_store.dart +++ b/lib/core/data/services/auth_session_store.dart @@ -23,7 +23,7 @@ class AuthSessionStore { Future deviceId() async { if (_cachedDeviceId != null) return _cachedDeviceId!; - final prefs = await SharedPreferences.getInstance(); + final prefs = SharedPreferencesAsync(); var value = await prefs.getString(_kDeviceId) ?? ''; if (value.isEmpty) { final random = Random(); @@ -37,7 +37,7 @@ class AuthSessionStore { } Future read() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = SharedPreferencesAsync(); final accessToken = await prefs.getString(_kAccessToken) ?? ''; final refreshToken = await prefs.getString(_kRefreshToken) ?? ''; if (accessToken.isEmpty || refreshToken.isEmpty) return null; @@ -58,14 +58,14 @@ class AuthSessionStore { } Future save(AuthSession session) async { - final prefs = await SharedPreferences.getInstance(); + final prefs = SharedPreferencesAsync(); await prefs.setString(_kAccessToken, session.accessToken); await prefs.setString(_kRefreshToken, session.refreshToken); await prefs.setString(_kUser, jsonEncode(session.user.toJson())); } Future clear() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = SharedPreferencesAsync(); await prefs.remove(_kAccessToken); await prefs.remove(_kRefreshToken); await prefs.remove(_kUser); diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 1f3e855..da46392 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,46 @@ 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(0xFF0D0D11); /// Cards lifted off [ink]. - static const card = Color(0xFF241C19); + static const card = Color(0xFF1B1C21); - /// Chips and nested rows. - static const raised = Color(0xFF322824); + /// Alternate card surface from the mobile preview. + static const cardAlt = Color(0xFF1C1D21); - /// Primary text on dark surfaces. - static const cream = Color(0xFFF8F1EA); + /// Floating surfaces, inputs, and nested rows. + static const raised = Color(0xFF1C1D21); + static const quick = Color(0xFF202126); + static const input = Color(0xFF25252A); + static const goldCard = Color(0xFF201B1E); - 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); + /// Body text on dark surfaces. + static const cream = Color(0xFFE6E1E3); + + /// Titles and high-emphasis text. + static const onSurface = Color(0xFFFFFFFF); + + /// Secondary text. + static const muted = Color(0xFFD2CDD0); + + /// Weak hints and timestamps. + static const subtle = Color(0xFF9A96A3); + + /// Soft orange used for continue labels and secondary times. + static const accent = Color(0xFFFFA490); + + /// 8% white border used by cards and inputs. + static const border = Color(0x14FFFFFF); + + /// 14% white border used by glass controls. + static const glassBorder = Color(0x24FFFFFF); + + static const outlineDark = Color(0xFF2A2C36); + static const outlineVariantDark = Color(0xFF242329); + 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..9bc0cb6 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; @@ -9,12 +9,17 @@ abstract final class AppDimens { static const double spaceXl = 24; static const double pageHorizontal = 16; - static const double cardRadius = 16; + static const double cardRadius = 12; static const double authCardRadius = 20; static const double cardPadding = 16; - static const double innerRadius = 12; + static const double innerRadius = 10; static const double innerPadding = 12; static const double pillRadius = 20; + static const double glassSmallRadius = 12; + static const double glassRadius = 14; + static const double glassCardRadius = 14; + static const double buttonRadius = 9; + static const double coverRadius = 10; static const double caption = 12; static const double emptyStateSubtitle = 12.5; @@ -24,16 +29,29 @@ abstract final class AppDimens { static const double sectionTitle = 16; static const double pageTitle = 20; static const double detailHeadline = 24; - static const double navLabel = 11; + static const double detailHeadlineSmall = 18; + static const double previewCardTitle = 13; + static const double previewSectionTitle = 14; + static const double chipFont = 10; + static const double navLabel = 9; static const double buttonHeight = 48; static const double primaryButtonHeight = 52; + static const double buttonCompact = 32; + static const double buttonSmall = 34; + static const double buttonMedium = 40; + static const double buttonLarge = 44; static const double tapTarget = 44; - static const double navHeight = 64; - static const double navIcon = 22; - static const double progressHeight = 4; - static const double progressRadius = 4; + static const double navHeight = 54; + static const double navIcon = 17; + static const double progressHeight = 3; + static const double progressRadius = 2; + static const double progressLineHeight = 3; + + static const double searchHeight = 44; + static const double navDot = 3; + 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..898d032 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'; @@ -24,7 +26,7 @@ class AppBottomNavItem { final bool selected; } -/// 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 +40,25 @@ 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.outlineVariantDark), + ), + ), + child: Row( + children: [for (final item in items) _NavDestination(item: item)], + ), ), ), - child: Row( - children: [for (final item in items) _NavDestination(item: item)], - ), ), ); } @@ -71,25 +78,20 @@ 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), @@ -100,7 +102,10 @@ 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, ), ), ], diff --git a/lib/core/widgets/glass_panel.dart b/lib/core/widgets/glass_panel.dart new file mode 100644 index 0000000..02d144c --- /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 = 10, + 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..b710ee7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15,6 +15,14 @@ "discoverTab": "Discover", "searchDiscover": "Search dramas, actors, genres...", "featured": "Featured", + "recommended": "Recommended", + "fruitShortsBrand": "FruitShorts", + "authLandingSubtitle": "Sign in to sync progress, favorites and coins", + "comingSoon": "Coming soon", + "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", + "authContinueGoogle": "Continue with Google", + "authPhoneSignIn": "Phone sign in", + "authEmailSignIn": "Email sign in", "suspense": "Suspense", "romance": "Romance", "actionGenre": "Action", @@ -23,6 +31,30 @@ "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", + "seasonOneOriginalRelease": "Season 1 • Original Release", + "startWatching": "Start Watching", + "showLess": "Less", + "views": "views", + "episodeOfTotal": "Episode {current} / {total}", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "clearScreen": "Clear screen", + "exitClearScreen": "Exit clear screen", + "playbackSpeed": "Playback speed", + "minutesAgo": "{count} min ago", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", @@ -44,7 +76,7 @@ }, "resume": "Resume", "history": "History", - "watchlist": "Watchlist", + "watchlist": "Favorites", "likedTab": "Liked", "all": "All", "inProgress": "In Progress", @@ -113,8 +145,6 @@ "reportFailed": "Could not submit report.", "shareFailed": "Could not open the share sheet.", "nowPlaying": "Playing now", - "watchFullscreen": "Watch in landscape", - "exitFullscreen": "Exit fullscreen", "pictureInPicture": "Picture in picture", "pipUnavailable": "Picture in picture is unavailable on this device", "originalTag": "Original", @@ -243,8 +273,6 @@ "myListTab": "My List", "watchFull": "Watch Full", "swipeForNext": "Swipe up for next", - "clearScreen": "Clear screen", - "exitClearScreen": "Exit clear screen", "logIn": "Log in", "logInHint": "Keep your coins and list", "rewards": "Rewards", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5e04b7c..6071753 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -12,9 +12,17 @@ "openQuickMenu": "Buka menu cepat", "quickAccess": "Akses cepat", "currentSeries": "Serial saat ini", - "discoverTab": "Jelajahi", + "discoverTab": "Temukan", "searchDiscover": "Cari drama, aktor, genre...", "featured": "Unggulan", + "recommended": "Rekomendasi", + "fruitShortsBrand": "FruitShorts", + "authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin", + "comingSoon": "Segera hadir", + "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", + "authContinueGoogle": "Lanjutkan dengan Google", + "authPhoneSignIn": "Masuk dengan ponsel", + "authEmailSignIn": "Masuk dengan email", "suspense": "Thriller", "romance": "Romantis", "actionGenre": "Aksi", @@ -23,6 +31,30 @@ "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", + "seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal", + "startWatching": "Mulai Menonton", + "showLess": "Lebih sedikit", + "views": "tayangan", + "episodeOfTotal": "Episode {current} / {total}", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "clearScreen": "Bersihkan layar", + "exitClearScreen": "Keluar dari layar bersih", + "playbackSpeed": "Kecepatan putar", + "minutesAgo": "{count} mnt lalu", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", @@ -113,8 +145,6 @@ "reportFailed": "Laporan tidak dapat dikirim.", "shareFailed": "Tidak bisa membuka menu berbagi.", "nowPlaying": "Sedang diputar", - "watchFullscreen": "Tonton mode lanskap", - "exitFullscreen": "Keluar dari layar penuh", "pictureInPicture": "Gambar dalam gambar", "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", "originalTag": "Original", @@ -243,8 +273,6 @@ "myListTab": "Daftar Saya", "watchFull": "Tonton Penuh", "swipeForNext": "Geser ke atas untuk lanjut", - "clearScreen": "Bersihkan layar", - "exitClearScreen": "Keluar dari mode bersih", "logIn": "Masuk", "logInHint": "Simpan koin dan daftar kamu", "rewards": "Hadiah", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 70ae96e..63446d1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -190,6 +190,54 @@ abstract class AppLocalizations { /// **'Featured'** String get featured; + /// No description provided for @recommended. + /// + /// In en, this message translates to: + /// **'Recommended'** + String get recommended; + + /// No description provided for @fruitShortsBrand. + /// + /// In en, this message translates to: + /// **'FruitShorts'** + String get fruitShortsBrand; + + /// No description provided for @authLandingSubtitle. + /// + /// In en, this message translates to: + /// **'Sign in to sync progress, favorites and coins'** + String get authLandingSubtitle; + + /// No description provided for @comingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get comingSoon; + + /// No description provided for @authLegal. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to the Terms of Service and Privacy Policy.'** + String get authLegal; + + /// No description provided for @authContinueGoogle. + /// + /// In en, this message translates to: + /// **'Continue with Google'** + String get authContinueGoogle; + + /// No description provided for @authPhoneSignIn. + /// + /// In en, this message translates to: + /// **'Phone sign in'** + String get authPhoneSignIn; + + /// No description provided for @authEmailSignIn. + /// + /// In en, this message translates to: + /// **'Email sign in'** + String get authEmailSignIn; + /// No description provided for @suspense. /// /// In en, this message translates to: @@ -238,6 +286,84 @@ 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 @seasonOneOriginalRelease. + /// + /// In en, this message translates to: + /// **'Season 1 • Original Release'** + String get seasonOneOriginalRelease; + + /// No description provided for @startWatching. + /// + /// In en, this message translates to: + /// **'Start Watching'** + String get startWatching; + + /// No description provided for @showLess. + /// + /// In en, this message translates to: + /// **'Less'** + String get showLess; + + /// No description provided for @views. + /// + /// In en, this message translates to: + /// **'views'** + String get views; + + /// No description provided for @episodeOfTotal. + /// + /// In en, this message translates to: + /// **'Episode {current} / {total}'** + String episodeOfTotal(int current, int total); + + /// No description provided for @clearScreen. + /// + /// In en, this message translates to: + /// **'Clear screen'** + String get clearScreen; + + /// No description provided for @exitClearScreen. + /// + /// In en, this message translates to: + /// **'Exit clear screen'** + String get exitClearScreen; + + /// No description provided for @playbackSpeed. + /// + /// In en, this message translates to: + /// **'Playback speed'** + String get playbackSpeed; + + /// No description provided for @minutesAgo. + /// + /// In en, this message translates to: + /// **'{count} min ago'** + String minutesAgo(int count); + /// No description provided for @artworkOnly. /// /// In en, this message translates to: @@ -325,7 +451,7 @@ abstract class AppLocalizations { /// No description provided for @watchlist. /// /// In en, this message translates to: - /// **'Watchlist'** + /// **'Favorites'** String get watchlist; /// No description provided for @likedTab. @@ -664,18 +790,6 @@ abstract class AppLocalizations { /// **'Playing now'** String get nowPlaying; - /// No description provided for @watchFullscreen. - /// - /// In en, this message translates to: - /// **'Watch in landscape'** - String get watchFullscreen; - - /// No description provided for @exitFullscreen. - /// - /// In en, this message translates to: - /// **'Exit fullscreen'** - String get exitFullscreen; - /// No description provided for @pictureInPicture. /// /// In en, this message translates to: @@ -1114,18 +1228,6 @@ abstract class AppLocalizations { /// **'Swipe up for next'** String get swipeForNext; - /// No description provided for @clearScreen. - /// - /// In en, this message translates to: - /// **'Clear screen'** - String get clearScreen; - - /// No description provided for @exitClearScreen. - /// - /// In en, this message translates to: - /// **'Exit clear screen'** - String get exitClearScreen; - /// No description provided for @logIn. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 48921e6..f777db2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -54,6 +54,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get featured => 'Featured'; + @override + String get recommended => 'Recommended'; + + @override + String get fruitShortsBrand => 'FruitShorts'; + + @override + String get authLandingSubtitle => + 'Sign in to sync progress, favorites and coins'; + + @override + String get comingSoon => 'Coming soon'; + + @override + String get authLegal => + 'By continuing, you agree to the Terms of Service and Privacy Policy.'; + + @override + String get authContinueGoogle => 'Continue with Google'; + + @override + String get authPhoneSignIn => 'Phone sign in'; + + @override + String get authEmailSignIn => 'Email sign in'; + @override String get suspense => 'Suspense'; @@ -78,6 +104,49 @@ 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 seasonOneOriginalRelease => 'Season 1 • Original Release'; + + @override + String get startWatching => 'Start Watching'; + + @override + String get showLess => 'Less'; + + @override + String get views => 'views'; + + @override + String episodeOfTotal(int current, int total) { + return 'Episode $current / $total'; + } + + @override + String get clearScreen => 'Clear screen'; + + @override + String get exitClearScreen => 'Exit clear screen'; + + @override + String get playbackSpeed => 'Playback speed'; + + @override + String minutesAgo(int count) { + return '$count min ago'; + } + @override String get artworkOnly => 'Artwork'; @@ -125,7 +194,7 @@ class AppLocalizationsEn extends AppLocalizations { String get history => 'History'; @override - String get watchlist => 'Watchlist'; + String get watchlist => 'Favorites'; @override String get likedTab => 'Liked'; @@ -300,12 +369,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nowPlaying => 'Playing now'; - @override - String get watchFullscreen => 'Watch in landscape'; - - @override - String get exitFullscreen => 'Exit fullscreen'; - @override String get pictureInPicture => 'Picture in picture'; @@ -544,12 +607,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get swipeForNext => 'Swipe up for next'; - @override - String get clearScreen => 'Clear screen'; - - @override - String get exitClearScreen => 'Exit clear screen'; - @override String get logIn => 'Log in'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 5bb2fd7..a417c57 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -46,7 +46,7 @@ class AppLocalizationsId extends AppLocalizations { String get currentSeries => 'Serial saat ini'; @override - String get discoverTab => 'Jelajahi'; + String get discoverTab => 'Temukan'; @override String get searchDiscover => 'Cari drama, aktor, genre...'; @@ -54,6 +54,32 @@ class AppLocalizationsId extends AppLocalizations { @override String get featured => 'Unggulan'; + @override + String get recommended => 'Rekomendasi'; + + @override + String get fruitShortsBrand => 'FruitShorts'; + + @override + String get authLandingSubtitle => + 'Masuk untuk menyinkronkan progres, favorit, dan koin'; + + @override + String get comingSoon => 'Segera hadir'; + + @override + String get authLegal => + 'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.'; + + @override + String get authContinueGoogle => 'Lanjutkan dengan Google'; + + @override + String get authPhoneSignIn => 'Masuk dengan ponsel'; + + @override + String get authEmailSignIn => 'Masuk dengan email'; + @override String get suspense => 'Thriller'; @@ -78,6 +104,49 @@ 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 seasonOneOriginalRelease => 'Musim 1 • Rilis Orisinal'; + + @override + String get startWatching => 'Mulai Menonton'; + + @override + String get showLess => 'Lebih sedikit'; + + @override + String get views => 'tayangan'; + + @override + String episodeOfTotal(int current, int total) { + return 'Episode $current / $total'; + } + + @override + String get clearScreen => 'Bersihkan layar'; + + @override + String get exitClearScreen => 'Keluar dari layar bersih'; + + @override + String get playbackSpeed => 'Kecepatan putar'; + + @override + String minutesAgo(int count) { + return '$count mnt lalu'; + } + @override String get artworkOnly => 'Gambar'; @@ -300,12 +369,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get nowPlaying => 'Sedang diputar'; - @override - String get watchFullscreen => 'Tonton mode lanskap'; - - @override - String get exitFullscreen => 'Keluar dari layar penuh'; - @override String get pictureInPicture => 'Gambar dalam gambar'; @@ -546,12 +609,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get swipeForNext => 'Geser ke atas untuk lanjut'; - @override - String get clearScreen => 'Bersihkan layar'; - - @override - String get exitClearScreen => 'Keluar dari mode bersih'; - @override String get logIn => 'Masuk'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f3bd4f1..ea78f10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -45,7 +45,7 @@ class AppLocalizationsZh extends AppLocalizations { String get currentSeries => '当前短剧'; @override - String get discoverTab => '找剧'; + String get discoverTab => '发现'; @override String get searchDiscover => '搜索短剧、演员、类型…'; @@ -53,6 +53,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get featured => '精选'; + @override + String get recommended => '推荐'; + + @override + String get fruitShortsBrand => '水果短剧'; + + @override + String get authLandingSubtitle => '登录后同步进度、收藏与金币权益'; + + @override + String get comingSoon => '即将开放'; + + @override + String get authLegal => '继续即代表同意《用户协议》与《隐私政策》'; + + @override + String get authContinueGoogle => '使用 Google 继续'; + + @override + String get authPhoneSignIn => '手机号登录'; + + @override + String get authEmailSignIn => '邮箱登录'; + @override String get suspense => '悬疑'; @@ -77,6 +101,49 @@ 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 seasonOneOriginalRelease => '第 1 季 · 原创上线'; + + @override + String get startWatching => '开始观看'; + + @override + String get showLess => '收起'; + + @override + String get views => '次播放'; + + @override + String episodeOfTotal(int current, int total) { + return '第 $current 集 / 共 $total 集'; + } + + @override + String get clearScreen => '清屏'; + + @override + String get exitClearScreen => '退出清屏'; + + @override + String get playbackSpeed => '倍速'; + + @override + String minutesAgo(int count) { + return '$count 分钟前'; + } + @override String get artworkOnly => '海报展示'; @@ -122,7 +189,7 @@ class AppLocalizationsZh extends AppLocalizations { String get history => '历史'; @override - String get watchlist => '追剧'; + String get watchlist => '收藏'; @override String get likedTab => '喜欢'; @@ -296,12 +363,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get nowPlaying => '正在播放'; - @override - String get watchFullscreen => '横屏全屏观看'; - - @override - String get exitFullscreen => '退出全屏'; - @override String get pictureInPicture => '画中画'; @@ -535,12 +596,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get swipeForNext => '上滑看下一部'; - @override - String get clearScreen => '清屏'; - - @override - String get exitClearScreen => '退出清屏'; - @override String get logIn => '登录'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bd97d34..5267de7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -12,9 +12,17 @@ "openQuickMenu": "打开快捷菜单", "quickAccess": "快捷入口", "currentSeries": "当前短剧", - "discoverTab": "找剧", + "discoverTab": "发现", "searchDiscover": "搜索短剧、演员、类型…", "featured": "精选", + "recommended": "推荐", + "fruitShortsBrand": "水果短剧", + "authLandingSubtitle": "登录后同步进度、收藏与金币权益", + "comingSoon": "即将开放", + "authLegal": "继续即代表同意《用户协议》与《隐私政策》", + "authContinueGoogle": "使用 Google 继续", + "authPhoneSignIn": "手机号登录", + "authEmailSignIn": "邮箱登录", "suspense": "悬疑", "romance": "都市爱情", "actionGenre": "动作", @@ -23,6 +31,30 @@ "newReleases": "新剧", "trendingMicroDramas": "热门短剧", "seeAll": "查看全部", + "trendingSubtitle": "本周追看最多", + "verticalMiniSeries": "竖屏短剧", + "followSeries": "追剧", + "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", + "seasonOneOriginalRelease": "第 1 季 · 原创上线", + "startWatching": "开始观看", + "showLess": "收起", + "views": "次播放", + "episodeOfTotal": "第 {current} 集 / 共 {total} 集", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "clearScreen": "清屏", + "exitClearScreen": "退出清屏", + "playbackSpeed": "倍速", + "minutesAgo": "{count} 分钟前", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", @@ -44,7 +76,7 @@ }, "resume": "继续播放", "history": "历史", - "watchlist": "追剧", + "watchlist": "收藏", "likedTab": "喜欢", "all": "全部", "inProgress": "未看完", @@ -113,8 +145,6 @@ "reportFailed": "举报提交失败,请重试。", "shareFailed": "无法打开系统分享面板。", "nowPlaying": "正在播放", - "watchFullscreen": "横屏全屏观看", - "exitFullscreen": "退出全屏", "pictureInPicture": "画中画", "pipUnavailable": "此设备无法使用画中画", "originalTag": "原创", @@ -205,8 +235,6 @@ "myListTab": "片单", "watchFull": "看全集", "swipeForNext": "上滑看下一部", - "clearScreen": "清屏", - "exitClearScreen": "退出清屏", "logIn": "登录", "logInHint": "登录后保存金币和片单", "rewards": "奖励", diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 555d495..5ddbde0 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -60,6 +60,7 @@ class _AuthPageState extends State { late final FocusNode _emailFocus; late final FocusNode _passwordFocus; bool _obscurePassword = true; + bool _emailEntryOpen = false; @override void initState() { @@ -243,7 +244,13 @@ class _AuthPageState extends State { tooltip: MaterialLocalizations.of( context, ).backButtonTooltip, - onPressed: () => Navigator.of(context).pop(), + onPressed: () { + if (_emailEntryOpen) { + setState(() => _emailEntryOpen = false); + } else { + Navigator.of(context).pop(); + } + }, icon: const Icon( Icons.arrow_back_rounded, color: AppColors.onPrimary, @@ -272,16 +279,107 @@ class _AuthPageState extends State { child: child, ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), - SizedBox(height: compact ? _compactBrandGap : 28), - _authCard(l10n), - ], - ), + child: _emailEntryOpen + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), + SizedBox(height: compact ? _compactBrandGap : 28), + _authCard(l10n), + ], + ) + : _landing(l10n), ); + Widget _landing(AppLocalizations l10n) => Column( + key: const Key('auth-landing'), + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + key: const Key('auth-brand-logo'), + width: 64, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue, AppColors.secondary], + ), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 32, + ), + ), + ), + const SizedBox(height: 14), + Text( + l10n.fruitShortsBrand, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.authLandingSubtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11), + ), + const SizedBox(height: 22), + _AuthEntryButton( + key: const Key('auth-entry-google'), + label: 'G ${l10n.authContinueGoogle}', + background: Colors.white, + foreground: const Color(0xFF1A1A1A), + leadingColor: const Color(0xFF4285F4), + onTap: () => _comingSoon(l10n), + ), + const SizedBox(height: 11), + _AuthEntryButton( + key: const Key('auth-entry-phone'), + label: l10n.authPhoneSignIn, + icon: Icons.phone_android_rounded, + background: AppColors.input, + foreground: AppColors.onSurface, + onTap: () => _comingSoon(l10n), + ), + const SizedBox(height: 11), + _AuthEntryButton( + key: const Key('auth-entry-email'), + label: l10n.authEmailSignIn, + icon: Icons.mail_outline_rounded, + background: AppColors.input, + foreground: AppColors.onSurface, + onTap: () => setState(() => _emailEntryOpen = true), + ), + const SizedBox(height: 18), + Text( + l10n.authLegal, + key: const Key('auth-legal'), + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF6B6772), + fontSize: 9, + height: 1.6, + ), + ), + ], + ); + + void _comingSoon(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + } + Widget _authCard(AppLocalizations l10n) => _AuthCard( state: _AuthCardState( l10n: l10n, @@ -320,3 +418,57 @@ class _AuthPageState extends State { setState(() => _logic.toggleMode()); } } + +class _AuthEntryButton extends StatelessWidget { + const _AuthEntryButton({ + super.key, + required this.label, + required this.background, + required this.foreground, + required this.onTap, + this.icon, + this.leadingColor, + }); + + final String label; + final Color background; + final Color foreground; + final VoidCallback onTap; + final IconData? icon; + final Color? leadingColor; + + @override + Widget build(BuildContext context) { + return Material( + color: background, + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 44, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) + Icon(icon, size: 16, color: leadingColor ?? foreground), + if (icon != null) const SizedBox(width: 7), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} 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..656adcb 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.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'; @@ -18,7 +18,6 @@ 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,14 @@ 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,134 +134,216 @@ class _DiscoverPageState extends State { Widget _feed( AppLocalizations l10n, CategoryController categories, - List banners, List allDramas, - ) => CustomScrollView( - controller: logic.scroll, - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(18), - child: _carousel(l10n, banners, allDramas), + List visible, + ) => RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: () async { + await Future.wait([categories.load(), logic.refreshResume()]); + }, + child: CustomScrollView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _search()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), + child: _categories(l10n, categories), ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 14), - child: _categories(l10n, categories), - ), - ), - _HomeDramaGrid( - l10n: l10n, - categories: categories, - onOpen: (drama) => logic.openCard( - context, - drama, - embedded: widget.embedded, - onDramaSelected: widget.onDramaSelected, - onAction: widget.onAction, - ), - ), - ], - ); - - Widget _header(AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 12, 8), - 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, + 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!, ), ), ), - const SizedBox(width: 8), - if (widget.signedIn) ...[ - _HeaderPill( - icon: Icons.monetization_on_rounded, - label: r'$ 0', - onTap: widget.onCoins, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _quickActions(l10n, categories), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), + child: Row( + children: [ + Container( + width: 3, + height: 18, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.trendingMicroDramas, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewSectionTitle, + fontWeight: FontWeight.w800, + ), + ), + ), + TextButton( + onPressed: () => + categories.selectCode(CategoryController.allCode), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + minimumSize: const Size(44, 44), + padding: EdgeInsets.zero, + ), + child: Text( + '${l10n.all} ›', + style: const TextStyle(fontSize: 10), + ), + ), + ], + ), + ), + ), + _HomeDramaGrid( + l10n: l10n, + categories: categories, + dramas: visible, + searching: logic.search.text.trim().isNotEmpty, + onOpen: (drama) => logic.openCard( + context, + drama, + embedded: widget.embedded, + onDramaSelected: widget.onDramaSelected, + onAction: widget.onAction, + ), + ), + ], + ), + ); + + Widget _header(AppLocalizations l10n) { + return SizedBox( + height: 46, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, + ), ), const SizedBox(width: 8), - _HeaderPill( - icon: Icons.card_giftcard_rounded, - label: l10n.rewards, - onTap: widget.onRewards, + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), ), - ] else - _LoginButton(label: l10n.logIn, onTap: widget.onSignIn), - ], + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: logic.openSearch, + ), + const SizedBox(width: 8), + 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: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 18, + ), + ), + ), + ), + ], + ), ), ); } - 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: 14), + child: SizedBox( + height: 36, + child: TextField( + key: const Key('discover-search'), + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + onTapOutside: (_) => logic.searchFocus.unfocus(), + textInputAction: TextInputAction.search, + style: const TextStyle(color: AppColors.onSurface, fontSize: 13), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: AppColors.input, + hintText: AppLocalizations.of(context)!.searchDiscover, + hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.subtle, + size: 17, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(color: AppColors.blue, width: 1), + ), ), - ], + ), + ), ); } - 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), - (code: CategoryController.hotCode, label: l10n.hot), - (code: CategoryController.newCode, label: l10n.newest), + (code: CategoryController.allCode, label: l10n.recommended), for (final category in categories.categories) (code: 'category-${category.id}', label: category.name), ]; @@ -265,6 +351,7 @@ class _DiscoverPageState extends State { key: const Key('home-category-chips'), height: AppDimens.tapTarget, child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 14), scrollDirection: Axis.horizontal, itemCount: choices.length, separatorBuilder: (_, _) => const SizedBox(width: 8), @@ -281,17 +368,20 @@ 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 : const Color(0xFF29272B), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: selected ? AppColors.blue : Colors.transparent, + ), ), child: Text( choice.label, maxLines: 1, style: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.cream, - fontSize: AppDimens.body, + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.chipFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), @@ -305,6 +395,173 @@ class _DiscoverPageState extends State { ); } + Widget _continueCard( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { + final progress = record.fraction; + return Container( + key: const Key('discover-continue-watching'), + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 52, + height: 66, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), + ), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.continueWatching, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.accent, + fontSize: 8, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + _resumeShort(drama, record), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: progress <= 0 ? null : progress, + minHeight: 3, + backgroundColor: const Color(0xFF474248), + 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(56, 28), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + child: Text(l10n.resume), + ), + ], + ), + ); + } + + String _resumeShort(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; + } + } + if (episode == null) return _duration(record.position); + return label.episodeOfTotal(episode.number, drama.episodeCount); + } + + 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: 7), + 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: 7), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-new'), + icon: Icons.auto_awesome_rounded, + label: l10n.newReleases, + color: AppColors.gold, + onTap: () => categories.selectCode(CategoryController.newCode), + ), + ), + const SizedBox(width: 7), + 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( @@ -343,72 +600,81 @@ class _DiscoverPageState extends State { ); } -const _coin = AppColors.gold; +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); -class _BrandMark extends StatelessWidget { - const _BrandMark(); + final IconData icon; + final String tooltip; + final VoidCallback onTap; @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, + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), ), ); } } -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.quick, + borderRadius: BorderRadius.circular(12), + 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: 62, + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(height: 4), + Text( + label, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 9, + fontWeight: FontWeight.w500, + height: 1.05, ), - ], - ), + ), + ], ), ), ), @@ -416,28 +682,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/cover_carousel.dart b/lib/pages/discover/widgets/cover_carousel.dart deleted file mode 100644 index 0fdb0b1..0000000 --- a/lib/pages/discover/widgets/cover_carousel.dart +++ /dev/null @@ -1,310 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/widgets/remote_image.dart'; -import '../../../l10n/app_localizations.dart'; - -const Duration _autoPlayInterval = Duration(milliseconds: 4500); - -class CoverItem { - const CoverItem({ - required this.title, - required this.subtitle, - required this.image, - required this.onOpen, - }); - - final String title; - final String subtitle; - final String image; - final VoidCallback onOpen; -} - -/// Full-bleed posters that turn as faces of a cube. Titles sit on the image. -class CoverCarousel extends StatefulWidget { - const CoverCarousel({super.key, required this.items, this.active = true}); - - final List items; - final bool active; - - @override - State createState() => _CoverCarouselState(); -} - -class _CoverCarouselState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _snap; - double _page = 0; - double _from = 0; - double _to = 0; - int? _pointer; - double _drag = 0; - double _dragPage = 0; - Timer? _autoTimer; - bool _tickerEnabled = true; - - @override - void initState() { - super.initState(); - _snap = AnimationController(vsync: this)..addListener(_onSnap); - } - - @override - void dispose() { - _autoTimer?.cancel(); - _snap.dispose(); - super.dispose(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final enabled = TickerMode.valuesOf(context).enabled; - if (enabled != _tickerEnabled) { - _tickerEnabled = enabled; - _syncAutoTimer(); - } else if (_autoTimer == null) { - _syncAutoTimer(); - } - } - - @override - void didUpdateWidget(covariant CoverCarousel oldWidget) { - super.didUpdateWidget(oldWidget); - if (_page > _last) _page = _last; - if (oldWidget.items.length != widget.items.length || - oldWidget.active != widget.active) { - _syncAutoTimer(); - } - } - - double get _last => math.max(0, widget.items.length - 1).toDouble(); - - void _onSnap() { - if (!mounted) return; - final t = Curves.easeOutCubic.transform(_snap.value); - setState(() => _page = _from + (_to - _from) * t); - } - - void _turnTo(double target) { - if (!mounted || widget.items.isEmpty) return; - _from = _page; - _to = target.clamp(0, _last); - _snap.duration = const Duration(milliseconds: 420); - _snap.forward(from: 0); - } - - void _syncAutoTimer() { - _autoTimer?.cancel(); - _autoTimer = null; - if (!mounted || - !widget.active || - !_tickerEnabled || - widget.items.length <= 1) { - return; - } - _autoTimer = Timer.periodic(_autoPlayInterval, (_) { - if (!mounted || _pointer != null || !widget.active || !_tickerEnabled) { - return; - } - final current = _page.round().clamp(0, _last.toInt()); - if (current >= _last.toInt()) { - setState(() => _page = 0); - } else { - _turnTo((current + 1).toDouble()); - } - }); - } - - @override - Widget build(BuildContext context) { - if (widget.items.isEmpty) return const SizedBox.shrink(); - final current = _page.round().clamp(0, widget.items.length - 1); - final l10n = AppLocalizations.of(context)!; - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final height = math.min(width * 0.52, 210).toDouble(); - return Semantics( - container: true, - label: l10n.carouselSemantics(current + 1, widget.items.length), - child: SizedBox( - width: double.infinity, - height: height, - child: Stack( - children: [ - Positioned.fill(child: _stage(width)), - _indicator(l10n, current), - ], - ), - ), - ); - }, - ); - } - - Widget _stage(double width) { - return Listener( - behavior: HitTestBehavior.opaque, - onPointerDown: (event) { - _autoTimer?.cancel(); - _autoTimer = null; - _snap.stop(); - _pointer = event.pointer; - _drag = 0; - _dragPage = _page; - }, - onPointerMove: (event) { - if (event.pointer != _pointer) return; - _drag += event.delta.dx; - setState(() { - _page = (_dragPage - _drag / width).clamp(0.0, _last); - }); - }, - onPointerUp: (event) { - if (event.pointer != _pointer) return; - _pointer = null; - _turnTo(_page.roundToDouble()); - _syncAutoTimer(); - }, - onPointerCancel: (event) { - if (event.pointer != _pointer) return; - _pointer = null; - _turnTo(_page.roundToDouble()); - _syncAutoTimer(); - }, - child: ClipRect( - child: Stack( - fit: StackFit.expand, - children: [for (final index in _visible) _face(index, width)], - ), - ), - ); - } - - Widget _indicator(AppLocalizations l10n, int current) { - return Positioned( - left: 0, - right: 0, - bottom: 8, - child: Semantics( - label: l10n.carouselSemantics(current + 1, widget.items.length), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (var index = 0; index < widget.items.length; index++) - Container( - key: Key('carousel-dot-$index'), - width: 6, - height: 6, - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: index == current - ? AppColors.onPrimary - : AppColors.muted.withValues(alpha: 0.4), - ), - ), - ], - ), - ), - ); - } - - List get _visible { - final first = _page.floor(); - final indexes = [first, first + 1]; - indexes.retainWhere((index) => index >= 0 && index < widget.items.length); - indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs())); - return indexes; - } - - Widget _face(int index, double width) { - final delta = index - _page; - if (delta <= -1 || delta >= 1) return const SizedBox.shrink(); - final item = widget.items[index]; - final angle = delta * math.pi / 2; - // Push the face out to the cube surface, spin the whole cube on its - // vertical center, then bring that center back to the banner. - final face = Matrix4.identity() - ..translateByDouble(0, 0, -width / 2, 1) - ..rotateY(angle) - ..translateByDouble(0, 0, width / 2, 1); - final transform = Matrix4.identity() - ..setEntry(3, 2, 0.0018) - ..multiply(face); - return Positioned.fill( - child: Transform( - alignment: Alignment.center, - filterQuality: FilterQuality.medium, - transform: transform, - child: GestureDetector( - onTap: () { - if ((_page - index).abs() < 0.08) item.onOpen(); - }, - child: _poster(item), - ), - ), - ); - } - - Widget _poster(CoverItem item) { - return Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - item.image, - showFallbackIcon: true, - fallbackIconKey: const Key('carousel-image-fallback'), - ), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)], - stops: [0, 0.5, 1], - ), - ), - ), - Positioned( - left: 20, - right: 20, - bottom: 30, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600, - height: 1.15, - shadows: [Shadow(blurRadius: 12, color: Colors.black)], - ), - ), - const SizedBox(height: 6), - Text( - item.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xF2FFFFFF), - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.2, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 3ecf9a0..d2bd04c 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 @@ -33,55 +37,65 @@ 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 - ? Icons.grid_view_rounded - : Icons.search_off_rounded, - title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults, + icon: searching + ? Icons.search_off_rounded + : Icons.movie_filter_outlined, + title: searching + ? l10n.noSearchResults + : categories.selectedSectionMissing + ? l10n.noCategoryDramas + : l10n.noSearchResults, subtitle: l10n.emptyHint, ), ); } - return SliverMainAxisGroup( slivers: [ - _dramaGrid(dramas), + SliverToBoxAdapter(child: _masonry(dramas)), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ); } - Widget _dramaGrid(List dramas) => 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, - mainAxisSpacing: 14, - crossAxisSpacing: 10, - childAspectRatio: 2 / 3, + Widget _masonry(List items) { + return Padding( + key: const Key('home-all-dramas-section'), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + key: const Key('home-all-dramas-grid'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _column(items, 0)), + const SizedBox(width: 10), + Expanded(child: _column(items, 1)), + ], ), - delegate: SliverChildBuilderDelegate( - (context, index) => _HomeDramaCard( - l10n: l10n, - drama: dramas[index], - onTap: () => onOpen(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ); - - Widget _fill(Widget child) { - return SliverFillRemaining(hasScrollBody: false, child: child); + ); } + + Widget _column(List items, int start) { + final children = []; + for (var index = start; index < items.length; index += 2) { + children.add( + _HomeDramaCard( + l10n: l10n, + drama: items[index], + onTap: () => onOpen(items[index]), + ), + ); + if (index + 2 < items.length) { + children.add(const SizedBox(height: 10)); + } + } + return Column(children: children); + } + + Widget _fill(Widget child) => + SliverFillRemaining(hasScrollBody: false, child: child); } class _HomeDramaCard extends StatelessWidget { @@ -98,96 +112,105 @@ class _HomeDramaCard extends StatelessWidget { @override Widget build(BuildContext context) { final genre = drama.genre.isEmpty ? drama.category : drama.genre; + final free = + drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); return InkWell( key: Key('home-drama-card-${drama.id}'), onTap: onTap, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - 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], - ), - ), - ), - if (drama.isHot) - Positioned( - top: 8, - left: 8, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Padding( + borderRadius: BorderRadius.circular(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + key: Key('home-drama-cover-${drama.id}'), + borderRadius: BorderRadius.circular(10), + child: Stack( + children: [ + _DiscoverCoverImage(path: drama.posterUrl), + if (free) + Positioned( + top: 6, + left: 6, + child: Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), child: Text( - l10n.hot, + l10n.free, style: const TextStyle( color: AppColors.onPrimary, - fontSize: AppDimens.caption, + fontSize: 8, fontWeight: FontWeight.w800, - height: 1.1, ), ), ), ), - ), - 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: 5), + Text( + drama.title, + key: Key('home-drama-title-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + genre.isEmpty ? l10n.videoPending : genre, + key: Key('home-drama-genre-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ), + ); + } +} + +class _DiscoverCoverImage extends StatelessWidget { + const _DiscoverCoverImage({required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + final url = AppConfig.mediaUrl(path); + if (url.isEmpty) return _placeholder(); + return Image.network( + url, + width: double.infinity, + fit: BoxFit.fitWidth, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _placeholder(); + }, + errorBuilder: (_, _, _) => _placeholder(), + ); + } + + Widget _placeholder() { + return AspectRatio( + aspectRatio: 3 / 4, + child: ColoredBox( + color: AppColors.card, + child: const Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.muted, + size: 32, ), ), ), diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index e3b25d2..952dda7 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -6,30 +6,35 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/playback_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/platform_share_service.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/remote_image.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; -import '../../core/data/repositories/watch_history_repository.dart'; -import '../../core/data/repositories/playback_repository.dart'; -const String _reportMenuValue = 'report'; +const String _menuFavorite = 'favorite'; +const String _menuDownload = 'download'; +const String _menuShare = 'share'; +const String _menuReport = 'report'; class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); + final Drama drama; @override @@ -78,254 +83,430 @@ class _DramaDetailPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - if (logic.loading) { - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: const Center(child: CircularProgressIndicator()), - ); - } - if (logic.failed) { - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - errorText(l10n, logic.failureMessage, l10n.detailError), - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), - FilledButton(onPressed: _load, child: Text(l10n.retry)), - ], - ), - ), - ), - ); - } + if (logic.loading) return _loading(l10n); + if (logic.failed) return _failed(l10n); final drama = logic.drama; - if (drama.episodes.isEmpty) { - // 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏 - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(localizedDramaTitle(l10n, drama)), - ), - body: EmptyState( - key: const Key('drama-detail-empty'), - icon: Icons.video_library_outlined, - title: l10n.noEpisodesYet, - subtitle: l10n.emptyHint, - actionLabel: l10n.retry, - onAction: _load, - ), - ); - } + if (drama.episodes.isEmpty) return _empty(l10n, drama); + return Scaffold( backgroundColor: AppColors.ink, body: CustomScrollView( + key: const Key('detail-scroll'), slivers: [ - SliverAppBar( - expandedHeight: 300, - pinned: true, - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - actions: [ - _reportAction(context, l10n), - _downloadAction(context, l10n), - _favoriteAction(context, l10n), - ], - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - RemoteImage(drama.posterUrl), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.center, - colors: [Color(0x99000000), Color(0x00000000)], - ), - ), - ), - ], + SliverToBoxAdapter(child: _hero(context, l10n, drama)), + SliverToBoxAdapter(child: _content(context, l10n, drama)), + const SliverToBoxAdapter(child: SizedBox(height: 28)), + ], + ), + ); + } + + Widget _loading(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: const Center(child: CircularProgressIndicator()), + ); + + Widget _failed(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + errorText(l10n, logic.failureMessage, l10n.detailError), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted), + ), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: Text(l10n.retry)), + ], + ), + ), + ), + ); + + Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, drama)), + ), + body: EmptyState( + key: const Key('drama-detail-empty'), + icon: Icons.video_library_outlined, + title: l10n.noEpisodesYet, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: _load, + ), + ); + + Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { + return SizedBox( + key: const Key('detail-hero'), + height: 190, + 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(0x33000000), Color(0xCC0B0C10)], ), ), ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Text( - l10n.technicalDemoCount(drama.episodes.length), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - letterSpacing: 1.4, - color: AppColors.blueSoft, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - localizedDramaTitle(l10n, drama), - style: const TextStyle( - fontSize: AppDimens.detailHeadline, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 10), - Text( - localizedDramaSynopsis(l10n, drama), - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppColors.muted), - ), - const SizedBox(height: 20), - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FilledButton.icon( - onPressed: () => - logic.openPlayer(context, logic.continueIndex ?? 0), - icon: const Icon(Icons.play_arrow_rounded), - label: Text( - logic.continueIndex == null - ? l10n.watchEpisodeOne - : l10n.continueEpisode(logic.continueIndex! + 1), - ), - ), - if (_continueFraction != null) ...[ - const SizedBox(height: 8), - _ResumeBar( - fraction: _continueFraction!, - label: l10n.resumeProgress( - _clock(logic.progress!.position), - _clock(logic.progress!.duration), - ), - ), - ], - if (logic.progressError != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - errorText( - l10n, - logic.progressError, - l10n.progressError, - ), - style: const TextStyle( - color: Colors.white60, - fontSize: 12, - ), - ), - ), - TextButton( - onPressed: () => unawaited(logic.loadContinue()), - child: Text(l10n.retry), - ), - ], - ), - ], - ], - ), - const SizedBox(height: 30), - Text( - l10n.episodes, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontSize: AppDimens.sectionTitle, - fontWeight: FontWeight.w700, - ), - ), - ]), + Positioned( + top: MediaQuery.paddingOf(context).top + 14, + left: 14, + child: _RoundAction( + key: const Key('detail-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + onPressed: () => Navigator.of(context).maybePop(), ), ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 40), - sliver: SliverList.builder( - itemCount: drama.episodes.length, - itemBuilder: (context, index) { - final episode = drama.episodes[index]; - final fraction = logic.progressFractionFor(episode.id); - return Card( - margin: const EdgeInsets.only(bottom: 10), - child: ListTile( - minTileHeight: 76, - leading: CircleAvatar( - backgroundColor: AppColors.blue.withValues(alpha: 0.18), - child: Text('${episode.number}'), - ), - title: Text(l10n.episodeLabel(episode.number)), - subtitle: fraction == null - ? null - : Padding( - padding: const EdgeInsets.only(top: 6), - child: _ResumeBar(fraction: fraction), - ), - trailing: const Icon(Icons.play_circle_outline_rounded), - onTap: () => logic.openPlayer(context, index), - ), - ); - }, - ), + Positioned( + top: MediaQuery.paddingOf(context).top + 14, + right: 14, + child: _moreMenu(context, l10n), ), ], ), ); } - double? get _continueFraction { - final record = logic.progress; - if (record == null || !record.hasPosition) return null; - if (logic.continueIndex == null) return null; - return record.fraction; - } - - /// 下载当前续看集或第一集;业务判定完全交给服务端。 - Widget _downloadAction(BuildContext context, AppLocalizations l10n) { - final episodes = logic.drama.episodes; - final index = logic.continueIndex ?? 0; - final episode = episodes.isEmpty - ? null - : episodes[index.clamp(0, episodes.length - 1)]; - return IconButton( - key: const Key('detail-download'), - tooltip: l10n.downloads, - icon: const Icon(Icons.download_outlined), - onPressed: episode == null - ? null - : () => requestEpisodeDownload(context, episode.id), + Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) { + final continueIndex = logic.continueIndex; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.episodeCount(drama.episodeCount), + key: const Key('detail-episode-count'), + style: const TextStyle( + color: AppColors.secondary, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 1, + ), + ), + const SizedBox(height: 7), + Text( + localizedDramaTitle(l10n, drama), + key: const Key('detail-title'), + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + const SizedBox(height: 10), + Text( + localizedDramaSynopsis(l10n, drama), + key: const Key('detail-synopsis'), + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + height: 1.6, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 40, + child: FilledButton.icon( + key: const Key('detail-cta'), + onPressed: () => logic.openPlayer(context, continueIndex ?? 0), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDimens.buttonRadius), + ), + textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text( + continueIndex == null + ? l10n.startWatching + : l10n.continueEpisode(continueIndex + 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (_continueFraction != null) ...[ + const SizedBox(height: 8), + _ResumeBar( + fraction: _continueFraction!, + label: l10n.resumeProgress( + _clock(logic.progress!.position), + _clock(logic.progress!.duration), + ), + ), + ], + if (logic.progressError != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + errorText(l10n, logic.progressError, l10n.progressError), + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ), + TextButton( + onPressed: () => unawaited(logic.loadContinue()), + child: Text(l10n.retry), + ), + ], + ), + ], + const SizedBox(height: 18), + Text( + l10n.episodes, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + final sixColumnsWidth = (constraints.maxWidth - 5 * 8) / 6; + final columns = sixColumnsWidth >= 40 ? 6 : 5; + final selectedIndex = continueIndex ?? 0; + final selectedNumber = drama.episodes.isEmpty + ? null + : drama.episodes[selectedIndex].number; + final ordered = [ + for (var index = 0; index < drama.episodes.length; index++) + (index: index, episode: drama.episodes[index]), + ]..sort((a, b) => a.episode.number.compareTo(b.episode.number)); + return GridView.builder( + key: const Key('detail-episode-chips'), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: ordered.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.35, + ), + itemBuilder: (context, position) { + final item = ordered[position]; + return _episodeChip( + context, + item.episode, + item.index, + selectedNumber, + ); + }, + ); + }, + ), + ], + ), ); } - /// 详情页更多菜单:本批只提供举报短剧。 - Widget _reportAction(BuildContext context, AppLocalizations l10n) { + Widget _episodeChip( + BuildContext context, + Episode episode, + int index, + int? selectedNumber, + ) { + final selected = episode.number == selectedNumber; + final locked = episode.locked || (!episode.isFree && index > 0); + return InkWell( + key: Key('detail-episode-chip-${episode.number}'), + onTap: () => logic.openPlayer(context, index), + borderRadius: BorderRadius.circular(9), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.16) + : const Color(0xFF29272B), + borderRadius: BorderRadius.circular(9), + ), + child: Text( + '${episode.number}', + style: TextStyle( + color: selected + ? AppColors.onPrimary + : locked + ? AppColors.secondary + : AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + Widget _moreMenu(BuildContext context, AppLocalizations l10n) { + final saved = context.watch().isSaved(logic.drama.id); return PopupMenuButton( key: const Key('detail-more'), tooltip: l10n.more, - onSelected: (_) => unawaited(_reportDrama(l10n)), + color: AppColors.card, + onSelected: (value) { + switch (value) { + case _menuFavorite: + unawaited(_toggleFavorite(l10n)); + case _menuDownload: + _downloadCurrent(); + case _menuShare: + unawaited(_shareDrama(l10n, logic.drama)); + case _menuReport: + unawaited(_reportDrama(l10n)); + } + }, itemBuilder: (_) => [ - PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)), + PopupMenuItem( + key: const Key('detail-favorite'), + value: _menuFavorite, + child: Row( + children: [ + Icon( + saved ? Icons.star_rounded : Icons.star_border_rounded, + color: saved ? AppColors.blue : AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.favorite), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-download'), + value: _menuDownload, + child: Row( + children: [ + const Icon( + Icons.download_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.downloads), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-share'), + value: _menuShare, + child: Row( + children: [ + const Icon( + Icons.share_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.share), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-report'), + value: _menuReport, + child: Row( + children: [ + const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18), + const SizedBox(width: 9), + Text(l10n.reportDrama), + ], + ), + ), ], + child: Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.more_horiz_rounded, + size: 18, + color: AppColors.onSurface, + ), + ), ); } + Future _shareDrama(AppLocalizations l10n, Drama drama) async { + try { + await const PlatformShareService().shareDrama( + title: localizedDramaTitle(l10n, drama), + synopsis: localizedDramaSynopsis(l10n, drama), + footer: l10n.shareFooter, + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.shareFailed))); + } + } + + Future _toggleFavorite(AppLocalizations l10n) async { + if (!await ensureSignedIn(context)) return; + if (!mounted) return; + unawaited(context.read().lightImpact()); + final ok = await logic.toggleFavorite(); + if (ok || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + } + + void _downloadCurrent() { + final episodes = logic.drama.episodes; + if (episodes.isEmpty) return; + final index = (logic.continueIndex ?? 0).clamp(0, episodes.length - 1); + requestEpisodeDownload(context, episodes[index].id); + } + Future _reportDrama(AppLocalizations l10n) async { if (!await ensureSignedIn(context) || !mounted) return; final draft = await showReportDialog(context, title: l10n.reportDrama); @@ -348,25 +529,11 @@ class _DramaDetailPageState extends State { ); } - /// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。 - Widget _favoriteAction(BuildContext context, AppLocalizations l10n) { - final favorites = context.watch(); - final saved = favorites.isSaved(logic.drama.id); - return IconButton( - tooltip: l10n.favorite, - icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border), - onPressed: () async { - if (!await ensureSignedIn(context)) return; - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await logic.toggleFavorite(); - if (ok || !context.mounted) return; - // 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚) - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); - }, - ); + double? get _continueFraction { + final record = logic.progress; + if (record == null || !record.hasPosition) return null; + if (logic.continueIndex == null) return null; + return record.fraction; } String _clock(Duration duration) { @@ -378,7 +545,37 @@ class _DramaDetailPageState extends State { } } -/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。 +class _RoundAction extends StatelessWidget { + const _RoundAction({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: IconButton( + tooltip: tooltip.isEmpty ? null : tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon(icon, size: 18, color: AppColors.onSurface), + ), + ); + } +} + class _ResumeBar extends StatelessWidget { const _ResumeBar({required this.fraction, this.label}); @@ -391,22 +588,19 @@ class _ResumeBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.progressRadius), + borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: fraction, - minHeight: AppDimens.progressHeight, - backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), + minHeight: 3, + backgroundColor: const Color(0xFF474248), + valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), if (label != null) ...[ const SizedBox(height: 4), Text( label!, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), + style: const TextStyle(color: AppColors.muted, fontSize: 10), ), ], ], diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 4529800..307b42c 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; @@ -49,21 +49,20 @@ class HomeLogic extends ChangeNotifier { notify(); } - void openMenu() { - drawerOpen = true; + void setMenuOpen(bool value) { + if (drawerOpen == value) return; + drawerOpen = value; notify(); - scaffoldKey.currentState?.openDrawer(); } void selectFromMenu(int nextTab, {int? mySection}) { - scaffoldKey.currentState?.closeDrawer(); + drawerOpen = false; select(nextTab, mySection: mySection); } Future openCurrentSeries(BuildContext context) async { detailOpen = true; notify(); - scaffoldKey.currentState?.closeDrawer(); await WidgetsBinding.instance.endOfFrame; if (!context.mounted) return; try { @@ -102,13 +101,11 @@ class HomeLogic extends ChangeNotifier { void openProfile() { profile = true; - scaffoldKey.currentState?.closeDrawer(); select(2, mySection: 0); } void openLibrary({int section = 1}) { profile = false; - scaffoldKey.currentState?.closeDrawer(); select(2, mySection: section); } @@ -139,24 +136,14 @@ class HomeLogic extends ChangeNotifier { } void onSystemBack() { - if (drawerOpen) { - scaffoldKey.currentState?.closeDrawer(); - } else if (videoImmersive) { + if (videoImmersive) { backRequest++; notify(); } else if (tab != 1) { select(1); - } else { - openMenu(); } } - void onDrawerChanged(bool opened) { - if (drawerOpen == opened) return; - drawerOpen = opened; - notify(); - } - void onDramaChanged(Drama drama) { currentDrama = drama; notify(); diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 32297ea..2c9377c 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -24,7 +24,7 @@ import '../mine/mine_view.dart'; import '../player/player_view.dart'; import 'home_binding.dart'; import 'home_logic.dart'; -import 'widgets/home_quick_drawer.dart'; +import 'widgets/home_menu_sheet.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -173,21 +173,15 @@ class _HomeTabsState extends State<_HomeTabs> { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - logic.onSystemBack(); + if (!logic.videoImmersive && logic.tab == 1) { + unawaited(_openMenu(context)); + } else { + logic.onSystemBack(); + } }, child: Scaffold( key: logic.scaffoldKey, backgroundColor: AppColors.ink, - drawer: HomeQuickDrawer( - drama: logic.currentDrama, - onOpenSeries: () => logic.openCurrentSeries(context), - onDiscover: () => logic.selectFromMenu(1), - onHistory: () => logic.openLibrary(section: 0), - onWatchlist: logic.openLibrary, - onLiked: () => logic.selectFromMenu(2, mySection: 2), - ), - drawerEnableOpenDragGesture: false, - onDrawerChanged: logic.onDrawerChanged, body: IndexedStack( index: logic.tab, children: [ @@ -204,7 +198,7 @@ class _HomeTabsState extends State<_HomeTabs> { onDiscover: () => logic.select(1), onMy: logic.openProfile, onSearch: () => logic.select(1, search: true), - onMenu: logic.openMenu, + onMenu: () => _openMenu(context), onImmersiveChanged: logic.onImmersiveChanged, ), DiscoverPage( @@ -243,36 +237,28 @@ class _HomeTabsState extends State<_HomeTabs> { : AppBottomNav( barKey: const Key('home-tabs-bar'), items: [ - AppBottomNavItem( - tabKey: const Key('tab-discover'), - 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, + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, + label: l10n.homeTab, selected: logic.tab == 0, 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, + onTap: () => _selectTab(context, 1), ), AppBottomNavItem( tabKey: const Key('tab-my'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, label: l10n.myTab, - selected: logic.tab == 2 && logic.profile, + selected: logic.tab == 2, onTap: () => _selectTab(context, 2, myProfile: true), ), ], @@ -282,9 +268,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()); } @@ -294,4 +278,23 @@ class _HomeTabsState extends State<_HomeTabs> { logic.select(tab); } } + + Future _openMenu(BuildContext context) async { + logic.setMenuOpen(true); + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => HomeMenuSheet( + drama: logic.currentDrama, + onOpenSeries: () => logic.openCurrentSeries(context), + onDiscover: () => logic.selectFromMenu(1), + onHistory: () => logic.openLibrary(section: 0), + onWatchlist: logic.openLibrary, + onLiked: () => logic.selectFromMenu(2, mySection: 2), + ), + ); + logic.setMenuOpen(false); + } } diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart new file mode 100644 index 0000000..e870bd4 --- /dev/null +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -0,0 +1,246 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/config/locale_controller.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +/// Compact navigation menu opened from the preview home header. +class HomeMenuSheet extends StatelessWidget { + const HomeMenuSheet({ + super.key, + required this.drama, + required this.onOpenSeries, + required this.onDiscover, + required this.onHistory, + required this.onWatchlist, + required this.onLiked, + }); + + final Drama drama; + final VoidCallback onOpenSeries; + final VoidCallback onDiscover; + final VoidCallback onHistory; + final VoidCallback onWatchlist; + final VoidCallback onLiked; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final languageCode = Localizations.localeOf(context).languageCode; + void closeThen(VoidCallback action) { + Navigator.pop(context); + action(); + } + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: RemoteImage(drama.posterUrl, width: 64, height: 86), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: 9, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 34, + child: FilledButton.icon( + key: const Key('drawer-open-series'), + onPressed: () => closeThen(onOpenSeries), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle(fontSize: 11), + ), + icon: const Icon( + Icons.play_arrow_rounded, + size: 15, + ), + label: Text(l10n.exploreSeries), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 14), + Text( + l10n.quickAccess, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _MenuTile( + key: const Key('drawer-discover'), + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: () => closeThen(onDiscover), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _MenuTile( + key: const Key('drawer-history'), + icon: Icons.history_rounded, + label: l10n.history, + onTap: () => closeThen(onHistory), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _MenuTile( + key: const Key('drawer-watchlist'), + icon: Icons.bookmark_border_rounded, + label: l10n.watchlist, + onTap: () => closeThen(onWatchlist), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _MenuTile( + key: const Key('drawer-liked'), + icon: Icons.favorite_border_rounded, + label: l10n.likedTab, + onTap: () => closeThen(onLiked), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + l10n.appLanguage, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final (code, label) in [ + ('en', l10n.english), + ('zh', l10n.simplifiedChinese), + ('id', l10n.bahasaIndonesia), + ]) + ChoiceChip( + label: Text(label), + selected: languageCode == code, + onSelected: (_) => + context.read().select(code), + backgroundColor: AppColors.input, + selectedColor: AppColors.blue, + labelStyle: TextStyle( + color: languageCode == code + ? AppColors.onPrimary + : AppColors.muted, + fontSize: 10, + ), + side: BorderSide.none, + showCheckmark: false, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _MenuTile extends StatelessWidget { + const _MenuTile({ + super.key, + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.quick, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 58, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: AppColors.secondary, size: 16), + const SizedBox(height: 4), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/home_quick_drawer.dart b/lib/pages/home/widgets/home_quick_drawer.dart deleted file mode 100644 index b18575f..0000000 --- a/lib/pages/home/widgets/home_quick_drawer.dart +++ /dev/null @@ -1,287 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../core/config/locale_controller.dart'; -import '../../../core/data/models/drama.dart'; -import '../../../core/formatting/localized_content.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/remote_image.dart'; -import '../../../l10n/app_localizations.dart'; - -/// Shortcuts for the existing root tabs; this widget owns no navigation state. -class HomeQuickDrawer extends StatelessWidget { - const HomeQuickDrawer({ - super.key, - required this.drama, - required this.onOpenSeries, - required this.onDiscover, - required this.onHistory, - required this.onWatchlist, - required this.onLiked, - }); - - final Drama drama; - final VoidCallback onOpenSeries; - final VoidCallback onDiscover; - final VoidCallback onHistory; - final VoidCallback onWatchlist; - final VoidCallback onLiked; - - static const _panel = AppColors.card; - static const _card = AppColors.raised; - static const _accent = AppColors.blueSoft; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final languageCode = Localizations.localeOf(context).languageCode; - return Drawer( - width: (MediaQuery.sizeOf(context).width * 0.83).clamp(0.0, 360.0), - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal(right: Radius.circular(20)), - ), - child: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), - children: [ - Row( - children: [ - const CircleAvatar( - radius: 30, - backgroundColor: AppColors.raised, - child: Icon( - Icons.person_rounded, - size: 34, - color: Colors.white, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.localViewer, - style: const TextStyle( - color: Colors.white, - fontSize: 19, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - l10n.localDemoProfile, - style: const TextStyle(color: AppColors.cream), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 26), - _SectionCard( - title: l10n.currentSeries, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - AppDimens.innerRadius, - ), - child: RemoteImage( - drama.posterUrl, - width: 74, - height: 100, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w700, - fontSize: 16, - ), - ), - const SizedBox(height: 7), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle(color: AppColors.cream), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - key: const Key('drawer-open-series'), - onPressed: onOpenSeries, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - ), - icon: const Icon(Icons.play_arrow_rounded), - label: Text(l10n.exploreSeries), - ), - ), - ], - ), - ), - const SizedBox(height: 14), - _SectionCard( - title: l10n.quickAccess, - child: Column( - children: [ - Row( - children: [ - Expanded( - child: _QuickAction( - key: const Key('drawer-discover'), - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: onDiscover, - ), - ), - const SizedBox(width: 9), - Expanded( - child: _QuickAction( - key: const Key('drawer-history'), - icon: Icons.history_rounded, - label: l10n.history, - onTap: onHistory, - ), - ), - ], - ), - const SizedBox(height: 9), - Row( - children: [ - Expanded( - child: _QuickAction( - key: const Key('drawer-watchlist'), - icon: Icons.bookmark_border_rounded, - label: l10n.watchlist, - onTap: onWatchlist, - ), - ), - const SizedBox(width: 9), - Expanded( - child: _QuickAction( - key: const Key('drawer-liked'), - icon: Icons.favorite_border_rounded, - label: l10n.likedTab, - onTap: onLiked, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 14), - _SectionCard( - title: l10n.appLanguage, - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final (code, label) in [ - ('en', l10n.english), - ('zh', l10n.simplifiedChinese), - ('id', l10n.bahasaIndonesia), - ]) - ChoiceChip( - label: Text(label, maxLines: 1), - selected: languageCode == code, - onSelected: (_) => - context.read().select(code), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _SectionCard extends StatelessWidget { - const _SectionCard({required this.title, required this.child}); - - final String title; - final Widget child; - - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: HomeQuickDrawer._card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 14), - child, - ], - ), - ); -} - -class _QuickAction extends StatelessWidget { - const _QuickAction({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) => Material( - color: AppColors.raised, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7), - child: Column( - children: [ - Icon(icon, color: HomeQuickDrawer._accent, size: 25), - const SizedBox(height: 6), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), - ), - ], - ), - ), - ), - ); -} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 6acf5ac..bc1e7a5 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -6,27 +6,28 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/formatting/localized_content.dart'; +import '../../core/config/locale_controller.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; -import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/interaction_controller.dart'; import '../../core/formatting/error_text.dart'; -import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/app_bottom_nav.dart'; -import '../../core/widgets/remote_image.dart'; +import '../../core/formatting/localized_content.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/remote_image.dart'; import '../../l10n/app_localizations.dart'; -import 'mine_binding.dart'; -import 'mine_logic.dart'; -import '../watch_records/watch_records_page.dart'; +import '../account/account_entry_card.dart'; +import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; import '../notices/notices_page.dart'; -import '../downloads/downloads_page.dart'; -import '../account/account_entry_card.dart'; +import '../watch_records/watch_records_page.dart'; +import 'mine_binding.dart'; +import 'mine_logic.dart'; class MyPage extends StatefulWidget { const MyPage({ @@ -115,42 +116,29 @@ class _MyPageState extends State { bottom: !widget.embedded, child: Column( children: [ - if (!widget.profile) _topBar(l10n), + _topBar(l10n), Expanded( - child: FutureBuilder( - future: logic.position, - builder: (context, snapshot) { - final position = snapshot.data ?? Duration.zero; - final total = widget.duration > Duration.zero - ? widget.duration - : const Duration(seconds: 12); - final fraction = - (position.inMilliseconds / total.inMilliseconds).clamp( - 0.0, - 1.0, - ); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), - children: [ - if (widget.profile) - ..._accountPage(l10n) - else ...[ - _library(l10n, position, total, fraction), - ], - const SizedBox(height: 24), - Center( - child: Text( - l10n.brandMark, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - letterSpacing: 1.3, - ), - ), - ), - ], - ); - }, + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 18), + children: [ + _userRow(l10n), + const SizedBox(height: 12), + _wallet(l10n), + if (_continueDrama != null) ...[ + const SizedBox(height: 12), + _continueCard(l10n, _continueDrama!, _continueRecord!), + ], + const SizedBox(height: 14), + _library(l10n), + const SizedBox(height: 14), + _languageRow(l10n), + if (widget.signedIn) ...[ + const SizedBox(height: 12), + const AccountEntryCard(), + ], + const SizedBox(height: 12), + _menuCard(l10n), + ], ), ), if (!widget.embedded) _bottomNav(l10n), @@ -166,124 +154,104 @@ class _MyPageState extends State { } Widget _topBar(AppLocalizations l10n) => SizedBox( - height: 54, + height: 46, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27), - const SizedBox(width: 7), + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, + ), + ), + const SizedBox(width: 8), Text( - l10n.myListTab, + l10n.myTab, style: const TextStyle( - fontSize: AppDimens.pageTitle, + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, fontWeight: FontWeight.w800, ), ), const Spacer(), - IconButton( + _RoundHeaderButton( + key: const Key('mine-search-button'), tooltip: l10n.search, - onPressed: () => logic.act( + icon: Icons.search_rounded, + onTap: () => logic.act( context, MyPageAction.discover, embedded: widget.embedded, onAction: widget.onAction, ), - icon: const Icon(Icons.search_rounded, size: 23), ), - const SizedBox(width: 6), - const CircleAvatar( - radius: 14, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 19), + const SizedBox(width: 8), + Container( + key: const Key('mine-avatar'), + width: 36, + height: 36, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 18, + ), ), ], ), ), ); - List _accountPage(AppLocalizations l10n) => [ - if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n), - if (widget.signedIn) ...[ - const SizedBox(height: 8), - _wallet(l10n), - const SizedBox(height: 4), - const AccountEntryCard(), - ], - const SizedBox(height: 4), - _menuCard(l10n), - const SizedBox(height: 8), - Text( - l10n.preferencesHeading, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w800, - letterSpacing: 1.1, - ), - ), - const SizedBox(height: 8), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft), - title: Text(l10n.appLanguage), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => logic.showLanguage( - context, - showServerSetting: widget.debugShowSetting, - ), - ), - ]; - - Widget _guestRow(AppLocalizations l10n) { + Widget _userRow(AppLocalizations l10n) { + final user = widget.signedIn ? context.watch().user : null; + final name = widget.signedIn + ? ((user?.nickname.isNotEmpty ?? false) + ? user!.nickname + : l10n.localViewer) + : l10n.localViewer; + final subtitle = widget.signedIn + ? ((user?.uid.isNotEmpty ?? false) + ? 'ID: ${user!.uid}' + : l10n.myAccount) + : l10n.logInHint; return Row( + key: const Key('mine-user-row'), children: [ - const CircleAvatar( - radius: 28, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 30), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - l10n.logInHint, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 22, ), ), - const SizedBox(width: 8), - TextButton( - onPressed: widget.onSignIn, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: AppColors.blue, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: const StadiumBorder(), - ), - child: Text(l10n.logIn), - ), - ], - ); - } - - Widget _signedInRow(AppLocalizations l10n) { - // 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案 - final user = context.watch().user; - final name = (user?.nickname.isNotEmpty ?? false) - ? user!.nickname - : l10n.mockUserName; - final uidText = (user?.uid.isNotEmpty ?? false) - ? 'ID: ${user!.uid}' - : l10n.mockUserId; - return Row( - children: [ - const CircleAvatar( - radius: 28, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 30), - ), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -293,120 +261,520 @@ class _MyPageState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( - uidText, - style: const TextStyle(color: AppColors.muted, fontSize: 12), + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10), ), ], ), ), - TextButton( - onPressed: widget.onSignOut ?? () => _mockSnack(l10n), - style: TextButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: const StadiumBorder(), + if (widget.signedIn) + TextButton( + onPressed: widget.onSignOut, + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + backgroundColor: const Color(0xFF24252A), + minimumSize: const Size(52, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text(l10n.signOut), + ) + else + TextButton( + key: const Key('mine-sign-in'), + onPressed: widget.onSignIn, + style: TextButton.styleFrom( + foregroundColor: AppColors.cream, + backgroundColor: const Color(0xFF24252A), + minimumSize: const Size(64, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text('${l10n.logIn}/${l10n.signUp}'), ), - child: Text(l10n.signOut), - ), ], ); } Widget _wallet(AppLocalizations l10n) { + return InkWell( + key: const Key('mine-wallet'), + onTap: () => _mockSnack(l10n), + borderRadius: BorderRadius.circular(13), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.goldCard, + borderRadius: BorderRadius.circular(13), + ), + child: Row( + children: [ + const Icon( + Icons.monetization_on_rounded, + color: AppColors.gold, + size: 20, + ), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.coins, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '0 ${l10n.coins} · ${l10n.topUpWallet}', + style: const TextStyle( + color: AppColors.accent, + fontSize: 9, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Color(0xFF666666), + size: 16, + ), + ], + ), + ), + ); + } + + WatchProgress? get _continueRecord { + for (final record in logic.recent) { + if (!record.finished && record.hasPosition) return record; + } + return null; + } + + Drama? get _continueDrama { + final record = _continueRecord; + if (record == null) return null; + for (final drama in [...?widget.dramas, widget.drama]) { + if (drama.id == record.dramaId) return drama; + } + return null; + } + + Widget _continueCard( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { return Container( - padding: const EdgeInsets.all(AppDimens.cardPadding), + key: const Key('mine-continue-watching'), + padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(12), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - l10n.myAccount, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800), + _poster(drama, width: 50, height: 64, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.continueWatching, + style: const TextStyle( + color: AppColors.accent, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '${_episodeText(drama, record)} · ' + '${_clock(record.position)} / ${_clock(record.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ), ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _accountTile( - label: l10n.coins, - value: '\$ 0', - action: l10n.topUpWallet, - color: AppColors.gold, - onPressed: () => _mockSnack(l10n), + const SizedBox(width: 8), + SizedBox( + width: 60, + height: 28, + child: FilledButton( + onPressed: () => _resume(drama), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, ), ), - const SizedBox(width: 10), - Expanded( - child: _accountTile( - label: l10n.vipMembership, - value: l10n.notActive, - action: l10n.joinVip, - color: AppColors.blue, - onPressed: () => _mockSnack(l10n), - ), - ), - ], + child: Text(l10n.resume), + ), ), ], ), ); } - void _mockSnack(AppLocalizations l10n) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); + Widget _library(AppLocalizations l10n) { + final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < tabs.length; index++) + Padding( + padding: EdgeInsets.only( + right: index == tabs.length - 1 ? 0 : 20, + ), + child: Semantics( + selected: logic.section == index, + button: true, + child: InkWell( + key: Key('mine-library-tab-$index'), + onTap: () => logic.selectSection(index), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tabs[index], + maxLines: 1, + style: TextStyle( + color: logic.section == index + ? AppColors.blue + : const Color(0xFFE4DBDD), + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 18, + height: 2, + decoration: BoxDecoration( + color: logic.section == index + ? AppColors.blue + : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + if (logic.section == 1 && context.watch().loading) + const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (logic.section == 1) + ..._favoriteCards(l10n) + else if (logic.section == 2) + ..._likedCards(l10n) + else if (logic.recentLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (_recentDramas.isEmpty) + EmptyState( + key: const Key('mine-recent-empty'), + icon: Icons.history_rounded, + title: l10n.noStories, + subtitle: l10n.emptyHint, + compact: true, + ) + else + for (final drama in _recentDramas) ...[ + _historyCard(l10n, drama), + const SizedBox(height: 8), + ], + ], + ); } - Widget _accountTile({ - required String label, - required String value, - required String action, - required Color color, - required VoidCallback onPressed, - }) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.ink, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - border: Border.all(color: color.withValues(alpha: 0.45)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: TextStyle(color: color, fontSize: 12)), - const SizedBox(height: 6), - Text( - value, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: onPressed, - style: FilledButton.styleFrom( - backgroundColor: color, - foregroundColor: color == AppColors.blue - ? Colors.white - : AppColors.onGold, - shape: const StadiumBorder(), + List _favoriteCards(AppLocalizations l10n) { + final favorites = context.watch(); + if (favorites.error != null && favorites.cards.isEmpty) { + return [ + EmptyState( + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + onAction: () => unawaited(favorites.load()), + compact: true, + ), + ]; + } + if (favorites.cards.isEmpty) { + return [ + EmptyState( + key: const Key('mine-saved-empty'), + icon: Icons.bookmark_border_rounded, + title: l10n.noSavedShows, + subtitle: l10n.emptyHint, + compact: true, + ), + ]; + } + return [ + for (final drama in favorites.cards) ...[ + _smallDramaCard(l10n, drama), + const SizedBox(height: 8), + ], + ]; + } + + List _likedCards(AppLocalizations l10n) { + final interactions = context.watch(); + final dramas = [ + ...?widget.dramas, + widget.drama, + ].where((drama) => interactions.isLiked(drama.id)).toList(); + if (dramas.isEmpty) { + return [ + EmptyState( + key: const Key('mine-saved-empty'), + icon: Icons.favorite_border_rounded, + title: l10n.noSavedShows, + subtitle: l10n.emptyHint, + compact: true, + ), + ]; + } + return [ + for (final drama in dramas) ...[ + _smallDramaCard(l10n, drama), + const SizedBox(height: 8), + ], + ]; + } + + Widget _historyCard(AppLocalizations l10n, Drama drama) { + final record = _recordFor(drama.id); + return InkWell( + key: Key('mine-history-card-${drama.id}'), + onTap: () => _resume(drama), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 48, height: 60, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + record == null + ? l10n.episodeCount(drama.episodeCount) + : '${_episodeText(drama, record)} · ' + '${_clock(record.position)} / ${_clock(record.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 9, + ), + ), + ], ), - child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis), ), - ), - ], + Container( + width: 26, + height: 26, + decoration: const BoxDecoration( + color: Color(0xFF313137), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.blue, + size: 15, + ), + ), + ], + ), + ), + ); + } + + Widget _smallDramaCard(AppLocalizations l10n, Drama drama) { + return InkWell( + onTap: () { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + _resume(drama); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 48, height: 60, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 9, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.muted, + size: 16, + ), + ], + ), + ), + ); + } + + Widget _languageRow(AppLocalizations l10n) { + final locale = context.watch(); + final code = locale.locale?.languageCode ?? 'id'; + final value = switch (code) { + 'zh' => l10n.simplifiedChinese, + 'en' => l10n.english, + _ => l10n.bahasaIndonesia, + }; + return InkWell( + key: const Key('mine-language'), + onTap: () => logic.showLanguage( + context, + showServerSetting: widget.debugShowSetting, + ), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon( + Icons.language_rounded, + color: AppColors.muted, + size: 14, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + l10n.appLanguage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + ), + ), + ), + Text( + value, + style: const TextStyle(color: AppColors.accent, fontSize: 10), + ), + const SizedBox(width: 3), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.accent, + size: 16, + ), + ], + ), ), ); } @@ -414,97 +782,42 @@ class _MyPageState extends State { Widget _menuCard(AppLocalizations l10n) { return Material( color: AppColors.card, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: Column( children: [ - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.card_giftcard_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.earnRewards), - trailing: Text( - l10n.freeBonus, - style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), - ), - onTap: () => _mockSnack(l10n), - ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, + _menuTile( + icon: Icons.notifications_none_rounded, + title: l10n.notices, key: const Key('mine-notices'), - leading: const Icon( - Icons.notifications_none_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.notices), - trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const NoticesPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.history_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.watchHistory), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.history_rounded, + title: l10n.watchHistory, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const WatchRecordsPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.favorite_outline_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.myFavorites), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.favorite_outline_rounded, + title: l10n.myFavorites, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const FavoritesPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.download_outlined, - color: AppColors.blueSoft, - ), - title: Text(l10n.downloads), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.download_outlined, + title: l10n.downloads, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DownloadsPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.settings_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.settings), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.settings_rounded, + title: l10n.settings, onTap: () => logic.showLanguage( context, showServerSetting: widget.debugShowSetting, @@ -515,345 +828,30 @@ class _MyPageState extends State { ); } - Widget _library( - AppLocalizations l10n, - Duration position, - Duration total, - double fraction, - ) { - final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; - final filters = [l10n.all, l10n.inProgress, l10n.completed]; - final hasHistory = - logic.filter == 0 || - (logic.filter == 1 && fraction < 0.99) || - (logic.filter == 2 && fraction >= 0.99); - final interactions = context.watch(); - // 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。 - final favorites = context.watch(); - final isWatchlist = logic.section == 1; - final markedDramas = isWatchlist - ? favorites.cards - : (widget.dramas ?? [widget.drama]) - .where((drama) => interactions.isLiked(drama.id)) - .toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < tabs.length; index++) ...[ - Semantics( - selected: logic.section == index, - button: true, - child: InkWell( - onTap: () { - logic.selectSection(index); - // 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐) - if (index == 1) unawaited(favorites.load()); - }, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - decoration: BoxDecoration( - color: logic.section == index - ? AppColors.blue - : AppColors.raised, - borderRadius: BorderRadius.circular( - AppDimens.pillRadius, - ), - ), - child: Text( - tabs[index], - style: const TextStyle( - color: Colors.white, - fontSize: AppDimens.body, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ), - if (index < tabs.length - 1) const SizedBox(width: 18), - ], - ], - ), - ), - const SizedBox(height: 17), - if (logic.section == 0) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < filters.length; index++) - Padding( - padding: const EdgeInsets.only(right: 7), - child: ChoiceChip( - label: Text(filters[index]), - selected: logic.filter == index, - onSelected: (_) => logic.selectFilter(index), - showCheckmark: false, - materialTapTargetSize: MaterialTapTargetSize.padded, - backgroundColor: AppColors.raised, - selectedColor: AppColors.blue, - labelStyle: TextStyle( - color: logic.filter == index - ? Colors.white - : AppColors.muted, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w700, - ), - side: BorderSide.none, - ), - ), - ], - ), - ), - if (logic.section == 0) const SizedBox(height: 11), - if (logic.section == 0 && hasHistory) - InkWell( - onTap: () => logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ), - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: Container( - padding: const EdgeInsets.all(AppDimens.innerPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Row( - children: [ - _poster(width: 74, height: 103), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, widget.drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 5), - Text( - l10n.dramaTag, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(height: 32), - Text( - '${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.blueSoft, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - const CircleAvatar( - radius: 15, - backgroundColor: AppColors.raised, - child: Icon( - Icons.play_arrow_rounded, - color: _accent, - size: 20, - ), - ), - ], - ), - ), - ) - else if (isWatchlist && favorites.loading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 22), - child: Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (isWatchlist && favorites.error != null) - EmptyState( - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - onAction: () => unawaited(favorites.load()), - compact: true, - ) - else if (logic.section != 0 && markedDramas.isNotEmpty) - for (final drama in markedDramas) ...[ - _markedDramaCard(drama, l10n), - const SizedBox(height: 9), - ] - else - EmptyState( - key: const Key('mine-saved-empty'), - icon: Icons.bookmark_border_rounded, - title: l10n.noSavedShows, - subtitle: l10n.emptyHint, - ), - // 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧; - // 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。 - if (logic.section == 0) ...[ - const SizedBox(height: 18), - Text( - l10n.history, - style: const TextStyle( - color: Colors.white, - fontSize: AppDimens.body, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 9), - if (logic.positionFailed) - Row( - children: [ - Expanded( - child: Text( - l10n.progressError, - style: const TextStyle(color: AppColors.cream), - ), - ), - TextButton( - onPressed: logic.refreshPosition, - child: Text(l10n.retry), - ), - ], - ) - else if (logic.recentLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (logic.recentError != null) - Row( - children: [ - Expanded( - child: Text( - errorText(l10n, logic.recentError, l10n.progressError), - style: const TextStyle(color: AppColors.cream), - ), - ), - TextButton( - onPressed: () => unawaited(logic.refreshRecent()), - child: Text(l10n.retry), - ), - ], - ) - else if (_recentDramas.isEmpty) - EmptyState( - key: const Key('mine-recent-empty'), - icon: Icons.history_rounded, - title: l10n.noStories, - subtitle: l10n.emptyHint, - ) - else - for (final drama in _recentDramas) ...[ - _markedDramaCard(drama, l10n), - const SizedBox(height: 9), - ], - ], - ], + Widget _menuTile({ + required IconData icon, + required String title, + required VoidCallback onTap, + Key? key, + }) { + return ListTile( + key: key, + dense: true, + minTileHeight: 44, + leading: Icon(icon, color: AppColors.secondary, size: 17), + title: Text( + title, + style: const TextStyle(color: AppColors.onSurface, fontSize: 11), + ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.muted, + size: 16, + ), + onTap: onTap, ); } - /// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。 - List get _recentDramas { - final pool = [...?widget.dramas, widget.drama]; - final result = []; - for (final item in logic.recent) { - for (final drama in pool) { - if (drama.id == item.dramaId && - !result.any((picked) => picked.id == drama.id)) { - result.add(drama); - } - } - } - return result; - } - - Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell( - onTap: () { - if (widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else if (drama.id == widget.drama.id) { - logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ); - } - }, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: Container( - padding: const EdgeInsets.all(AppDimens.innerPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: RemoteImage(drama.posterUrl, width: 74, height: 103), - ), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 7), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - const Icon(Icons.chevron_right_rounded, color: AppColors.cream), - ], - ), - ), - ); - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( @@ -891,15 +889,101 @@ class _MyPageState extends State { ], ); - Widget _poster({required double width, required double height}) => ClipRRect( - borderRadius: BorderRadius.circular(7), - child: RemoteImage(widget.drama.posterUrl, width: width, height: height), + List get _recentDramas { + final pool = [...?widget.dramas, widget.drama]; + final result = []; + for (final item in logic.recent) { + for (final drama in pool) { + if (drama.id == item.dramaId && + !result.any((picked) => picked.id == drama.id)) { + result.add(drama); + } + } + } + return result; + } + + WatchProgress? _recordFor(String dramaId) { + for (final record in logic.recent) { + if (record.dramaId == dramaId) return record; + } + return null; + } + + String _episodeText(Drama drama, WatchProgress record) { + for (final episode in drama.episodes) { + if (episode.id == record.episodeId) { + return AppLocalizations.of( + context, + )!.episodeOfTotal(episode.number, drama.episodeCount); + } + } + return AppLocalizations.of(context)!.episodeCount(drama.episodeCount); + } + + void _resume(Drama drama) { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + logic.act( + context, + MyPageAction.resume, + embedded: widget.embedded, + onAction: widget.onAction, + ); + } + } + + void _mockSnack(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); + } + + Widget _poster( + Drama drama, { + required double width, + required double height, + required double radius, + }) => ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: RemoteImage(drama.posterUrl, width: width, height: height), ); + + String _clock(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } } -String _clock(Duration duration) { - final seconds = duration.inSeconds; - return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; -} +class _RoundHeaderButton extends StatelessWidget { + const _RoundHeaderButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onTap, + }); -const _accent = AppColors.blueSoft; + final String tooltip; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), + ), + ); + } +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index aa4e50b..321a3b6 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; @@ -69,12 +68,11 @@ class PlayerLogic extends ChangeNotifier { List sessionEpisodes = const []; int pageRequest = 0; bool muted = false; + double playbackSpeed = 1.0; bool stoppedAtFeedEnd = false; VideoPlayerPort? scrubbingPlayer; - bool fullscreen = false; bool pip = false; bool pipPreparing = false; - bool screenCleared = false; final Map pickedEpisode = {}; bool Function() isMounted = () => false; @@ -98,9 +96,9 @@ class PlayerLogic extends ChangeNotifier { /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 bool _started = false; - bool get immersive => fullscreen || pip || pipPreparing; + bool get immersive => pip || pipPreparing; - bool get chromeHidden => immersive || screenCleared; + bool get chromeHidden => immersive; Episode displayEpisode(Drama drama, Episode fallback) { final number = pickedEpisode[drama.id]; @@ -111,13 +109,6 @@ class PlayerLogic extends ChangeNotifier { return fallback; } - void setScreenCleared(bool value) { - if (screenCleared == value) return; - screenCleared = value; - notify(); - notifyImmersive(); - } - void notify() { if (!_disposed) notifyListeners(); } @@ -262,13 +253,7 @@ class PlayerLogic extends ChangeNotifier { final request = backRequest; WidgetsBinding.instance.addPostFrameCallback((_) { if (isMounted() && backRequest == request) { - if (fullscreen) { - unawaited(setFullscreen(false)); - } else if (screenCleared) { - setScreenCleared(false); - } else { - onMenu?.call(); - } + onMenu?.call(); } }); } @@ -295,12 +280,6 @@ class PlayerLogic extends ChangeNotifier { if (_disposed) return; _disposed = true; pictureInPicture.setStateListener(null); - if (fullscreen) { - unawaited( - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), - ); - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } unawaited(saveCurrentProgress()); if (_started) { session.setRouteVisible(false); @@ -313,32 +292,6 @@ class PlayerLogic extends ChangeNotifier { void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); - Future setFullscreen(bool value) async { - if (fullscreen == value || !isMounted()) return; - fullscreen = value; - notify(); - notifyImmersive(); - try { - await SystemChrome.setPreferredOrientations( - value - ? [ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ] - : [DeviceOrientation.portraitUp], - ); - await SystemChrome.setEnabledSystemUIMode( - value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, - ); - } catch (_) { - if (value && isMounted()) { - fullscreen = false; - notify(); - notifyImmersive(); - } - } - } - Future enterPip(BuildContext context) async { if (pip || pipPreparing) return; if (session.active is! VideoPlayerPort) { @@ -347,7 +300,6 @@ class PlayerLogic extends ChangeNotifier { ); return; } - if (fullscreen) await setFullscreen(false); pipPreparing = true; notify(); notifyImmersive(); @@ -390,6 +342,7 @@ class PlayerLogic extends ChangeNotifier { Future preparePlayer(Episode episode, PlayerPort player) async { if (player is! VideoPlayerPort) return; try { + await player.controller.setPlaybackSpeed(playbackSpeed); await pendingWrite; // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 if (resumePoint == null) await loadResumePoint(); @@ -409,6 +362,7 @@ class PlayerLogic extends ChangeNotifier { final index = session.currentIndex; final drama = queue.entryAt(index).drama; await player.controller.setVolume(muted ? 0 : 1); + await player.controller.setPlaybackSpeed(playbackSpeed); if (session.active != player) return; trackedPlayer = player; trackedEpisode = episode; @@ -531,6 +485,18 @@ class PlayerLogic extends ChangeNotifier { : null; } + bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; + + Future playNextEpisode() async { + final next = nextVisibleIndex(session.currentIndex); + if (next == null || !pages.hasClients) return; + await pages.animateToPage( + visibleIndices.indexOf(next), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; @@ -824,6 +790,15 @@ class PlayerLogic extends ChangeNotifier { notify(); } + Future setPlaybackSpeed(double value) async { + playbackSpeed = value; + final active = session.active; + if (active is VideoPlayerPort && active.controller.value.isInitialized) { + await active.controller.setPlaybackSpeed(value); + } + notify(); + } + Future showComments(BuildContext context, Drama drama) async { session.setOverlayOpen(true); await session.playbackSettled; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 94f744a..20ac336 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -18,12 +18,15 @@ import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; +import 'widgets/home_feed_overlay.dart'; +import 'widgets/exit_clear_screen_button.dart'; +import 'widgets/player_center_toggle.dart'; +import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -68,6 +71,8 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; + bool _chromeVisible = true; + double _playbackSpeed = 1.0; @override void initState() { @@ -161,7 +166,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; final ready = videoPort?.controller.value.isInitialized ?? false; - final theater = logic.fullscreen || logic.pip || logic.pipPreparing; + final theater = logic.pip || logic.pipPreparing; Widget? video; if (ready) { video = GestureDetector( @@ -186,9 +191,10 @@ class _PlayerPageState extends State with WidgetsBindingObserver { children: [ const ColoredBox(color: AppColors.ink), ?video, - if (!logic.chromeHidden) - const IgnorePointer( - child: DecoratedBox( + if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible)) + IgnorePointer( + key: widget.homeMode ? const Key('home-feed-scrim') : null, + child: const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -204,6 +210,15 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), + if (widget.homeMode && _chromeVisible && !logic.chromeHidden) + Positioned.fill( + child: GestureDetector( + key: const Key('home-feed-video-toggle'), + behavior: HitTestBehavior.opaque, + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + ), + ), // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) const Center( @@ -300,41 +315,60 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), - if (selected && ready && !logic.session.userWantsPlay && !logic.pip) - const Center( - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - child: Padding( - padding: EdgeInsets.all(10), - child: Icon( - Icons.play_arrow_rounded, - size: 48, - color: Colors.white, - ), - ), - ), - ), - ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() - else if (logic.fullscreen) - _FullscreenControls( - l10n: l10n, - controller: ready ? videoPort!.controller : null, - onClose: () => unawaited(logic.setFullscreen(false)), - 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) + else if (widget.homeMode) + _chromeVisible + ? HomeFeedOverlay( + l10n: l10n, + drama: drama, + episode: episode, + 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: () => setState(() => _chromeVisible = false), + onSearch: + widget.onSearch ?? () => logic.showDiscover(context), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + ) + : const SizedBox.shrink() + else _PlaybackOverlay( l10n: l10n, drama: drama, @@ -346,9 +380,18 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onBack: widget.embedded ? null : () => unawaited(logic.leave(context)), - onDiscover: () => logic.showDiscover(context), onEpisodes: () => logic.showEpisodes(context), - onMy: () => logic.showMy(context), + onNext: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + playing: logic.session.userWantsPlay, + playbackSpeed: _playbackSpeed, + onSpeedTap: () => unawaited(_openSpeedSheet(context)), + controlsVisible: _chromeVisible, + onClear: () => setState(() => _chromeVisible = false), + onExitClear: () => setState(() => _chromeVisible = true), + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) onLike: () async { if (await ensureSignedIn(context)) { @@ -382,7 +425,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onDownload: () => requestEpisodeDownload(context, episode.id), onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), - onClear: () => logic.setScreenCleared(true), onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, @@ -390,81 +432,58 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, - showBottomNav: false, ), - if (logic.screenCleared) - SafeArea( - child: Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.only(top: 4, right: 8), - child: IconButton( - tooltip: l10n.exitClearScreen, - onPressed: () => logic.setScreenCleared(false), - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.close_fullscreen_rounded), - ), + if (widget.homeMode && !_chromeVisible) + Positioned.fill( + child: GestureDetector( + key: const Key('home-feed-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + ), + ), + if (widget.homeMode && !_chromeVisible) + Positioned( + top: 0, + right: 0, + child: SafeArea( + left: false, + bottom: false, + minimum: const EdgeInsets.only(top: 8, right: 12), + child: ExitClearScreenButton( + key: const Key('home-feed-exit-clear-screen'), + tooltip: l10n.exitClearScreen, + onPressed: () => setState(() => _chromeVisible = true), ), ), ), + if (widget.homeMode && + !logic.chromeHidden && + selected && + !logic.session.userWantsPlay) + Center( + child: PlayerCenterToggle( + key: const Key('home-feed-center-play'), + playing: false, + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + ), + ), ], ); } -} -class _FullscreenControls extends StatelessWidget { - const _FullscreenControls({ - required this.l10n, - required this.controller, - required this.onClose, - required this.onPip, - required this.onScrubStart, - required this.onSeek, - }); - - final AppLocalizations l10n; - final VideoPlayerController? controller; - final VoidCallback onClose; - final VoidCallback? onPip; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; - - @override - Widget build(BuildContext context) => SafeArea( - child: Column( - children: [ - Row( - children: [ - IconButton( - tooltip: l10n.exitFullscreen, - onPressed: onClose, - icon: const Icon(Icons.close_fullscreen_rounded), - ), - const Spacer(), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - icon: const Icon(Icons.picture_in_picture_alt_outlined), - ), - ], - ), - const Spacer(), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - onScrubStart: onScrubStart, - onSeek: onSeek, - ), - ), - ], - ), - ); + Future _openSpeedSheet(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed), + ); + if (selected == null || !mounted) return; + setState(() => _playbackSpeed = selected); + await logic.setPlaybackSpeed(selected); + } } /// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 @@ -589,7 +608,7 @@ class _UnlockCard extends StatelessWidget { } } -class _PlaybackOverlay extends StatelessWidget { +class _PlaybackOverlay extends StatefulWidget { const _PlaybackOverlay({ required this.l10n, required this.drama, @@ -597,20 +616,24 @@ class _PlaybackOverlay extends StatelessWidget { required this.controller, required this.liked, required this.saved, + required this.playing, + required this.playbackSpeed, + required this.onSpeedTap, + required this.controlsVisible, + required this.onTogglePlay, + required this.onClear, + required this.onExitClear, required this.onBack, - required this.onDiscover, required this.onEpisodes, - required this.onMy, + required this.onComments, required this.onLike, required this.onSave, required this.onDownload, - required this.onComments, required this.onShare, - required this.onClear, required this.onPip, required this.onScrubStart, required this.onSeek, - required this.showBottomNav, + this.onNext, }); final AppLocalizations l10n; @@ -619,230 +642,446 @@ class _PlaybackOverlay extends StatelessWidget { final VideoPlayerController? controller; final bool liked; final bool saved; + final bool playing; + final double playbackSpeed; + final VoidCallback onSpeedTap; + final bool controlsVisible; + final VoidCallback onTogglePlay; + final VoidCallback onClear; + final VoidCallback onExitClear; final VoidCallback? onBack; - final VoidCallback onDiscover; final VoidCallback onEpisodes; - final VoidCallback onMy; + final VoidCallback onComments; + final VoidCallback? onNext; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onDownload; - final VoidCallback onComments; final VoidCallback onShare; - final VoidCallback onClear; final VoidCallback? onPip; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; - final bool showBottomNav; @override - Widget build(BuildContext context) => SafeArea( - bottom: showBottomNav, - child: Stack( - children: [ - Column( - children: [ - const Spacer(), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 8, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _info()), - _rail(), - ], - ), - ), - const SizedBox(height: 4), - Padding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - 20 + MediaQuery.paddingOf(context).bottom, - ), - child: SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - onScrubStart: onScrubStart, - onSeek: onSeek, - ), - ), - if (showBottomNav) _fallbackNav(), - ], - ), - if (onBack != null) - Positioned( - top: 4, - left: 8, - child: IconButton( - tooltip: l10n.backToSeries, - onPressed: onBack, - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.arrow_back_rounded), - ), - ), - Positioned( - top: 4, - right: 8, - child: IconButton( - tooltip: l10n.clearScreen, - onPressed: onClear, - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.fit_screen_outlined), - ), - ), - ], - ), - ); - - Widget _info() => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w800, - height: 1.15, - shadows: [Shadow(blurRadius: 12, color: Colors.black)], - ), - ), - const SizedBox(height: 6), - Text( - '${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - ], - ); - - Widget _rail() => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _RailAction( - key: const Key('rail-like'), - icon: liked ? Icons.favorite : Icons.favorite_border, - label: l10n.like, - selected: liked, - onTap: onLike, - ), - _RailAction( - key: const Key('rail-save'), - icon: saved ? Icons.bookmark : Icons.bookmark_border, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - key: const Key('rail-download'), - icon: Icons.download_outlined, - label: l10n.downloads, - onTap: onDownload, - ), - _RailAction( - icon: Icons.grid_view_outlined, - label: l10n.episodes, - onTap: onEpisodes, - ), - _RailAction( - key: const Key('rail-comments'), - icon: Icons.chat_bubble_outline, - label: l10n.comments, - onTap: onComments, - ), - _RailAction( - key: const Key('rail-share'), - icon: Icons.share_outlined, - label: l10n.share, - onTap: onShare, - ), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _railTapSize, - height: _railTapSize, - ), - color: AppColors.cream, - icon: const Icon( - Icons.picture_in_picture_alt_outlined, - size: _railIconSize, - ), - ), - ], - ); - - Widget _fallbackNav() => AppBottomNav( - items: [ - AppBottomNavItem( - tabKey: const Key('player-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, - label: l10n.homeTab, - selected: true, - onTap: () {}, - ), - AppBottomNavItem( - tabKey: const Key('player-nav-discover'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, - onTap: onDiscover, - ), - AppBottomNavItem( - tabKey: const Key('player-nav-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, - label: l10n.myTab, - onTap: onMy, - ), - ], - ); + State<_PlaybackOverlay> createState() => _PlaybackOverlayState(); } -const double _railIconSize = 28; -const double _railIconWeight = 300; -const double _railTapSize = 44; +class _PlaybackOverlayState extends State<_PlaybackOverlay> { + @override + Widget build(BuildContext context) { + return SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + key: const Key('player-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: widget.onTogglePlay, + ), + ), + if (widget.controlsVisible) + Column( + key: const Key('player-controls-visible'), + children: [ + _topBar(context), + const Spacer(), + Padding( + key: const Key('player-controls-bottom'), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 12), + child: Column( + children: [ + SeekProgressBar( + key: const Key('player-progress'), + valueListenable: widget.controller, + semanticsLabel: widget.l10n.seekVideo, + showTimes: true, + onScrubStart: widget.onScrubStart, + onSeek: widget.onSeek, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _PlayerAction( + key: const Key('player-episodes'), + icon: Icons.view_list_rounded, + label: widget.l10n.episodes, + onTap: widget.onEpisodes, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _PlayerAction( + key: const Key('player-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: widget.l10n.comments, + onTap: widget.onComments, + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 32, + child: FilledButton( + key: const Key('player-next'), + onPressed: widget.onNext, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.subtle, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + widget.l10n.nextEpisode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.skip_next_rounded, + size: 14, + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + PlayerFeaturedComment( + drama: widget.drama, + onViewAll: widget.onComments, + ), + ], + ), + ), + ], + ), + if (!widget.controlsVisible) + Positioned( + top: 8, + right: 12, + child: ExitClearScreenButton( + key: const Key('player-exit-clear-screen'), + tooltip: widget.l10n.exitClearScreen, + onPressed: widget.onExitClear, + ), + ), + if (!widget.playing) + Center( + child: PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: false, + onTap: widget.onTogglePlay, + ), + ), + ], + ), + ); + } -class _RailAction extends StatelessWidget { - const _RailAction({ + Widget _topBar(BuildContext context) { + return SizedBox( + key: const Key('player-controls-top'), + height: 46, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + if (widget.onBack != null) + _TopAction( + key: const Key('player-back'), + tooltip: widget.l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: widget.onBack!, + ) + else + const SizedBox(width: 36), + const SizedBox(width: 8), + Expanded( + child: Text( + '${localizedDramaTitle(widget.l10n, widget.drama)} · ' + '${widget.l10n.episodeLabel(widget.episode.number)}', + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + _TopAction( + key: const Key('player-clear-screen'), + tooltip: widget.l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: widget.onClear, + ), + const SizedBox(width: 8), + _TopAction( + key: const Key('player-more'), + tooltip: widget.l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ), + ); + } + + 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('player-more-speed'), + leading: const Icon( + Icons.speed_rounded, + color: AppColors.secondary, + ), + title: Text(widget.l10n.playbackSpeed), + trailing: Text( + _speedLabel(widget.playbackSpeed), + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onSpeedTap(); + }); + }, + ), + ListTile( + key: const Key('player-more-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, + ), + title: Text(widget.l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + widget.onDownload(); + }, + ), + if (widget.onPip != null) + ListTile( + key: const Key('player-more-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(widget.l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + widget.onPip!(); + }, + ), + ListTile( + key: const Key('player-more-like'), + leading: Icon( + widget.liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: widget.liked ? AppColors.blue : AppColors.muted, + ), + title: Text(widget.l10n.like), + onTap: () { + Navigator.pop(sheetContext); + widget.onLike(); + }, + ), + ListTile( + key: const Key('player-more-favorite'), + leading: Icon( + widget.saved ? Icons.star_rounded : Icons.star_border_rounded, + color: widget.saved ? AppColors.blue : AppColors.muted, + ), + title: Text(widget.l10n.favorite), + onTap: () { + Navigator.pop(sheetContext); + widget.onSave(); + }, + ), + ListTile( + key: const Key('player-more-share'), + leading: const Icon(Icons.share_outlined, color: AppColors.muted), + title: Text(widget.l10n.share), + onTap: () { + Navigator.pop(sheetContext); + widget.onShare(); + }, + ), + ], + ), + ), + ); + } +} + +const List _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + +String _speedLabel(double value) => '${value.toString()}x'; + +class _PlaybackSpeedSheet extends StatelessWidget { + const _PlaybackSpeedSheet({required this.selected}); + + final double selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SafeArea( + child: Column( + key: const Key('player-speed-sheet'), + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackSpeed, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + for (final value in _speedOptions) + ListTile( + key: Key('player-speed-${value.toString()}'), + dense: true, + tileColor: value == selected + ? AppColors.blue.withValues(alpha: 0.12) + : null, + title: Text( + _speedLabel(value), + style: TextStyle( + color: value == selected ? AppColors.blue : AppColors.cream, + fontSize: 13, + fontWeight: value == selected + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + trailing: value == selected + ? const Icon( + Icons.check_rounded, + color: AppColors.blue, + size: 18, + ) + : null, + onTap: () => Navigator.pop(context, value), + ), + ], + ), + ); + } +} + +class _TopAction extends StatelessWidget { + const _TopAction({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _PlayerAction extends StatelessWidget { + const _PlayerAction({ super.key, required this.icon, required this.label, required this.onTap, - this.selected = false, }); + final IconData icon; final String label; - final VoidCallback? onTap; - final bool selected; + final VoidCallback onTap; @override - Widget build(BuildContext context) => IconButton( - tooltip: label, - onPressed: onTap, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _railTapSize, - height: _railTapSize, - ), - color: selected ? AppColors.blueSoft : AppColors.cream, - icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0), - ); + Widget build(BuildContext context) { + return Material( + color: const Color(0xFF2A2A30), + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: AppColors.secondary), + const SizedBox(width: 3), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/pages/player/widgets/exit_clear_screen_button.dart b/lib/pages/player/widgets/exit_clear_screen_button.dart new file mode 100644 index 0000000..a2b1c51 --- /dev/null +++ b/lib/pages/player/widgets/exit_clear_screen_button.dart @@ -0,0 +1,34 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +/// Shared floating action used to leave clear-screen mode. +class ExitClearScreenButton extends StatelessWidget { + const ExitClearScreenButton({ + super.key, + required this.tooltip, + required this.onPressed, + }); + + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0x80000000), + foregroundColor: Colors.white, + shape: const CircleBorder(), + ), + icon: const Icon(Icons.fullscreen_exit_rounded, size: 18), + ), + ); + } +} 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..3fc5c00 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -0,0 +1,422 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/glass_panel.dart'; +import '../../../l10n/app_localizations.dart'; + +/// Home feed layer from `docs/移动端设计预览.html` screen 01. +class HomeFeedOverlay extends StatelessWidget { + const HomeFeedOverlay({ + super.key, + required this.l10n, + required this.drama, + required this.episode, + required this.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, + this.onPip, + }); + + final AppLocalizations l10n; + final Drama drama; + final Episode episode; + 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; + + @override + Widget build(BuildContext context) { + return SafeArea( + bottom: false, + child: Column( + children: [ + _header(context), + Expanded( + child: Align( + alignment: const Alignment(1.0, 0.45), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 12, 16), + child: _rail(), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + child: _infoCard(), + ), + ], + ), + ); + } + + Widget _header(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 6, 10, 4), + child: Row( + children: [ + Expanded( + child: Text( + localizedDramaTitle(l10n, drama), + key: const Key('home-feed-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), + ), + ), + _RoundIcon( + key: const Key('home-feed-search'), + tooltip: l10n.search, + icon: Icons.search_rounded, + onPressed: onSearch, + ), + const SizedBox(width: 8), + _RoundIcon( + key: const Key('home-feed-clear-screen'), + tooltip: l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: onClear, + ), + const SizedBox(width: 8), + _RoundIcon( + key: const Key('home-feed-more'), + tooltip: l10n.more, + icon: Icons.menu_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ); + } + + Widget _rail() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _RailItem( + key: const Key('rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + label: l10n.like, + selected: liked, + onTap: onLike, + ), + _RailItem( + key: const Key('rail-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + ), + _RailItem( + key: const Key('rail-share'), + icon: Icons.ios_share_rounded, + label: l10n.share, + onTap: onShare, + ), + _RailItem( + key: const Key('rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: l10n.favorite, + selected: saved, + onTap: onSave, + ), + ], + ); + } + + Widget _infoCard() { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; + return GlassPanel( + key: const Key('home-feed-info-card'), + radius: 14, + blur: 6, + padding: const EdgeInsets.all(12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xBF0F0F14), Color(0xA60F0F14)], + ), + borderColor: Colors.white.withValues(alpha: 0.06), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (genre.isNotEmpty) + _tag( + genre, + background: AppColors.blue, + foreground: AppColors.onPrimary, + ), + if (drama.category.isNotEmpty && drama.category != genre) + _tag( + drama.category, + background: const Color(0xFF333333), + foreground: const Color(0xFFDDDDDD), + ), + Text( + l10n.episodeOfTotal(episode.number, drama.episodeCount), + key: const Key('home-feed-episode'), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ), + const SizedBox(height: 8), + Text( + episode.title.trim().isEmpty + ? localizedDramaTitle(l10n, drama) + : episode.title, + key: const Key('home-feed-episode-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: SizedBox( + height: 34, + child: FilledButton.icon( + key: const Key('home-feed-follow-series'), + onPressed: onSave, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 16), + label: Text( + saved ? l10n.followingTab : l10n.followSeries, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 34, + child: FilledButton( + key: const Key('home-feed-episodes'), + onPressed: onEpisodes, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2A2A30), + foregroundColor: AppColors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: Text( + l10n.episodes, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _tag( + String label, { + required Color background, + required Color foreground, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: foreground, + fontSize: 8, + fontWeight: FontWeight.w800, + ), + ), + ); + } + + 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.secondary, + ), + 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.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + onPip!(); + }, + ), + ], + ), + ), + ); + } +} + +class _RoundIcon extends StatelessWidget { + const _RoundIcon({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xCC2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _RailItem extends StatelessWidget { + const _RailItem({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.selected = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool selected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Semantics( + button: true, + label: label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: SizedBox( + width: 44, + child: Column( + children: [ + Icon( + icon, + size: 22, + color: selected ? AppColors.blue : AppColors.onSurface, + ), + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 9, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart new file mode 100644 index 0000000..b15e355 --- /dev/null +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -0,0 +1,41 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +/// Shared play/pause control used by the home feed and full-screen player. +class PlayerCenterToggle extends StatelessWidget { + const PlayerCenterToggle({ + super.key, + required this.playing, + required this.onTap, + }); + + final bool playing; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: playing ? 'Pause' : 'Play', + child: GestureDetector( + onTap: onTap, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: Icon( + playing ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 26, + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart new file mode 100644 index 0000000..e888ba8 --- /dev/null +++ b/lib/pages/player/widgets/player_featured_comment.dart @@ -0,0 +1,158 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/repositories/comment_repository.dart'; +import '../../../core/data/repositories/interaction_repository.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +class PlayerFeaturedComment extends StatefulWidget { + const PlayerFeaturedComment({ + super.key, + required this.drama, + required this.onViewAll, + }); + + final Drama drama; + final VoidCallback onViewAll; + + @override + State createState() => _PlayerFeaturedCommentState(); +} + +class _PlayerFeaturedCommentState extends State { + late final CommentController _controller; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + dramaId: widget.drama.id, + ); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + if (_controller.comments.isEmpty) return const SizedBox.shrink(); + return _card(context, _controller.comments.first); + }, + ); + } + + Widget _card(BuildContext context, DramaComment comment) { + final l10n = AppLocalizations.of(context)!; + final time = _time(l10n, comment.createdAt); + return InkWell( + key: const Key('player-featured-comment'), + onTap: widget.onViewAll, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.secondary.withValues(alpha: 0.2), + ), + child: Text( + _initial(comment.nickname), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + comment.nickname.isEmpty + ? l10n.localViewer + : comment.nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + if (time != null) ...[ + const SizedBox(width: 4), + Text( + '· $time', + style: const TextStyle( + color: Color(0xFF777777), + fontSize: 9, + ), + ), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 5), + Text( + comment.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFDDDDDD), + fontSize: 10, + height: 1.5, + ), + ), + ], + ), + ), + ); + } + + String? _time(AppLocalizations l10n, DateTime? createdAt) { + if (createdAt == null) return null; + final diff = DateTime.now().difference(createdAt); + if (diff.inMinutes < 1) return null; + return l10n.minutesAgo(diff.inMinutes); + } + + String _initial(String nickname) { + final trimmed = nickname.trim(); + return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + } +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 1a1cf1a..6680fa1 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) + : const Color(0xFF474248), + 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.lock b/pubspec.lock index 7dc0e15..2db1ec3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: @@ -208,10 +208,26 @@ packages: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.flutter-io.cn" source: hosted - version: "0.20.2" + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" json_annotation: dependency: transitive description: @@ -256,10 +272,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -272,10 +288,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.flutter-io.cn" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -292,6 +308,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -368,26 +392,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" url: "https://pub.flutter-io.cn" source: hosted - version: "2.3.3" + version: "2.5.6" shared_preferences_android: - dependency: "direct main" - description: - name: shared_preferences_android - sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.28" - shared_preferences_foundation: dependency: transitive description: - name: shared_preferences_foundation - sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + name: shared_preferences_android + sha256: b90caa27669d5f3ef9918d55343b44000f088ee9d4685ed822310848004c793e url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.7" + version: "2.5.0" + shared_preferences_foundation: + dependency: "direct overridden" + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -397,7 +421,7 @@ packages: source: hosted version: "2.4.1" shared_preferences_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences_platform_interface sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" @@ -437,10 +461,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.flutter-io.cn" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -477,10 +501,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -493,10 +517,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0" + version: "2.4.3" video_player: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 092293d..d184d02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,8 +40,7 @@ dependencies: dio: ^5.7.0 provider: ^6.1.5+1 video_player: ^2.14.0 - shared_preferences: 2.3.3 - shared_preferences_android: 2.4.28 + shared_preferences: ^2.5.5 flutter_localizations: sdk: flutter @@ -60,8 +59,13 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + shared_preferences_platform_interface: ^2.4.2 flutter_launcher_icons: ^0.14.4 +# Xcode 16.2 / Swift 6.0.3 cannot parse the trailing comma in foundation 2.6.0. +dependency_overrides: + shared_preferences_foundation: 2.5.6 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -78,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/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index 7097af0..d0612ca 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -46,6 +46,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-account-card')), findsOneWidget); expect(find.byKey(const Key('mine-account-profile')), findsOneWidget); diff --git a/test/auth/auth_k21_test.dart b/test/auth/auth_k21_test.dart index 14fefef..6312819 100644 --- a/test/auth/auth_k21_test.dart +++ b/test/auth/auth_k21_test.dart @@ -82,4 +82,7 @@ Future _pumpAuth(WidgetTester tester) async { ), ); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); } diff --git a/test/auth/auth_landing_test.dart b/test/auth/auth_landing_test.dart new file mode 100644 index 0000000..af8b6d7 --- /dev/null +++ b/test/auth/auth_landing_test.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; + +import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + testWidgets('should_show_three_login_entries_and_open_email_form', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final auth = AuthController( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ), + AuthSessionStore(), + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AuthPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-legal')), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-entry-google'))); + await tester.pumpAndSettle(); + expect(find.text('Coming soon'), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('auth-email')), findsOneWidget); + expect(find.byKey(const Key('auth-password')), findsOneWidget); + }); +} diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index ffa24e0..3a5851c 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -59,6 +59,9 @@ void main() { ), ); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); } testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', ( diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index 5f5e052..10c94f4 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -29,6 +29,7 @@ void main() { WidgetTester tester, { AuthApi? api, FakeHaptics? haptics, + bool openEmail = true, }) async { final auth = AuthController( api ?? @@ -57,20 +58,32 @@ void main() { ), ); await tester.pump(const Duration(milliseconds: 220)); + if (openEmail) { + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + } } testWidgets( 'should_render_branded_dark_layout_with_approved_tokens_when_page_opens', (tester) async { - await pumpAuthPage(tester); + await pumpAuthPage(tester, openEmail: false); - expect(find.byKey(const Key('auth-brand')), findsOneWidget); + expect(find.byKey(const Key('auth-landing')), findsOneWidget); expect(find.text('FruitShorts'), findsOneWidget); - expect(find.text('Short dramas made for Indonesia'), findsOneWidget); expect( - tester.getSize(find.byKey(const Key('auth-brand-mark'))), - const Size(48, 48), + find.text('Sign in to sync progress, favorites and coins'), + findsOneWidget, ); + expect( + tester.getSize(find.byKey(const Key('auth-brand-logo'))), + const Size(64, 64), + ); + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-legal')), findsOneWidget); final glow = tester.widget( find.byKey(const Key('auth-background-glow')), @@ -81,6 +94,9 @@ void main() { expect(color.a, lessThanOrEqualTo(0.18)); } + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + final card = tester.widget(find.byKey(const Key('auth-card'))); final cardDecoration = card.decoration! as BoxDecoration; expect(cardDecoration.color, AppColors.card); diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index aa80ea9..4774d95 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -81,7 +81,10 @@ void main() { isNull, ); - expect(await ServerEndpoint.save(' http://39.162.50.99:8081/ '), isTrue); + expect( + await ServerEndpoint.save(' http://39.162.50.99:8081/ '), + isTrue, + ); expect( await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), 'http://39.162.50.99:8081', diff --git a/test/discover/cover_carousel_test.dart b/test/discover/cover_carousel_test.dart deleted file mode 100644 index 75040b6..0000000 --- a/test/discover/cover_carousel_test.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - List items(int count) => [ - for (var index = 0; index < count; index++) - CoverItem( - title: 'Banner $index', - subtitle: 'Subtitle $index', - image: '/files/banner-$index.png', - onOpen: _noop, - ), - ]; - - testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', ( - tester, - ) async { - await tester.pumpWidget( - _localizedApp( - CoverCarousel( - items: [ - for (final title in ['The Signal', 'City Lights', 'Vengeance']) - CoverItem( - title: title, - subtitle: 'Mock', - image: '/files/poster.png', - onOpen: _noop, - ), - ], - ), - ), - ); - await tester.pump(); - - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('Mock'), findsOneWidget); - - final box = tester.getRect(find.byType(CoverCarousel)); - final gesture = await tester.startGesture( - Offset(box.right - 40, box.center.dy), - ); - await gesture.moveBy(const Offset(-500, 0)); - await tester.pump(); - - final turned = tester - .widgetList(find.byType(Transform)) - .any((transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15); - expect(turned, isTrue); - - await gesture.moveBy(const Offset(-200, 0)); - await gesture.up(); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 450)); - - expect(find.text('City Lights'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets('should_auto_advance_and_show_dots_when_multiple_items_exist', ( - tester, - ) async { - await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(3)))); - await tester.pump(); - - expect(find.text('Banner 0'), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-2')), findsOneWidget); - - await tester.pump(const Duration(milliseconds: 4500)); - await tester.pump(const Duration(milliseconds: 500)); - - expect(find.text('Banner 1'), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); - }); - - testWidgets('should_not_schedule_auto_advance_when_only_one_item_exists', ( - tester, - ) async { - await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(1)))); - await tester.pump(const Duration(seconds: 12)); - - expect(find.text('Banner 0'), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-1')), findsNothing); - }); - - testWidgets('should_pause_auto_advance_while_pointer_is_down', ( - tester, - ) async { - await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(3)))); - await tester.pump(); - final box = tester.getRect(find.byType(CoverCarousel)); - final gesture = await tester.startGesture(box.center); - await tester.pump(const Duration(seconds: 6)); - - expect(find.text('Banner 0'), findsOneWidget); - - await gesture.up(); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 4500)); - await tester.pump(const Duration(milliseconds: 500)); - - expect(find.text('Banner 1'), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); - }); - - testWidgets('should_pause_when_inactive_and_resume_when_active_again', ( - tester, - ) async { - final active = ValueNotifier(false); - await tester.pumpWidget( - _localizedApp( - ValueListenableBuilder( - valueListenable: active, - builder: (context, enabled, _) => - CoverCarousel(items: items(2), active: enabled), - ), - ), - ); - await tester.pump(const Duration(seconds: 6)); - expect(find.text('Banner 0'), findsOneWidget); - - active.value = true; - await tester.pump(); - await tester.pump(const Duration(milliseconds: 4500)); - await tester.pump(const Duration(milliseconds: 500)); - expect(find.text('Banner 1'), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); - }); - - testWidgets('should_show_icon_fallback_when_banner_image_is_empty', ( - tester, - ) async { - await tester.pumpWidget( - _localizedApp( - CoverCarousel( - items: const [ - CoverItem( - title: 'Broken image', - subtitle: '', - image: '', - onOpen: _noop, - ), - ], - ), - ), - ); - await tester.pump(); - - expect(find.byKey(const Key('carousel-image-fallback')), findsOneWidget); - }); - - testWidgets( - 'should_keep_existing_open_callback_when_active_poster_is_tapped', - (tester) async { - var opened = 0; - await tester.pumpWidget( - _localizedApp( - CoverCarousel( - items: [ - CoverItem( - title: 'Tap me', - subtitle: '', - image: '/files/banner.png', - onOpen: () => opened++, - ), - ...items(1), - ], - ), - ), - ); - await tester.pump(); - - await tester.tap(find.text('Tap me')); - expect(opened, 1); - await tester.pumpWidget(const SizedBox.shrink()); - }, - ); -} - -void _noop() {} - -Widget _localizedApp(Widget child) => MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: child), -); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index a91ba2d..de51edf 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -7,11 +7,11 @@ 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'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/core/widgets/remote_image.dart'; import '../support/fake_catalog.dart'; import '../support/fake_interaction_repositories.dart'; @@ -25,7 +25,7 @@ void main() { }); testWidgets( - 'should_show_all_dramas_in_three_column_sliver_grid_without_continue_watching', + 'should_show_all_dramas_in_two_column_masonry_without_continue_watching', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; @@ -37,57 +37,94 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Continue Watching'), findsNothing); - expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); - expect(find.text('All'), findsOneWidget); + expect(find.text('Recommended'), findsOneWidget); + for (final key in const [ + Key('discover-search-button'), + Key('discover-profile'), + ]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant( + of: find.byKey(key), + matching: find.byType(Icon), + ), + ) + .size, + 18, + ); + } expect(find.text('For You'), findsNothing); - expect( - 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( + final masonry = 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.mainAxisSpacing, 14); - expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001)); + expect(masonry.children, hasLength(3)); + expect((masonry.children[1] as SizedBox).width, 10); expect( tester - .widget( - find.byKey(const Key('home-all-dramas-section')), - ) + .widget(find.byKey(const Key('home-all-dramas-section'))) .padding, - const EdgeInsets.symmetric(horizontal: 16), + const EdgeInsets.symmetric(horizontal: 14), ); expect(find.byType(GridView), findsNothing); expect(find.byType(CustomScrollView), findsOneWidget); - expect(find.byType(RemoteImage), findsWidgets); + final signalCard = find.byKey( + const Key('home-drama-card-the-signal-demo'), + ); + final cityCard = find.byKey( + const Key('home-drama-card-city-lights-demo'), + ); + final vengeanceCard = find.byKey( + const Key('home-drama-card-vengeance-demo'), + ); + expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy); + expect( + tester.getTopLeft(vengeanceCard).dx, + tester.getTopLeft(signalCard).dx, + ); + expect( + tester.getTopLeft(vengeanceCard).dy - + tester.getBottomLeft(signalCard).dy, + closeTo(10, 1), + ); - final cover = tester.widget( - find.byKey(const Key('home-drama-cover-city-lights-demo')), + final cover = find.byKey(const Key('home-drama-cover-city-lights-demo')); + expect(tester.getSize(cover).height, greaterThan(120)); + expect( + tester + .widget( + find.descendant(of: cover, matching: find.byType(Image)), + ) + .fit, + BoxFit.fitWidth, + ); + expect( + find.descendant( + of: cover, + matching: find.text('City Lights & Velvet Nights'), + ), + findsNothing, + ); + expect( + find.byKey(const Key('home-drama-title-city-lights-demo')), + findsOneWidget, + ); + expect( + tester + .getTopLeft( + find.byKey(const Key('home-drama-title-city-lights-demo')), + ) + .dy, + greaterThan(tester.getBottomLeft(cover).dy), ); - expect(cover.aspectRatio, closeTo(2 / 3, 0.001)); expect( tester .widget( find.byKey(const Key('home-drama-title-city-lights-demo')), ) .maxLines, - 2, + 1, ); expect( tester @@ -111,14 +148,14 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('The Signal'), findsWidgets); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); await tester.tap(find.text('Romance')); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsNothing, @@ -129,6 +166,43 @@ void main() { ); }); + testWidgets('should_keep_discover_header_fixed_when_content_scrolls', ( + 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( + discoverFeed: _tallDiscoverFeed, + ), + ); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + final headerBefore = tester.getTopLeft( + find.byKey(const Key('discover-search-button')), + ); + final contentBefore = tester.getTopLeft( + find.byKey(const Key('home-category-chips')), + ); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -40)); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.byKey(const Key('discover-search-button'))), + headerBefore, + ); + expect( + tester.getTopLeft(find.byKey(const Key('home-category-chips'))).dy, + lessThan(contentBefore.dy), + ); + }); + testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', ( tester, ) async { @@ -146,17 +220,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - 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(); @@ -174,7 +239,6 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('Categories could not be loaded.'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); expect(find.byKey(const Key('empty-state-icon')), findsOneWidget); @@ -184,7 +248,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); @@ -200,15 +264,164 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(tester.takeException(), isNull); }); + + 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_unfocus_search_without_losing_input_or_filter', ( + 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(); + + await tester.tap(find.byKey(const Key('discover-search'))); + await tester.pump(); + expect( + tester + .widget(find.byKey(const Key('discover-search'))) + .focusNode! + .hasFocus, + isTrue, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + + await tester.tapAt(const Offset(70, 23)); + await tester.pump(); + + final field = tester.widget( + find.byKey(const Key('discover-search')), + ); + expect(field.focusNode!.hasFocus, isFalse); + expect(field.controller!.text, 'velvet'); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + }); + + testWidgets('should_refresh_feed_when_pulled_down', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final counter = _CountingCategoryRepository( + _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), + ); + await _pumpApp(tester, categoryRepository: counter); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + final before = counter.categoryLoads + counter.feedLoads; + await tester.drag(find.byType(CustomScrollView), const Offset(0, 320)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); + expect(find.byType(RefreshProgressIndicator), findsNothing); + }); + + 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 +429,7 @@ Future _pumpApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: categoryRepository, - watchHistoryRepository: InMemoryWatchHistoryRepository(), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), @@ -273,3 +486,43 @@ final _defaultDiscoverFeed = HomeFeed( ), ], ); + +final _tallDiscoverFeed = HomeFeed( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [ + for (var index = 0; index < 12; index++) + Drama( + id: 'tall-$index', + title: 'Tall Drama $index', + synopsis: 'Tall Drama $index synopsis', + posterUrl: '/files/tall-$index.png', + genre: 'Urban Romance', + detailLoaded: true, + ), + ], + ), + ], +); + +class _CountingCategoryRepository implements CategoryRepository { + _CountingCategoryRepository(this.delegate); + + final CategoryRepository delegate; + int categoryLoads = 0; + int feedLoads = 0; + + @override + Future> fetchCategories() { + categoryLoads++; + return delegate.fetchCategories(); + } + + @override + Future fetchDiscoverFeed() { + feedLoads++; + return delegate.fetchDiscoverFeed(); + } +} diff --git a/test/discover/home_banner_source_test.dart b/test/discover/home_banner_source_test.dart deleted file mode 100644 index 1e4edeb..0000000 --- a/test/discover/home_banner_source_test.dart +++ /dev/null @@ -1,100 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/app.dart'; -import 'package:fruitshorts/core/data/models/drama.dart'; -import 'package:fruitshorts/core/data/models/home_feed.dart'; -import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; -import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; -import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; -import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; - -import '../support/fake_catalog.dart'; -import '../support/fake_category.dart'; -import '../support/fake_interaction_repositories.dart'; -import '../support/fake_notice_repository.dart'; -import '../support/widget_player.dart'; - -void main() { - testWidgets( - 'should_render_home_feed_banners_when_discover_banners_are_empty', - (tester) async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final catalog = _HomeBannerCatalogRepository(); - await tester.pumpWidget( - LumaDramaApp( - playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(), - playbackRepository: FakePlaybackRepository(), - commentRepository: FakeCommentRepository(), - interactionRepository: FakeInteractionRepository(), - noticeRepository: FakeNoticeRepository(), - ), - ); - await tester.pumpAndSettle(); - 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, - ); - await tester.pumpWidget(const SizedBox.shrink()); - }, - ); -} - -class _HomeBannerCatalogRepository implements CatalogRepository { - final _delegate = FakeCatalogRepository(); - - @override - Future fetchHomeFeed() async => HomeFeed( - banners: const [ - FeedBanner( - id: 'banner-1', - imageUrl: '/files/banner-1.png', - linkType: 'DRAMA', - linkValue: 'the-signal-demo', - ), - FeedBanner( - id: 'banner-2', - imageUrl: '/files/banner-2.png', - linkType: 'DRAMA', - linkValue: 'city-lights-demo', - ), - ], - sections: [FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas)], - ); - - @override - Future fetchDiscoverFeed() async => HomeFeed( - sections: [FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas)], - ); - - @override - Future fetchDrama(String dramaId) => _delegate.fetchDrama(dramaId); -} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart new file mode 100644 index 0000000..ed939b3 --- /dev/null +++ b/test/drama_detail/drama_detail_view_test.dart @@ -0,0 +1,236 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190); + expect( + tester.getSize(find.byKey(const Key('detail-back'))), + const Size(36, 36), + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('detail-back')), + matching: find.byType(Icon), + ), + ) + .size, + 18, + ); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40); + expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); + + final synopsis = tester.widget( + find.byKey(const Key('detail-synopsis')), + ); + expect(synopsis.style?.fontSize, 11); + expect(synopsis.style?.height, 1.6); + + await tester.tap(find.byKey(const Key('detail-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('detail-favorite')), findsOneWidget); + expect(find.byKey(const Key('detail-download')), findsOneWidget); + expect(find.byKey(const Key('detail-share')), findsOneWidget); + expect(find.byKey(const Key('detail-report')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_fit_compact_viewport_when_detail_scrolls', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + final grid = tester.widget( + find.byKey(const Key('detail-episode-chips')), + ); + expect( + (grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount) + .crossAxisCount, + 6, + ); + await tester.drag( + find.byKey(const Key('detail-scroll')), + const Offset(0, -520), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_render_sorted_six_column_grid_and_open_original_index', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama(count: 80))); + await tester.pumpAndSettle(); + + final grid = tester.widget( + find.byKey(const Key('detail-episode-chips')), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 6); + expect(delegate.mainAxisSpacing, 8); + expect(delegate.crossAxisSpacing, 8); + expect(delegate.childAspectRatio, closeTo(1.35, 0.001)); + + final chip1 = find.byKey(const Key('detail-episode-chip-1')); + final chip2 = find.byKey(const Key('detail-episode-chip-2')); + final chip6 = find.byKey(const Key('detail-episode-chip-6')); + final chip7 = find.byKey(const Key('detail-episode-chip-7')); + expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy); + expect( + tester.getTopLeft(chip7).dy, + greaterThan(tester.getTopLeft(chip1).dy), + ); + expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); + expect(tester.getSize(chip1), tester.getSize(chip2)); + + await tester.drag( + find.byKey(const Key('detail-scroll')), + const Offset(0, -420), + ); + await tester.pumpAndSettle(); + final chip12 = find.byKey(const Key('detail-episode-chip-12')); + expect(chip12, findsOneWidget); + await tester.tap(chip12); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.initialIndex, 11); + }); + + testWidgets('should_sort_episode_chips_ascending_by_number', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama(count: 80, descending: true))); + await tester.pumpAndSettle(); + + final chip1 = find.byKey(const Key('detail-episode-chip-1')); + final chip2 = find.byKey(const Key('detail-episode-chip-2')); + final chip6 = find.byKey(const Key('detail-episode-chip-6')); + final chip7 = find.byKey(const Key('detail-episode-chip-7')); + expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy); + expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); + expect( + tester.getTopLeft(chip7).dy, + greaterThan(tester.getTopLeft(chip1).dy), + ); + expect(tester.getSize(chip1), tester.getSize(chip2)); + }); +} + +Widget _host(Drama drama) { + final catalog = FakeCatalogRepository(dramas: [drama]); + final interactions = FakeInteractionRepository(); + return MultiProvider( + providers: [ + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakePlaybackRepository()), + Provider.value(value: createWidgetPlayer), + Provider(create: (_) => PlaybackCoordinator()), + Provider.value(value: interactions), + Provider.value(value: FakeCommentRepository()), + Provider.value(value: FakeHaptics()), + ChangeNotifierProvider( + create: (_) => FavoriteController(InMemoryFavoriteRepository()), + ), + ChangeNotifierProvider( + create: (_) => InteractionController(interactions), + ), + ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), + ], + child: MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: DramaDetailPage(drama: drama), + ), + ); +} + +Drama _detailDrama({int count = 8, bool descending = false}) { + final numbers = List.generate( + count, + (index) => descending ? count - index : index + 1, + ); + return Drama( + id: 'detail-k25', + title: 'Detail K25', + synopsis: + 'She agreed to marry a mysterious stranger to save her family, ' + 'until she discovered his secret identity and the city he controls.', + posterUrl: '/files/detail-k25.png', + genre: 'Urban Romance', + category: 'Romance', + totalEpisodes: count, + viewCount: 4800000, + detailLoaded: true, + episodes: [ + for (final number in numbers) + Episode( + id: 'detail-k25-$number', + number: number, + title: 'Episode $number', + isFree: number == 1, + durationSec: 135, + locked: number > 1, + videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '', + ), + ], + ); +} diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 7ba720b..85c609c 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,35 +85,39 @@ 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', () { + test('should_return_to_discover_and_handle_immersive_back', () { 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); logic.select(0); logic.onSystemBack(); @@ -123,30 +130,25 @@ void main() { expect(logic.backRequest, 1); expect(logic.tab, 0); - logic.onDrawerChanged(true); - logic.onSystemBack(); - expect(logic.backRequest, 1); - - logic.onDrawerChanged(false); logic.onImmersiveChanged(false); logic.onSystemBack(); expect(logic.tab, 1); logic.dispose(); }); - test('should_notify_only_on_actual_change_when_drawer_state_is_set', () { + test('should_notify_only_on_actual_change_when_menu_state_is_set', () { final logic = HomeLogic(dramas); var notifications = 0; logic.addListener(() => notifications++); - logic.onDrawerChanged(false); + logic.setMenuOpen(false); expect(notifications, 0); - logic.onDrawerChanged(true); + logic.setMenuOpen(true); expect(logic.drawerOpen, isTrue); expect(notifications, 1); - logic.onDrawerChanged(true); + logic.setMenuOpen(true); expect(notifications, 1); logic.dispose(); }); @@ -190,7 +192,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..0c9d15a 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,17 +51,15 @@ 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.outlineVariantDark); expect(border.top.color, isNot(Colors.white)); expect( tester - .widget( + .widget( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byType(AnimatedContainer), + matching: find.byType(AnimatedScale), ), ) .duration, @@ -71,7 +68,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.movie_rounded), + matching: find.byIcon(Icons.play_circle_fill_rounded), ), findsOneWidget, ); @@ -85,29 +82,42 @@ 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.play_circle_outline_rounded), ), findsOneWidget, ); }); - testWidgets('should_hide_themed_navigation_when_immersive_mode_is_enabled', ( - tester, - ) async { + testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { await _pumpApp(tester); - await tester.tap(find.byTooltip('Clear screen')); - await tester.pumpAndSettle(); - for (final key in _keys) { - expect(find.byKey(key), 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', ( @@ -119,6 +129,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/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart new file mode 100644 index 0000000..753de68 --- /dev/null +++ b/test/home/home_preview_layout_test.dart @@ -0,0 +1,158 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episode')), findsOneWidget); + final cardRect = tester.getRect( + find.byKey(const Key('home-feed-info-card')), + ); + final episodeRect = tester.getRect( + find.byKey(const Key('home-feed-episode')), + ); + expect(episodeRect.left, lessThan(cardRect.center.dx)); + final railCenter = tester.getCenter(find.byKey(const Key('rail-like'))); + final railBottom = tester.getBottomLeft(find.byKey(const Key('rail-save'))); + expect(railCenter.dy, greaterThan(844 * 0.4)); + expect(railBottom.dy, lessThan(cardRect.top)); + expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); + for (final key in const [Key('home-feed-search'), Key('home-feed-more')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ) + .size, + 18, + ); + } + expect( + tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height, + 34, + ); + expect(find.text('HOT'), findsNothing); + expect(find.textContaining('Season'), findsNothing); + expect(find.byKey(const Key('home-feed-follow')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_use_episode_title_then_drama_title_as_fallback', ( + tester, + ) async { + Future pumpEpisode(Episode episode) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: _drama, + episode: episode, + liked: false, + saved: false, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDownload: () {}, + onClear: () {}, + onSearch: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + await pumpEpisode( + const Episode( + id: 'featured', + number: 2, + title: 'Midnight Contract', + isFree: true, + ), + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + 'Midnight Contract', + ); + + await pumpEpisode( + const Episode(id: 'fallback', number: 2, title: '', isFree: true), + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + 'Fallback Drama', + ); + }); +} + +const _drama = Drama( + id: 'featured-drama', + title: 'Fallback Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, +); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart new file mode 100644 index 0000000..d9c9946 --- /dev/null +++ b/test/mine/mine_preview_layout_test.dart @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_me_user_wallet_continue_and_language_rows', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-1', + position: Duration(minutes: 2), + duration: Duration(minutes: 5), + ), + ], + ), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mine-user-row')), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + for (final key in const [Key('mine-search-button'), Key('mine-avatar')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ) + .size, + 18, + ); + } + expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('mine-language')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget); + final tab0 = find.byKey(const Key('mine-library-tab-0')); + final tab1 = find.byKey(const Key('mine-library-tab-1')); + final tab2 = find.byKey(const Key('mine-library-tab-2')); + expect(tester.getTopLeft(tab0).dx, lessThan(tester.getTopLeft(tab1).dx)); + expect(tester.getTopLeft(tab1).dx, lessThan(tester.getTopLeft(tab2).dx)); + expect( + tester.getTopLeft(tab1).dx - tester.getTopRight(tab0).dx, + closeTo(20, 1), + ); + expect( + (tester + .widget( + find.descendant( + of: tab0, + matching: find.byType(AnimatedContainer), + ), + ) + .decoration + as BoxDecoration) + .color, + AppColors.blue, + ); + expect( + (tester + .widget( + find.descendant( + of: tab1, + matching: find.byType(AnimatedContainer), + ), + ) + .decoration + as BoxDecoration) + .color, + Colors.transparent, + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index db6d08b..1767c7f 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -46,6 +46,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-notices')), findsOneWidget); await tester.tap(find.byKey(const Key('mine-notices'))); diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 67d3012..53ad74b 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -56,11 +56,13 @@ void main() { await tester.pump(); expect(started, 1); expect(target, isNull); + expect(find.textContaining('/ 00:12'), findsOneWidget); await gesture.up(); await tester.pumpAndSettle(); expect(target, isNotNull); expect(target!, greaterThan(const Duration(seconds: 3))); expect(target!, lessThan(const Duration(seconds: 12))); + expect(find.textContaining('/ 00:12'), findsNothing); }); testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', ( diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart new file mode 100644 index 0000000..e6912cd --- /dev/null +++ b/test/player/player_center_toggle_test.dart @@ -0,0 +1,47 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; + +void main() { + testWidgets('should_use_one_shared_52px_center_toggle_style', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + PlayerCenterToggle( + key: const Key('test-center-toggle-a'), + playing: false, + onTap: () {}, + ), + PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: true, + onTap: () {}, + ), + ], + ), + ), + ), + ); + + for (final key in const [ + Key('test-center-toggle-a'), + Key('player-play-toggle'), + ]) { + expect(tester.getSize(find.byKey(key)), const Size(52, 52)); + final container = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(Container)), + ); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, const Color(0x80000000)); + expect(decoration.shape, BoxShape.circle); + final icon = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ); + expect(icon.size, 26); + } + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart new file mode 100644 index 0000000..45fc167 --- /dev/null +++ b/test/player/player_fullscreen_test.dart @@ -0,0 +1,525 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; +import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_open_fullscreen_overlay_from_discover_card', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + await tester.pumpAndSettle(); + + _expectFullscreenOverlay(tester); + expect(find.byType(PageView), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-download')), findsOneWidget); + expect(find.byKey(const Key('player-more-pip')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_open_fullscreen_overlay_from_detail_cta_and_chip', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + _expectFullscreenOverlay(tester); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); + await tester.pumpAndSettle(); + _expectFullscreenOverlay(tester); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_show_center_toggle_only_when_paused', (tester) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + expect( + find.descendant( + of: find.byKey(const Key('player-play-toggle')), + matching: find.byIcon(Icons.play_arrow_rounded), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('player-play-toggle'))); + await tester.pump(); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + }); + + testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsNothing); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + + await tester.pump(const Duration(seconds: 5)); + await tester.pump(); + + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + expect(find.byKey(const Key('player-video-tap')), findsOneWidget); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('player-exit-clear-screen'))), + const Size(36, 36), + ); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsNothing); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); + }); + + testWidgets('should_fit_player_header_on_320_width', (tester) async { + await _pumpApp(tester, size: const Size(320, 568)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byKey(const Key('player-back'))), + const Size(36, 36), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_keep_series_clear_state_when_swiping_next_episode', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); + }); + + testWidgets('should_open_speed_sheet_and_apply_selected_speed', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-speed')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-speed'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); + for (final value in ['0.5', '0.75', '1.0', '1.25', '1.5', '2.0']) { + expect(find.byKey(Key('player-speed-$value')), findsOneWidget); + } + + await tester.tap(find.byKey(const Key('player-speed-1.5'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-speed-sheet')), findsNothing); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); + }); + + testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-speed'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-speed-1.5'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-more')), findsNothing); + + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); + }); + + testWidgets('should_enable_drag_time_preview_on_player_progress', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + final bar = tester.widget( + find.byKey(const Key('player-progress')), + ); + expect(bar.showTimes, isTrue); + }); + + testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + + await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect(find.byKey(const Key('home-feed-scrim')), findsNothing); + expect(find.byKey(const Key('home-feed-video-tap')), findsOneWidget); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))), + const Size(36, 36), + ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + expect( + find.ancestor( + of: find.byKey(const Key('home-feed-exit-clear-screen')), + matching: find.byType(SafeArea), + ), + findsWidgets, + ); + expect( + tester + .getTopLeft(find.byKey(const Key('home-feed-exit-clear-screen'))) + .dy, + greaterThanOrEqualTo(8), + ); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isFalse, + ); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + + await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); + expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); + + testWidgets('should_show_home_center_toggle_only_when_paused', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isFalse, + ); + + await tester.tap(find.byKey(const Key('home-feed-center-play'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); + + testWidgets('should_keep_home_clear_state_when_swiping_to_next_page', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); +} + +void _expectFullscreenOverlay(WidgetTester tester) { + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-more')), findsOneWidget); + for (final key in const [Key('player-back'), Key('player-more')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + } + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect(find.byKey(const Key('player-episodes')), findsOneWidget); + expect(find.byKey(const Key('player-comments')), findsOneWidget); + expect(find.byKey(const Key('player-next')), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + + expect(find.byKey(const Key('rail-like')), findsNothing); + expect(find.byKey(const Key('rail-save')), findsNothing); +} + +Future _pumpApp( + WidgetTester tester, { + Size size = const Size(390, 844), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 0b516ea..ff0b5c9 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -117,33 +117,7 @@ void main() { }); group('surface flags', () { - test('should_hide_chrome_until_restored_when_screen_is_cleared', () { - final logic = _logic(FakeWatchHistory()); - final immersive = []; - logic.onImmersiveChanged = immersive.add; - var notifications = 0; - logic.addListener(() => notifications++); - - expect(logic.chromeHidden, isFalse); - expect(logic.immersive, isFalse); - - logic.setScreenCleared(true); - expect(logic.chromeHidden, isTrue); - expect(notifications, 1); - expect(immersive, [true]); - - // Repeating the same value is not a state change. - logic.setScreenCleared(true); - expect(notifications, 1); - expect(immersive, [true]); - - logic.setScreenCleared(false); - expect(logic.chromeHidden, isFalse); - expect(notifications, 2); - expect(immersive, [true, false]); - }); - - test('should_outrank_cleared_screen_flag_when_picture_in_picture_callback_fires', () async { + test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index 149b85e..f4402d4 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -6,18 +6,18 @@ import 'package:fruitshorts/core/theme/app_dimens.dart'; void main() { test('should_expose_the_k20_spacing_and_type_scale', () { expect(AppDimens.pageHorizontal, 16); - expect(AppDimens.cardRadius, 16); - expect(AppDimens.innerRadius, 12); + expect(AppDimens.cardRadius, 12); + expect(AppDimens.innerRadius, 10); expect(AppDimens.caption, 12); expect(AppDimens.body, 14); expect(AppDimens.listTitle, 15); expect(AppDimens.sectionTitle, 16); expect(AppDimens.pageTitle, 20); - expect(AppDimens.navLabel, 11); + expect(AppDimens.navLabel, 9); expect(AppDimens.buttonHeight, 48); expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.tapTarget, 44); - expect(AppDimens.navHeight, 64); - expect(AppDimens.progressHeight, 4); + expect(AppDimens.navHeight, 54); + expect(AppDimens.progressHeight, 3); }); } diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index ba429ff..4a7eb85 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -14,11 +14,18 @@ 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(0xFF1B1C21)); + expect(AppColors.cardAlt, const Color(0xFF1C1D21)); + expect(AppColors.raised, const Color(0xFF1C1D21)); + expect(AppColors.bar, const Color(0xFF0D0D11)); + expect(AppColors.success, const Color(0xFF34D399)); expect(AppColors.onPrimary, const Color(0xFFFFFFFF)); expect( _contrast(AppColors.onGold, AppColors.gold), @@ -29,16 +36,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/watch/watch_history_provider_test.dart b/test/watch/watch_history_provider_test.dart index 932728f..2b10336 100644 --- a/test/watch/watch_history_provider_test.dart +++ b/test/watch/watch_history_provider_test.dart @@ -42,6 +42,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); await tester.tap(find.text('Watch History')); await tester.pumpAndSettle(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 7732b90..430b533 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -110,41 +110,24 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); - expect(find.byType(Slider), findsOneWidget); + expect(find.byType(Slider), findsNothing); expect(find.text('Home'), findsWidgets); - expect(find.textContaining('Playing now'), findsOneWidget); - expect(find.text('Recommended'), findsNothing); - expect(find.text('Following'), findsNothing); - expect(find.byTooltip('Episodes'), findsWidgets); - expect(find.byTooltip('Clear screen'), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('rail-like')), findsOneWidget); + expect(find.byKey(const Key('rail-comments')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + await tester.tap(find.byKey(const Key('home-feed-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-download')), findsOneWidget); + expect(find.byKey(const Key('home-feed-pip')), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); expect(find.text('Watch Full'), findsNothing); expect(find.byTooltip('Explore series'), findsNothing); }); - testWidgets('should_hide_tabs_and_restore_them_when_clear_screen_is_tapped', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Clear screen')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); - expect(find.byTooltip('Exit clear screen'), findsOneWidget); - await tester.tap(find.byTooltip('Exit clear screen')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - expect(find.byTooltip('Clear screen'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - testWidgets( 'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used', (tester) async { @@ -155,6 +138,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(); @@ -166,18 +151,12 @@ void main() { ); expect(find.text('Quick access'), findsOneWidget); expect(find.text('Bahasa Indonesia'), findsOneWidget); - expect( - find.descendant( - of: find.byType(Drawer), - matching: find.text('7 EPISODES'), - ), - findsOneWidget, - ); + expect(find.text('7 EPISODES'), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('drawer-open-series'))); await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect( @@ -217,18 +196,20 @@ 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); - expect(find.text('我的账户'), findsOneWidget); - expect(find.text('首页'), findsOneWidget); - await tester.tap(find.text('首页')); + await tester.drag(find.byType(ListView), const Offset(0, 700)); await tester.pumpAndSettle(); - expect(find.text('全部'), findsOneWidget); - expect(find.text('热门'), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + expect(find.text('首页'), findsOneWidget); + await tester.tap(find.text('发现')); + await tester.pumpAndSettle(); + expect(find.text('推荐'), findsOneWidget); }); testWidgets( @@ -249,7 +230,9 @@ void main() { expect(find.text('City Lights & Velvet Nights'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap( + find.byKey(const Key('home-drama-card-city-lights-demo')), + ); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); expect(tester.takeException(), isNull); @@ -272,7 +255,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 +294,37 @@ 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.text('Recommended'), 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.byTooltip('Search'), findsNothing); + expect( + find.byKey( + const Key('home-drama-genre-vengeance-demo'), + skipOffstage: false, + ), + findsOneWidget, + ); + expect(find.byTooltip('Search'), findsOneWidget); 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.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('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,10 +340,10 @@ 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); + expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -356,28 +351,34 @@ 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); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), ).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'))); @@ -406,17 +407,27 @@ void main() { await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - expect(find.textContaining('Episode 1'), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + contains('Episode 1'), + ); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap( + find.byKey(const Key('home-drama-card-city-lights-demo')), + ); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.text('Episode 2 · Playing now'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, ); @@ -477,9 +488,9 @@ void main() { await tester.pumpAndSettle(); final keys = [ const Key('rail-like'), - const Key('rail-save'), const Key('rail-comments'), const Key('rail-share'), + const Key('rail-save'), ]; final tops = keys .map((key) => tester.getTopLeft(find.byKey(key)).dy) @@ -545,6 +556,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 +583,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,15 +623,17 @@ 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.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets); await tester.tap(find.text('The Signal')); await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); }, ); }