chore(release): 合并 develop 到 release/v1.0.0

This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-19 21:39:21 +08:00
commit 35364c949a
69 files changed
+6115 -2941

No files matched your search

+3
View File
@@ -49,6 +49,9 @@ app.*.map.json
/assets/* /assets/*
!/assets/icon/ !/assets/icon/
!/assets/icon/*.png !/assets/icon/*.png
!/assets/fonts/
!/assets/fonts/*.ttf
!/assets/fonts/OFL-*.txt
/server/ /server/
/docs/ /docs/
/AGENTS.md /AGENTS.md
+93
View File
@@ -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.
+93
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -39,11 +39,11 @@ abstract final class ServerEndpoint {
/// In release builds this returns immediately without reading preferences. /// In release builds this returns immediately without reading preferences.
static Future<void> load({ static Future<void> load({
bool debug = kDebugMode, bool debug = kDebugMode,
SharedPreferences? preferences, SharedPreferencesAsync? preferences,
}) async { }) async {
if (!debug) return; if (!debug) return;
try { try {
final prefs = preferences ?? await SharedPreferences.getInstance(); final prefs = preferences ?? SharedPreferencesAsync();
final stored = await prefs.getString(preferenceKey); final stored = await prefs.getString(preferenceKey);
AppConfig.applyRuntimeBaseUrl( AppConfig.applyRuntimeBaseUrl(
resolve( resolve(
@@ -60,12 +60,12 @@ abstract final class ServerEndpoint {
/// Persists a normalized runtime endpoint. /// Persists a normalized runtime endpoint.
static Future<bool> save( static Future<bool> save(
String raw, { String raw, {
SharedPreferences? preferences, SharedPreferencesAsync? preferences,
}) async { }) async {
final normalized = normalize(raw); final normalized = normalize(raw);
if (normalized == null) return false; if (normalized == null) return false;
try { try {
await (preferences ?? await SharedPreferences.getInstance()).setString( await (preferences ?? SharedPreferencesAsync()).setString(
preferenceKey, preferenceKey,
normalized, normalized,
); );
@@ -76,9 +76,9 @@ abstract final class ServerEndpoint {
} }
/// Removes the persisted runtime endpoint. /// Removes the persisted runtime endpoint.
static Future<void> clear({SharedPreferences? preferences}) async { static Future<void> clear({SharedPreferencesAsync? preferences}) async {
try { try {
await (preferences ?? await SharedPreferences.getInstance()).remove(preferenceKey); await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey);
} catch (_) { } catch (_) {
// Clearing an optional developer override is best effort. // Clearing an optional developer override is best effort.
} }
@@ -23,7 +23,7 @@ class AuthSessionStore {
Future<String> deviceId() async { Future<String> deviceId() async {
if (_cachedDeviceId != null) return _cachedDeviceId!; if (_cachedDeviceId != null) return _cachedDeviceId!;
final prefs = await SharedPreferences.getInstance(); final prefs = SharedPreferencesAsync();
var value = await prefs.getString(_kDeviceId) ?? ''; var value = await prefs.getString(_kDeviceId) ?? '';
if (value.isEmpty) { if (value.isEmpty) {
final random = Random(); final random = Random();
@@ -37,7 +37,7 @@ class AuthSessionStore {
} }
Future<AuthSession?> read() async { Future<AuthSession?> read() async {
final prefs = await SharedPreferences.getInstance(); final prefs = SharedPreferencesAsync();
final accessToken = await prefs.getString(_kAccessToken) ?? ''; final accessToken = await prefs.getString(_kAccessToken) ?? '';
final refreshToken = await prefs.getString(_kRefreshToken) ?? ''; final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
if (accessToken.isEmpty || refreshToken.isEmpty) return null; if (accessToken.isEmpty || refreshToken.isEmpty) return null;
@@ -58,14 +58,14 @@ class AuthSessionStore {
} }
Future<void> save(AuthSession session) async { Future<void> save(AuthSession session) async {
final prefs = await SharedPreferences.getInstance(); final prefs = SharedPreferencesAsync();
await prefs.setString(_kAccessToken, session.accessToken); await prefs.setString(_kAccessToken, session.accessToken);
await prefs.setString(_kRefreshToken, session.refreshToken); await prefs.setString(_kRefreshToken, session.refreshToken);
await prefs.setString(_kUser, jsonEncode(session.user.toJson())); await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
} }
Future<void> clear() async { Future<void> clear() async {
final prefs = await SharedPreferences.getInstance(); final prefs = SharedPreferencesAsync();
await prefs.remove(_kAccessToken); await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken); await prefs.remove(_kRefreshToken);
await prefs.remove(_kUser); await prefs.remove(_kUser);
+55 -27
View File
@@ -2,26 +2,31 @@
import 'package:flutter/material.dart'; 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 /// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page
/// progress. Cocoa surfaces keep the app in night-market light instead of /// code does not need a mass rename; their values now follow the design tokens.
/// a cold utility screen.
abstract final class AppColors { abstract final class AppColors {
/// Chili fill for buttons, selected chips, and brand marks. /// Primary CTA, selected navigation, playback progress.
static const blue = Color(0xFFC4322A); static const blue = Color(0xFFFF5B3D);
static const primary = blue;
/// Pressed chili. /// Pressed state for the primary CTA.
static const bluePressed = Color(0xFF8E221C); static const bluePressed = Color(0xFFFF4321);
/// Coral for text and icons sitting on dark surfaces. /// Secondary accent for tags, locks, and highlighted icons.
static const blueSoft = Color(0xFFFF8A72); static const blueSoft = Color(0xFF9278EA);
static const secondary = blueSoft;
static const lavender = blueSoft;
/// Coins, rewards, and the playback progress line. /// Coins and rewards.
static const gold = Color(0xFFF6C453); static const gold = Color(0xFFFBBF24);
/// Favorite/like heart without competing with the chili primary action. /// Success and free-state accents.
static const favorite = Color(0xFFD74A7D); static const success = Color(0xFF34D399);
/// Liked hearts use the primary accent.
static const favorite = blue;
/// Text placed on [gold]. /// Text placed on [gold].
static const onGold = Color(0xFF2A1810); static const onGold = Color(0xFF2A1810);
@@ -30,23 +35,46 @@ abstract final class AppColors {
static const onPrimary = Color(0xFFFFFFFF); static const onPrimary = Color(0xFFFFFFFF);
/// Page background. /// Page background.
static const ink = Color(0xFF100C0A); static const ink = Color(0xFF0B0C10);
/// Bottom bar. /// Bottom navigation background (rendered at 95% with blur).
static const bar = Color(0xFF1A1412); static const bar = Color(0xFF0D0D11);
/// Cards lifted off [ink]. /// Cards lifted off [ink].
static const card = Color(0xFF241C19); static const card = Color(0xFF1B1C21);
/// Chips and nested rows. /// Alternate card surface from the mobile preview.
static const raised = Color(0xFF322824); static const cardAlt = Color(0xFF1C1D21);
/// Primary text on dark surfaces. /// Floating surfaces, inputs, and nested rows.
static const cream = Color(0xFFF8F1EA); 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); /// Body text on dark surfaces.
static const outlineDark = Color(0xFF4A3C36); static const cream = Color(0xFFE6E1E3);
static const outlineVariantDark = Color(0xFF2A221F);
static const outlineLight = Color(0xFF8C7A72); /// Titles and high-emphasis text.
static const outlineVariantLight = Color(0xFFE4D5CC); 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);
} }
+26 -8
View File
@@ -1,6 +1,6 @@
// Author: fruit.com.cn // 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 { abstract final class AppDimens {
static const double spaceXs = 4; static const double spaceXs = 4;
static const double spaceSm = 8; static const double spaceSm = 8;
@@ -9,12 +9,17 @@ abstract final class AppDimens {
static const double spaceXl = 24; static const double spaceXl = 24;
static const double pageHorizontal = 16; static const double pageHorizontal = 16;
static const double cardRadius = 16; static const double cardRadius = 12;
static const double authCardRadius = 20; static const double authCardRadius = 20;
static const double cardPadding = 16; static const double cardPadding = 16;
static const double innerRadius = 12; static const double innerRadius = 10;
static const double innerPadding = 12; static const double innerPadding = 12;
static const double pillRadius = 20; static const double pillRadius = 20;
static const double glassSmallRadius = 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 caption = 12;
static const double emptyStateSubtitle = 12.5; static const double emptyStateSubtitle = 12.5;
@@ -24,16 +29,29 @@ abstract final class AppDimens {
static const double sectionTitle = 16; static const double sectionTitle = 16;
static const double pageTitle = 20; static const double pageTitle = 20;
static const double detailHeadline = 24; static const double detailHeadline = 24;
static const double navLabel = 11; static const double detailHeadlineSmall = 18;
static const double previewCardTitle = 13;
static const double previewSectionTitle = 14;
static const double chipFont = 10;
static const double navLabel = 9;
static const double buttonHeight = 48; static const double buttonHeight = 48;
static const double primaryButtonHeight = 52; static const double primaryButtonHeight = 52;
static const double buttonCompact = 32;
static const double buttonSmall = 34;
static const double buttonMedium = 40;
static const double buttonLarge = 44;
static const double tapTarget = 44; static const double tapTarget = 44;
static const double navHeight = 64; static const double navHeight = 54;
static const double navIcon = 22; static const double navIcon = 17;
static const double progressHeight = 4; static const double progressHeight = 3;
static const double progressRadius = 4; static const double progressRadius = 2;
static const double progressLineHeight = 3;
static const double searchHeight = 44;
static const double navDot = 3;
static const double glassBlur = 24;
static const double accountCardHeight = 64; static const double accountCardHeight = 64;
static const double accountIcon = 18; static const double accountIcon = 18;
+37 -13
View File
@@ -6,9 +6,10 @@ import 'package:flutter/services.dart';
import 'app_colors.dart'; import 'app_colors.dart';
import 'app_dimens.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 { abstract final class AppTheme {
static const _lightBackground = Color(0xFFFFF6F1); static const _lightBackground = Color(0xFFF7F7F8);
static const _darkBackground = AppColors.ink; static const _darkBackground = AppColors.ink;
/// Light theme for system light mode. /// Light theme for system light mode.
@@ -22,7 +23,7 @@ abstract final class AppTheme {
static ThemeData dark() => _build( static ThemeData dark() => _build(
brightness: Brightness.dark, brightness: Brightness.dark,
surface: _darkBackground, surface: _darkBackground,
accent: AppColors.blueSoft, accent: AppColors.blue,
); );
static ThemeData _build({ static ThemeData _build({
@@ -35,27 +36,27 @@ abstract final class AppTheme {
? const ColorScheme.dark( ? const ColorScheme.dark(
primary: AppColors.blue, primary: AppColors.blue,
onPrimary: AppColors.onPrimary, onPrimary: AppColors.onPrimary,
secondary: AppColors.blueSoft, secondary: AppColors.secondary,
onSecondary: AppColors.ink, onSecondary: AppColors.onPrimary,
primaryContainer: AppColors.bluePressed, primaryContainer: AppColors.bluePressed,
onPrimaryContainer: AppColors.onPrimary, onPrimaryContainer: AppColors.onPrimary,
secondaryContainer: AppColors.bluePressed, secondaryContainer: AppColors.raised,
onSecondaryContainer: AppColors.onPrimary, onSecondaryContainer: AppColors.onPrimary,
surface: _darkBackground, surface: _darkBackground,
onSurface: AppColors.cream, onSurface: AppColors.onSurface,
outline: AppColors.outlineDark, outline: AppColors.outlineDark,
outlineVariant: AppColors.outlineVariantDark, outlineVariant: AppColors.outlineVariantDark,
surfaceTint: AppColors.blueSoft, surfaceTint: AppColors.blue,
inversePrimary: AppColors.blueSoft, inversePrimary: AppColors.blueSoft,
) )
: ColorScheme.light( : ColorScheme.light(
primary: AppColors.blue, primary: AppColors.blue,
onPrimary: AppColors.onPrimary, onPrimary: AppColors.onPrimary,
secondary: AppColors.blue, secondary: AppColors.secondary,
onSecondary: AppColors.onPrimary, onSecondary: AppColors.onPrimary,
primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22), primaryContainer: AppColors.blue.withValues(alpha: 0.14),
onPrimaryContainer: AppColors.ink, onPrimaryContainer: AppColors.ink,
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22), secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14),
onSecondaryContainer: AppColors.ink, onSecondaryContainer: AppColors.ink,
surface: _lightBackground, surface: _lightBackground,
onSurface: AppColors.ink, onSurface: AppColors.ink,
@@ -64,11 +65,12 @@ abstract final class AppTheme {
surfaceTint: AppColors.blue, surfaceTint: AppColors.blue,
inversePrimary: AppColors.blueSoft, inversePrimary: AppColors.blueSoft,
); );
return ThemeData( final base = ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: brightness, brightness: brightness,
colorScheme: scheme, colorScheme: scheme,
scaffoldBackgroundColor: surface, scaffoldBackgroundColor: surface,
fontFamily: 'PlusJakartaSans',
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent), progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
sliderTheme: SliderThemeData( sliderTheme: SliderThemeData(
activeTrackColor: accent, activeTrackColor: accent,
@@ -105,7 +107,8 @@ abstract final class AppTheme {
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
systemOverlayStyle: SystemUiOverlayStyle.light, systemOverlayStyle: SystemUiOverlayStyle.light,
titleTextStyle: TextStyle( titleTextStyle: TextStyle(
color: AppColors.cream, color: AppColors.onSurface,
fontFamily: 'Outfit',
fontSize: AppDimens.pageTitle, fontSize: AppDimens.pageTitle,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
@@ -125,8 +128,29 @@ abstract final class AppTheme {
contentTextStyle: const TextStyle(color: AppColors.onPrimary), 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<WidgetState> states) { static Color _buttonColor(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed) || if (states.contains(WidgetState.pressed) ||
states.contains(WidgetState.hovered) || states.contains(WidgetState.hovered) ||
+30 -25
View File
@@ -1,5 +1,7 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
@@ -24,7 +26,7 @@ class AppBottomNavItem {
final bool selected; 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 { class AppBottomNav extends StatelessWidget {
const AppBottomNav({ const AppBottomNav({
super.key, super.key,
@@ -38,20 +40,25 @@ class AppBottomNav extends StatelessWidget {
final Key? barKey; final Key? barKey;
@override @override
Widget build(BuildContext context) => SafeArea( Widget build(BuildContext context) => ClipRect(
top: false, child: BackdropFilter(
child: Container( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
key: barKey, child: SafeArea(
height: AppDimens.navHeight, top: false,
decoration: BoxDecoration( child: Container(
color: backgroundColor, key: barKey,
border: const Border( height: AppDimens.navHeight,
top: BorderSide(color: AppColors.outlineVariantDark), 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, key: item.tabKey,
onTap: item.onTap, onTap: item.onTap,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget), constraints: const BoxConstraints(minHeight: 48),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
AnimatedContainer( AnimatedScale(
duration: const Duration(milliseconds: 160), duration: const Duration(milliseconds: 160),
curve: Curves.easeOut, curve: Curves.easeOut,
padding: const EdgeInsets.symmetric( scale: item.selected ? 1 : 0.96,
horizontal: 14,
vertical: 3,
),
decoration: BoxDecoration(
color: item.selected ? AppColors.blue : Colors.transparent,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Icon( child: Icon(
item.selected ? item.selectedIcon : item.icon, item.selected ? item.selectedIcon : item.icon,
size: AppDimens.navIcon, size: AppDimens.navIcon,
color: item.selected ? AppColors.onPrimary : AppColors.muted, color: item.selected
? AppColors.blue
: Colors.white.withValues(alpha: 0.45),
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
@@ -100,7 +102,10 @@ class _NavDestination extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: AppDimens.navLabel, fontSize: AppDimens.navLabel,
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500, 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,
), ),
), ),
], ],
+54
View File
@@ -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,
),
);
}
}
+33 -5
View File
@@ -15,6 +15,14 @@
"discoverTab": "Discover", "discoverTab": "Discover",
"searchDiscover": "Search dramas, actors, genres...", "searchDiscover": "Search dramas, actors, genres...",
"featured": "Featured", "featured": "Featured",
"recommended": "Recommended",
"fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "Sign in to sync progress, favorites and coins",
"comingSoon": "Coming soon",
"authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.",
"authContinueGoogle": "Continue with Google",
"authPhoneSignIn": "Phone sign in",
"authEmailSignIn": "Email sign in",
"suspense": "Suspense", "suspense": "Suspense",
"romance": "Romance", "romance": "Romance",
"actionGenre": "Action", "actionGenre": "Action",
@@ -23,6 +31,30 @@
"newReleases": "New Releases", "newReleases": "New Releases",
"trendingMicroDramas": "Trending Micro-Dramas", "trendingMicroDramas": "Trending Micro-Dramas",
"seeAll": "See All", "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", "artworkOnly": "Artwork",
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
"videoPending": "Video pending", "videoPending": "Video pending",
@@ -44,7 +76,7 @@
}, },
"resume": "Resume", "resume": "Resume",
"history": "History", "history": "History",
"watchlist": "Watchlist", "watchlist": "Favorites",
"likedTab": "Liked", "likedTab": "Liked",
"all": "All", "all": "All",
"inProgress": "In Progress", "inProgress": "In Progress",
@@ -113,8 +145,6 @@
"reportFailed": "Could not submit report.", "reportFailed": "Could not submit report.",
"shareFailed": "Could not open the share sheet.", "shareFailed": "Could not open the share sheet.",
"nowPlaying": "Playing now", "nowPlaying": "Playing now",
"watchFullscreen": "Watch in landscape",
"exitFullscreen": "Exit fullscreen",
"pictureInPicture": "Picture in picture", "pictureInPicture": "Picture in picture",
"pipUnavailable": "Picture in picture is unavailable on this device", "pipUnavailable": "Picture in picture is unavailable on this device",
"originalTag": "Original", "originalTag": "Original",
@@ -243,8 +273,6 @@
"myListTab": "My List", "myListTab": "My List",
"watchFull": "Watch Full", "watchFull": "Watch Full",
"swipeForNext": "Swipe up for next", "swipeForNext": "Swipe up for next",
"clearScreen": "Clear screen",
"exitClearScreen": "Exit clear screen",
"logIn": "Log in", "logIn": "Log in",
"logInHint": "Keep your coins and list", "logInHint": "Keep your coins and list",
"rewards": "Rewards", "rewards": "Rewards",
+33 -5
View File
@@ -12,9 +12,17 @@
"openQuickMenu": "Buka menu cepat", "openQuickMenu": "Buka menu cepat",
"quickAccess": "Akses cepat", "quickAccess": "Akses cepat",
"currentSeries": "Serial saat ini", "currentSeries": "Serial saat ini",
"discoverTab": "Jelajahi", "discoverTab": "Temukan",
"searchDiscover": "Cari drama, aktor, genre...", "searchDiscover": "Cari drama, aktor, genre...",
"featured": "Unggulan", "featured": "Unggulan",
"recommended": "Rekomendasi",
"fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin",
"comingSoon": "Segera hadir",
"authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.",
"authContinueGoogle": "Lanjutkan dengan Google",
"authPhoneSignIn": "Masuk dengan ponsel",
"authEmailSignIn": "Masuk dengan email",
"suspense": "Thriller", "suspense": "Thriller",
"romance": "Romantis", "romance": "Romantis",
"actionGenre": "Aksi", "actionGenre": "Aksi",
@@ -23,6 +31,30 @@
"newReleases": "Rilisan Baru", "newReleases": "Rilisan Baru",
"trendingMicroDramas": "Drama Mini Populer", "trendingMicroDramas": "Drama Mini Populer",
"seeAll": "Lihat Semua", "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", "artworkOnly": "Gambar",
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
"videoPending": "Video belum siap", "videoPending": "Video belum siap",
@@ -113,8 +145,6 @@
"reportFailed": "Laporan tidak dapat dikirim.", "reportFailed": "Laporan tidak dapat dikirim.",
"shareFailed": "Tidak bisa membuka menu berbagi.", "shareFailed": "Tidak bisa membuka menu berbagi.",
"nowPlaying": "Sedang diputar", "nowPlaying": "Sedang diputar",
"watchFullscreen": "Tonton mode lanskap",
"exitFullscreen": "Keluar dari layar penuh",
"pictureInPicture": "Gambar dalam gambar", "pictureInPicture": "Gambar dalam gambar",
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
"originalTag": "Original", "originalTag": "Original",
@@ -243,8 +273,6 @@
"myListTab": "Daftar Saya", "myListTab": "Daftar Saya",
"watchFull": "Tonton Penuh", "watchFull": "Tonton Penuh",
"swipeForNext": "Geser ke atas untuk lanjut", "swipeForNext": "Geser ke atas untuk lanjut",
"clearScreen": "Bersihkan layar",
"exitClearScreen": "Keluar dari mode bersih",
"logIn": "Masuk", "logIn": "Masuk",
"logInHint": "Simpan koin dan daftar kamu", "logInHint": "Simpan koin dan daftar kamu",
"rewards": "Hadiah", "rewards": "Hadiah",
+127 -25
View File
@@ -190,6 +190,54 @@ abstract class AppLocalizations {
/// **'Featured'** /// **'Featured'**
String get featured; String get featured;
/// No description provided for @recommended.
///
/// In en, this message translates to:
/// **'Recommended'**
String get recommended;
/// No description provided for @fruitShortsBrand.
///
/// In en, this message translates to:
/// **'FruitShorts'**
String get fruitShortsBrand;
/// No description provided for @authLandingSubtitle.
///
/// In en, this message translates to:
/// **'Sign in to sync progress, favorites and coins'**
String get authLandingSubtitle;
/// No description provided for @comingSoon.
///
/// In en, this message translates to:
/// **'Coming soon'**
String get comingSoon;
/// No description provided for @authLegal.
///
/// In en, this message translates to:
/// **'By continuing, you agree to the Terms of Service and Privacy Policy.'**
String get authLegal;
/// No description provided for @authContinueGoogle.
///
/// In en, this message translates to:
/// **'Continue with Google'**
String get authContinueGoogle;
/// No description provided for @authPhoneSignIn.
///
/// In en, this message translates to:
/// **'Phone sign in'**
String get authPhoneSignIn;
/// No description provided for @authEmailSignIn.
///
/// In en, this message translates to:
/// **'Email sign in'**
String get authEmailSignIn;
/// No description provided for @suspense. /// No description provided for @suspense.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -238,6 +286,84 @@ abstract class AppLocalizations {
/// **'See All'** /// **'See All'**
String get seeAll; 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. /// No description provided for @artworkOnly.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -325,7 +451,7 @@ abstract class AppLocalizations {
/// No description provided for @watchlist. /// No description provided for @watchlist.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Watchlist'** /// **'Favorites'**
String get watchlist; String get watchlist;
/// No description provided for @likedTab. /// No description provided for @likedTab.
@@ -664,18 +790,6 @@ abstract class AppLocalizations {
/// **'Playing now'** /// **'Playing now'**
String get nowPlaying; 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. /// No description provided for @pictureInPicture.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1114,18 +1228,6 @@ abstract class AppLocalizations {
/// **'Swipe up for next'** /// **'Swipe up for next'**
String get swipeForNext; 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. /// No description provided for @logIn.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+70 -13
View File
@@ -54,6 +54,32 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get featured => 'Featured'; String get featured => 'Featured';
@override
String get recommended => 'Recommended';
@override
String get fruitShortsBrand => 'FruitShorts';
@override
String get authLandingSubtitle =>
'Sign in to sync progress, favorites and coins';
@override
String get comingSoon => 'Coming soon';
@override
String get authLegal =>
'By continuing, you agree to the Terms of Service and Privacy Policy.';
@override
String get authContinueGoogle => 'Continue with Google';
@override
String get authPhoneSignIn => 'Phone sign in';
@override
String get authEmailSignIn => 'Email sign in';
@override @override
String get suspense => 'Suspense'; String get suspense => 'Suspense';
@@ -78,6 +104,49 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get seeAll => 'See All'; 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 @override
String get artworkOnly => 'Artwork'; String get artworkOnly => 'Artwork';
@@ -125,7 +194,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get history => 'History'; String get history => 'History';
@override @override
String get watchlist => 'Watchlist'; String get watchlist => 'Favorites';
@override @override
String get likedTab => 'Liked'; String get likedTab => 'Liked';
@@ -300,12 +369,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get nowPlaying => 'Playing now'; String get nowPlaying => 'Playing now';
@override
String get watchFullscreen => 'Watch in landscape';
@override
String get exitFullscreen => 'Exit fullscreen';
@override @override
String get pictureInPicture => 'Picture in picture'; String get pictureInPicture => 'Picture in picture';
@@ -544,12 +607,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get swipeForNext => 'Swipe up for next'; String get swipeForNext => 'Swipe up for next';
@override
String get clearScreen => 'Clear screen';
@override
String get exitClearScreen => 'Exit clear screen';
@override @override
String get logIn => 'Log in'; String get logIn => 'Log in';
+70 -13
View File
@@ -46,7 +46,7 @@ class AppLocalizationsId extends AppLocalizations {
String get currentSeries => 'Serial saat ini'; String get currentSeries => 'Serial saat ini';
@override @override
String get discoverTab => 'Jelajahi'; String get discoverTab => 'Temukan';
@override @override
String get searchDiscover => 'Cari drama, aktor, genre...'; String get searchDiscover => 'Cari drama, aktor, genre...';
@@ -54,6 +54,32 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get featured => 'Unggulan'; String get featured => 'Unggulan';
@override
String get recommended => 'Rekomendasi';
@override
String get fruitShortsBrand => 'FruitShorts';
@override
String get authLandingSubtitle =>
'Masuk untuk menyinkronkan progres, favorit, dan koin';
@override
String get comingSoon => 'Segera hadir';
@override
String get authLegal =>
'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.';
@override
String get authContinueGoogle => 'Lanjutkan dengan Google';
@override
String get authPhoneSignIn => 'Masuk dengan ponsel';
@override
String get authEmailSignIn => 'Masuk dengan email';
@override @override
String get suspense => 'Thriller'; String get suspense => 'Thriller';
@@ -78,6 +104,49 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get seeAll => 'Lihat Semua'; 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 @override
String get artworkOnly => 'Gambar'; String get artworkOnly => 'Gambar';
@@ -300,12 +369,6 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get nowPlaying => 'Sedang diputar'; String get nowPlaying => 'Sedang diputar';
@override
String get watchFullscreen => 'Tonton mode lanskap';
@override
String get exitFullscreen => 'Keluar dari layar penuh';
@override @override
String get pictureInPicture => 'Gambar dalam gambar'; String get pictureInPicture => 'Gambar dalam gambar';
@@ -546,12 +609,6 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get swipeForNext => 'Geser ke atas untuk lanjut'; String get swipeForNext => 'Geser ke atas untuk lanjut';
@override
String get clearScreen => 'Bersihkan layar';
@override
String get exitClearScreen => 'Keluar dari mode bersih';
@override @override
String get logIn => 'Masuk'; String get logIn => 'Masuk';
+69 -14
View File
@@ -45,7 +45,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get currentSeries => '当前短剧'; String get currentSeries => '当前短剧';
@override @override
String get discoverTab => '找剧'; String get discoverTab => '发现';
@override @override
String get searchDiscover => '搜索短剧、演员、类型…'; String get searchDiscover => '搜索短剧、演员、类型…';
@@ -53,6 +53,30 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get featured => '精选'; String get featured => '精选';
@override
String get recommended => '推荐';
@override
String get fruitShortsBrand => '水果短剧';
@override
String get authLandingSubtitle => '登录后同步进度、收藏与金币权益';
@override
String get comingSoon => '即将开放';
@override
String get authLegal => '继续即代表同意《用户协议》与《隐私政策》';
@override
String get authContinueGoogle => '使用 Google 继续';
@override
String get authPhoneSignIn => '手机号登录';
@override
String get authEmailSignIn => '邮箱登录';
@override @override
String get suspense => '悬疑'; String get suspense => '悬疑';
@@ -77,6 +101,49 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get seeAll => '查看全部'; 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 @override
String get artworkOnly => '海报展示'; String get artworkOnly => '海报展示';
@@ -122,7 +189,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get history => '历史'; String get history => '历史';
@override @override
String get watchlist => '追剧'; String get watchlist => '收藏';
@override @override
String get likedTab => '喜欢'; String get likedTab => '喜欢';
@@ -296,12 +363,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get nowPlaying => '正在播放'; String get nowPlaying => '正在播放';
@override
String get watchFullscreen => '横屏全屏观看';
@override
String get exitFullscreen => '退出全屏';
@override @override
String get pictureInPicture => '画中画'; String get pictureInPicture => '画中画';
@@ -535,12 +596,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get swipeForNext => '上滑看下一部'; String get swipeForNext => '上滑看下一部';
@override
String get clearScreen => '清屏';
@override
String get exitClearScreen => '退出清屏';
@override @override
String get logIn => '登录'; String get logIn => '登录';
+34 -6
View File
@@ -12,9 +12,17 @@
"openQuickMenu": "打开快捷菜单", "openQuickMenu": "打开快捷菜单",
"quickAccess": "快捷入口", "quickAccess": "快捷入口",
"currentSeries": "当前短剧", "currentSeries": "当前短剧",
"discoverTab": "找剧", "discoverTab": "发现",
"searchDiscover": "搜索短剧、演员、类型…", "searchDiscover": "搜索短剧、演员、类型…",
"featured": "精选", "featured": "精选",
"recommended": "推荐",
"fruitShortsBrand": "水果短剧",
"authLandingSubtitle": "登录后同步进度、收藏与金币权益",
"comingSoon": "即将开放",
"authLegal": "继续即代表同意《用户协议》与《隐私政策》",
"authContinueGoogle": "使用 Google 继续",
"authPhoneSignIn": "手机号登录",
"authEmailSignIn": "邮箱登录",
"suspense": "悬疑", "suspense": "悬疑",
"romance": "都市爱情", "romance": "都市爱情",
"actionGenre": "动作", "actionGenre": "动作",
@@ -23,6 +31,30 @@
"newReleases": "新剧", "newReleases": "新剧",
"trendingMicroDramas": "热门短剧", "trendingMicroDramas": "热门短剧",
"seeAll": "查看全部", "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": "海报展示", "artworkOnly": "海报展示",
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
"videoPending": "视频待提供", "videoPending": "视频待提供",
@@ -44,7 +76,7 @@
}, },
"resume": "继续播放", "resume": "继续播放",
"history": "历史", "history": "历史",
"watchlist": "追剧", "watchlist": "收藏",
"likedTab": "喜欢", "likedTab": "喜欢",
"all": "全部", "all": "全部",
"inProgress": "未看完", "inProgress": "未看完",
@@ -113,8 +145,6 @@
"reportFailed": "举报提交失败,请重试。", "reportFailed": "举报提交失败,请重试。",
"shareFailed": "无法打开系统分享面板。", "shareFailed": "无法打开系统分享面板。",
"nowPlaying": "正在播放", "nowPlaying": "正在播放",
"watchFullscreen": "横屏全屏观看",
"exitFullscreen": "退出全屏",
"pictureInPicture": "画中画", "pictureInPicture": "画中画",
"pipUnavailable": "此设备无法使用画中画", "pipUnavailable": "此设备无法使用画中画",
"originalTag": "原创", "originalTag": "原创",
@@ -205,8 +235,6 @@
"myListTab": "片单", "myListTab": "片单",
"watchFull": "看全集", "watchFull": "看全集",
"swipeForNext": "上滑看下一部", "swipeForNext": "上滑看下一部",
"clearScreen": "清屏",
"exitClearScreen": "退出清屏",
"logIn": "登录", "logIn": "登录",
"logInHint": "登录后保存金币和片单", "logInHint": "登录后保存金币和片单",
"rewards": "奖励", "rewards": "奖励",
+161 -9
View File
@@ -60,6 +60,7 @@ class _AuthPageState extends State<AuthPage> {
late final FocusNode _emailFocus; late final FocusNode _emailFocus;
late final FocusNode _passwordFocus; late final FocusNode _passwordFocus;
bool _obscurePassword = true; bool _obscurePassword = true;
bool _emailEntryOpen = false;
@override @override
void initState() { void initState() {
@@ -243,7 +244,13 @@ class _AuthPageState extends State<AuthPage> {
tooltip: MaterialLocalizations.of( tooltip: MaterialLocalizations.of(
context, context,
).backButtonTooltip, ).backButtonTooltip,
onPressed: () => Navigator.of(context).pop(), onPressed: () {
if (_emailEntryOpen) {
setState(() => _emailEntryOpen = false);
} else {
Navigator.of(context).pop();
}
},
icon: const Icon( icon: const Icon(
Icons.arrow_back_rounded, Icons.arrow_back_rounded,
color: AppColors.onPrimary, color: AppColors.onPrimary,
@@ -272,16 +279,107 @@ class _AuthPageState extends State<AuthPage> {
child: child, child: child,
), ),
), ),
child: Column( child: _emailEntryOpen
crossAxisAlignment: CrossAxisAlignment.stretch, ? Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact), children: [
SizedBox(height: compact ? _compactBrandGap : 28), _AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
_authCard(l10n), SizedBox(height: compact ? _compactBrandGap : 28),
], _authCard(l10n),
), ],
)
: _landing(l10n),
); );
Widget _landing(AppLocalizations l10n) => Column(
key: const Key('auth-landing'),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
key: const Key('auth-brand-logo'),
width: 64,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue, AppColors.secondary],
),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 32,
),
),
),
const SizedBox(height: 14),
Text(
l10n.fruitShortsBrand,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
l10n.authLandingSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11),
),
const SizedBox(height: 22),
_AuthEntryButton(
key: const Key('auth-entry-google'),
label: 'G ${l10n.authContinueGoogle}',
background: Colors.white,
foreground: const Color(0xFF1A1A1A),
leadingColor: const Color(0xFF4285F4),
onTap: () => _comingSoon(l10n),
),
const SizedBox(height: 11),
_AuthEntryButton(
key: const Key('auth-entry-phone'),
label: l10n.authPhoneSignIn,
icon: Icons.phone_android_rounded,
background: AppColors.input,
foreground: AppColors.onSurface,
onTap: () => _comingSoon(l10n),
),
const SizedBox(height: 11),
_AuthEntryButton(
key: const Key('auth-entry-email'),
label: l10n.authEmailSignIn,
icon: Icons.mail_outline_rounded,
background: AppColors.input,
foreground: AppColors.onSurface,
onTap: () => setState(() => _emailEntryOpen = true),
),
const SizedBox(height: 18),
Text(
l10n.authLegal,
key: const Key('auth-legal'),
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF6B6772),
fontSize: 9,
height: 1.6,
),
),
],
);
void _comingSoon(AppLocalizations l10n) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.comingSoon)));
}
Widget _authCard(AppLocalizations l10n) => _AuthCard( Widget _authCard(AppLocalizations l10n) => _AuthCard(
state: _AuthCardState( state: _AuthCardState(
l10n: l10n, l10n: l10n,
@@ -320,3 +418,57 @@ class _AuthPageState extends State<AuthPage> {
setState(() => _logic.toggleMode()); setState(() => _logic.toggleMode());
} }
} }
class _AuthEntryButton extends StatelessWidget {
const _AuthEntryButton({
super.key,
required this.label,
required this.background,
required this.foreground,
required this.onTap,
this.icon,
this.leadingColor,
});
final String label;
final Color background;
final Color foreground;
final VoidCallback onTap;
final IconData? icon;
final Color? leadingColor;
@override
Widget build(BuildContext context) {
return Material(
color: background,
borderRadius: BorderRadius.circular(9),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 44,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null)
Icon(icon, size: 16, color: leadingColor ?? foreground),
if (icon != null) const SizedBox(width: 7),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}
+1 -4
View File
@@ -8,8 +8,5 @@ class DiscoverBinding {
DiscoverLogic create({ DiscoverLogic create({
required Drama drama, required Drama drama,
required WatchHistoryRepository history, required WatchHistoryRepository history,
}) => DiscoverLogic( }) => DiscoverLogic(drama: drama, history: history);
drama: drama,
history: history,
);
} }
+40
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/data/models/drama.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/repositories/watch_history_repository.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier {
final WatchHistoryRepository history; final WatchHistoryRepository history;
Drama drama; Drama drama;
late Future<Duration?> position; late Future<Duration?> position;
WatchProgress? resumeRecord;
final TextEditingController search = TextEditingController(); final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode(); final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController(); final ScrollController scroll = ScrollController();
@@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier {
.resumePointFor(drama.id) .resumePointFor(drama.id)
.then((point) => point?.position) .then((point) => point?.position)
.catchError((Object _) => null); .catchError((Object _) => null);
refreshResume();
}
Future<void> 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(); void onSearchChanged() => notify();
List<Drama> filtered(List<Drama> 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<Drama> 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; bool get showSearch => searchOpen || search.text.isNotEmpty;
void openSearch() { void openSearch() {
+433 -187
View File
@@ -4,10 +4,10 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/drama.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/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart'; import '../../core/data/services/category_controller.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_dimens.dart';
@@ -18,7 +18,6 @@ import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'discover_binding.dart'; import 'discover_binding.dart';
import 'discover_logic.dart'; import 'discover_logic.dart';
import 'widgets/cover_carousel.dart';
part 'widgets/home_drama_grid.dart'; part 'widgets/home_drama_grid.dart';
@@ -70,7 +69,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
drama: widget.drama, drama: widget.drama,
history: widget.history ?? context.read<WatchHistoryRepository>(), history: widget.history ?? context.read<WatchHistoryRepository>(),
); );
if (widget.focusSearch) logic.searchOpen = true; if (widget.focusSearch) {
logic.searchOpen = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.searchFocus.requestFocus();
});
}
} }
@override @override
@@ -104,14 +108,14 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) { Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>(); final categories = context.watch<CategoryController>();
final homeBanners = context.watch<CatalogController>().feed.banners;
final allDramas = [...?widget.dramas, ...categories.visibleDramas]; final allDramas = [...?widget.dramas, ...categories.visibleDramas];
final visible = logic.filtered(categories.visibleDramas);
final content = SafeArea( final content = SafeArea(
bottom: !widget.embedded, bottom: !widget.embedded,
child: Column( child: Column(
children: [ children: [
_header(l10n), _header(l10n),
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)), Expanded(child: _feed(l10n, categories, allDramas, visible)),
if (!widget.embedded) _bottomNav(l10n), if (!widget.embedded) _bottomNav(l10n),
], ],
), ),
@@ -130,134 +134,216 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _feed( Widget _feed(
AppLocalizations l10n, AppLocalizations l10n,
CategoryController categories, CategoryController categories,
List<FeedBanner> banners,
List<Drama> allDramas, List<Drama> allDramas,
) => CustomScrollView( List<Drama> visible,
controller: logic.scroll, ) => RefreshIndicator(
slivers: [ key: const Key('discover-refresh'),
SliverToBoxAdapter( color: AppColors.blue,
child: Padding( backgroundColor: AppColors.card,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), onRefresh: () async {
child: ClipRRect( await Future.wait([categories.load(), logic.refreshResume()]);
borderRadius: BorderRadius.circular(18), },
child: _carousel(l10n, banners, allDramas), 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),
), ),
), ),
), if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 14), padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _categories(l10n, categories), child: _continueCard(
), l10n,
), logic.resumeDrama(allDramas)!,
_HomeDramaGrid( logic.resumeRecord!,
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,
), ),
), ),
), ),
const SizedBox(width: 8), SliverToBoxAdapter(
if (widget.signedIn) ...[ child: Padding(
_HeaderPill( padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
icon: Icons.monetization_on_rounded, child: _quickActions(l10n, categories),
label: r'$ 0', ),
onTap: widget.onCoins, ),
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), const SizedBox(width: 8),
_HeaderPill( Text(
icon: Icons.card_giftcard_rounded, l10n.discoverTab,
label: l10n.rewards, style: const TextStyle(
onTap: widget.onRewards, fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
), ),
] else const Spacer(),
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn), _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( Widget _search() {
AppLocalizations l10n, return Padding(
List<FeedBanner> banners, padding: const EdgeInsets.symmetric(horizontal: 14),
List<Drama> allDramas, child: SizedBox(
) { height: 36,
final items = banners child: TextField(
.where((banner) => banner.imageUrl.isNotEmpty) key: const Key('discover-search'),
.toList(); controller: logic.search,
if (items.isEmpty) return const SizedBox.shrink(); focusNode: logic.searchFocus,
return CoverCarousel( onChanged: (_) => logic.onSearchChanged(),
active: widget.active, onTapOutside: (_) => logic.searchFocus.unfocus(),
items: [ textInputAction: TextInputAction.search,
for (final banner in items) style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
CoverItem( decoration: InputDecoration(
title: _bannerTitle(banner, allDramas), isDense: true,
subtitle: '', filled: true,
image: banner.imageUrl, fillColor: AppColors.input,
onOpen: () => _openBanner(banner, allDramas), 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<Drama> 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<Drama> 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) { Widget _categories(AppLocalizations l10n, CategoryController categories) {
final choices = <({String code, String label})>[ final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.all), (code: CategoryController.allCode, label: l10n.recommended),
(code: CategoryController.hotCode, label: l10n.hot),
(code: CategoryController.newCode, label: l10n.newest),
for (final category in categories.categories) for (final category in categories.categories)
(code: 'category-${category.id}', label: category.name), (code: 'category-${category.id}', label: category.name),
]; ];
@@ -265,6 +351,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
key: const Key('home-category-chips'), key: const Key('home-category-chips'),
height: AppDimens.tapTarget, height: AppDimens.tapTarget,
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: choices.length, itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8), separatorBuilder: (_, _) => const SizedBox(width: 8),
@@ -281,17 +368,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? AppColors.blue : AppColors.raised, color: selected ? AppColors.blue : const Color(0xFF29272B),
borderRadius: BorderRadius.circular(AppDimens.pillRadius), borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected ? AppColors.blue : Colors.transparent,
),
), ),
child: Text( child: Text(
choice.label, choice.label,
maxLines: 1, maxLines: 1,
style: TextStyle( style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.cream, color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.body, fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500, fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
height: 1.1, height: 1.1,
), ),
@@ -305,6 +395,173 @@ class _DiscoverPageState extends State<DiscoverPage> {
); );
} }
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( Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
items: [ items: [
AppBottomNavItem( AppBottomNavItem(
@@ -343,72 +600,81 @@ class _DiscoverPageState extends State<DiscoverPage> {
); );
} }
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 { final IconData icon;
const _BrandMark(); final String tooltip;
final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return SizedBox(
width: 28, width: 36,
height: 28, height: 36,
alignment: Alignment.center, child: IconButton(
decoration: BoxDecoration( tooltip: tooltip,
color: AppColors.blue, onPressed: onTap,
borderRadius: BorderRadius.circular(8), padding: EdgeInsets.zero,
), style: IconButton.styleFrom(
child: const Icon( backgroundColor: const Color(0xFF2C2D33),
Icons.play_arrow_rounded, foregroundColor: AppColors.onSurface,
color: Colors.white, ),
size: 20, icon: Icon(icon, size: 18),
), ),
); );
} }
} }
class _HeaderPill extends StatelessWidget { class _QuickAction extends StatelessWidget {
const _HeaderPill({ const _QuickAction({
super.key,
required this.icon, required this.icon,
required this.label, required this.label,
required this.color,
required this.onTap, required this.onTap,
}); });
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback? onTap; final Color color;
final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
color: AppColors.raised, color: AppColors.quick,
shape: StadiumBorder( borderRadius: BorderRadius.circular(12),
side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)), clipBehavior: Clip.antiAlias,
),
child: InkWell( child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap, onTap: onTap,
child: ConstrainedBox( child: Container(
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget), height: 62,
child: Padding( padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
child: Row( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, size: 15, color: _coin), Icon(icon, color: color, size: 16),
const SizedBox(width: 4), const SizedBox(height: 4),
Text( Text(
label, label,
maxLines: 1, maxLines: 2,
style: const TextStyle( textAlign: TextAlign.center,
color: AppColors.cream, overflow: TextOverflow.ellipsis,
fontSize: AppDimens.caption, style: const TextStyle(
fontWeight: FontWeight.w700, color: AppColors.muted,
height: 1.1, fontSize: 9,
), fontWeight: FontWeight.w500,
height: 1.05,
), ),
], ),
), ],
), ),
), ),
), ),
@@ -416,28 +682,8 @@ class _HeaderPill extends StatelessWidget {
} }
} }
class _LoginButton extends StatelessWidget { String _duration(Duration value) {
const _LoginButton({required this.label, required this.onTap}); final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
final String label; return '$minutes:$seconds';
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),
);
}
} }
@@ -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<CoverItem> items;
final bool active;
@override
State<CoverCarousel> createState() => _CoverCarouselState();
}
class _CoverCarouselState extends State<CoverCarousel>
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<int> get _visible {
final first = _page.floor();
final indexes = <int>[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)],
),
),
],
),
),
],
);
}
}
+130 -107
View File
@@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget {
const _HomeDramaGrid({ const _HomeDramaGrid({
required this.l10n, required this.l10n,
required this.categories, required this.categories,
required this.dramas,
required this.searching,
required this.onOpen, required this.onOpen,
}); });
final AppLocalizations l10n; final AppLocalizations l10n;
final CategoryController categories; final CategoryController categories;
final List<Drama> dramas;
final bool searching;
final ValueChanged<Drama> onOpen; final ValueChanged<Drama> onOpen;
@override @override
@@ -33,55 +37,65 @@ class _HomeDramaGrid extends StatelessWidget {
), ),
); );
} }
final dramas = categories.visibleDramas;
if (dramas.isEmpty) { if (dramas.isEmpty) {
final missingCategory = categories.selectedSectionMissing;
return _fill( return _fill(
EmptyState( EmptyState(
key: const Key('home-all-dramas-empty'), key: const Key('home-all-dramas-empty'),
icon: missingCategory icon: searching
? Icons.grid_view_rounded ? Icons.search_off_rounded
: Icons.search_off_rounded, : Icons.movie_filter_outlined,
title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults, title: searching
? l10n.noSearchResults
: categories.selectedSectionMissing
? l10n.noCategoryDramas
: l10n.noSearchResults,
subtitle: l10n.emptyHint, subtitle: l10n.emptyHint,
), ),
); );
} }
return SliverMainAxisGroup( return SliverMainAxisGroup(
slivers: [ slivers: [
_dramaGrid(dramas), SliverToBoxAdapter(child: _masonry(dramas)),
const SliverToBoxAdapter(child: SizedBox(height: 24)), const SliverToBoxAdapter(child: SizedBox(height: 24)),
], ],
); );
} }
Widget _dramaGrid(List<Drama> dramas) => SliverPadding( Widget _masonry(List<Drama> items) {
key: const Key('home-all-dramas-section'), return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), key: const Key('home-all-dramas-section'),
sliver: SliverGrid( padding: const EdgeInsets.symmetric(horizontal: 14),
key: const Key('home-all-dramas-grid'), child: Row(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( key: const Key('home-all-dramas-grid'),
crossAxisCount: 3, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSpacing: 14, children: [
crossAxisSpacing: 10, Expanded(child: _column(items, 0)),
childAspectRatio: 2 / 3, 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<Drama> items, int start) {
final children = <Widget>[];
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 { class _HomeDramaCard extends StatelessWidget {
@@ -98,96 +112,105 @@ class _HomeDramaCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final genre = drama.genre.isEmpty ? drama.category : drama.genre; final genre = drama.genre.isEmpty ? drama.category : drama.genre;
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return InkWell( return InkWell(
key: Key('home-drama-card-${drama.id}'), key: Key('home-drama-card-${drama.id}'),
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(AppDimens.cardRadius), borderRadius: BorderRadius.circular(10),
child: AspectRatio( child: Column(
key: Key('home-drama-cover-${drama.id}'), crossAxisAlignment: CrossAxisAlignment.start,
aspectRatio: 2 / 3, children: [
child: ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.cardRadius), key: Key('home-drama-cover-${drama.id}'),
child: Stack( borderRadius: BorderRadius.circular(10),
fit: StackFit.expand, child: Stack(
children: [ children: [
RemoteImage( _DiscoverCoverImage(path: drama.posterUrl),
drama.posterUrl, if (free)
fit: BoxFit.cover, Positioned(
showFallbackIcon: true, top: 6,
), left: 6,
const DecoratedBox( child: Container(
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(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text( child: Text(
l10n.hot, l10n.free,
style: const TextStyle( style: const TextStyle(
color: AppColors.onPrimary, color: AppColors.onPrimary,
fontSize: AppDimens.caption, fontSize: 8,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
height: 1.1,
), ),
), ),
), ),
), ),
), ],
Positioned( ),
left: 10, ),
right: 10, const SizedBox(height: 5),
bottom: 10, Text(
child: Column( drama.title,
crossAxisAlignment: CrossAxisAlignment.start, key: Key('home-drama-title-${drama.id}'),
mainAxisSize: MainAxisSize.min, maxLines: 1,
children: [ overflow: TextOverflow.ellipsis,
Text( style: const TextStyle(
drama.title, color: AppColors.onSurface,
key: Key('home-drama-title-${drama.id}'), fontSize: 11,
maxLines: 2, fontWeight: FontWeight.w800,
overflow: TextOverflow.ellipsis, ),
style: const TextStyle( ),
color: AppColors.cream, const SizedBox(height: 2),
fontSize: AppDimens.body, Text(
fontWeight: FontWeight.w800, genre.isEmpty ? l10n.videoPending : genre,
height: 1.15, key: Key('home-drama-genre-${drama.id}'),
), maxLines: 1,
), overflow: TextOverflow.ellipsis,
const SizedBox(height: 4), style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
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, class _DiscoverCoverImage extends StatelessWidget {
fontWeight: FontWeight.w400, const _DiscoverCoverImage({required this.path});
height: 1.2,
), 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,
), ),
), ),
), ),
+447 -253
View File
@@ -6,30 +6,35 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/data/models/drama.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/catalog_controller.dart';
import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/interaction_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/error_text.dart';
import '../../core/formatting/localized_content.dart'; import '../../core/formatting/localized_content.dart';
import '../../core/platform/haptics.dart'; import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart'; import '../../core/widgets/download_action.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.dart'; import '../interaction/widgets/report_dialog.dart';
import 'drama_detail_binding.dart'; import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart'; import 'drama_detail_logic.dart';
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 { class DramaDetailPage extends StatefulWidget {
const DramaDetailPage({super.key, required this.drama}); const DramaDetailPage({super.key, required this.drama});
final Drama drama; final Drama drama;
@override @override
@@ -78,254 +83,430 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget _page(BuildContext context) { Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
if (logic.loading) { if (logic.loading) return _loading(l10n);
return Scaffold( if (logic.failed) return _failed(l10n);
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)),
],
),
),
),
);
}
final drama = logic.drama; final drama = logic.drama;
if (drama.episodes.isEmpty) { if (drama.episodes.isEmpty) return _empty(l10n, drama);
// 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏
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,
),
);
}
return Scaffold( return Scaffold(
backgroundColor: AppColors.ink, backgroundColor: AppColors.ink,
body: CustomScrollView( body: CustomScrollView(
key: const Key('detail-scroll'),
slivers: [ slivers: [
SliverAppBar( SliverToBoxAdapter(child: _hero(context, l10n, drama)),
expandedHeight: 300, SliverToBoxAdapter(child: _content(context, l10n, drama)),
pinned: true, const SliverToBoxAdapter(child: SizedBox(height: 28)),
backgroundColor: AppColors.ink, ],
foregroundColor: Colors.white, ),
actions: [ );
_reportAction(context, l10n), }
_downloadAction(context, l10n),
_favoriteAction(context, l10n), Widget _loading(AppLocalizations l10n) => Scaffold(
], backgroundColor: AppColors.ink,
flexibleSpace: FlexibleSpaceBar( appBar: AppBar(
background: Stack( backgroundColor: AppColors.ink,
fit: StackFit.expand, foregroundColor: AppColors.onPrimary,
children: [ title: Text(localizedDramaTitle(l10n, widget.drama)),
RemoteImage(drama.posterUrl), ),
const DecoratedBox( body: const Center(child: CircularProgressIndicator()),
decoration: BoxDecoration( );
gradient: LinearGradient(
begin: Alignment.topCenter, Widget _failed(AppLocalizations l10n) => Scaffold(
end: Alignment.center, backgroundColor: AppColors.ink,
colors: [Color(0x99000000), Color(0x00000000)], 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( Positioned(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), top: MediaQuery.paddingOf(context).top + 14,
sliver: SliverList( left: 14,
delegate: SliverChildListDelegate([ child: _RoundAction(
Text( key: const Key('detail-back'),
l10n.technicalDemoCount(drama.episodes.length), tooltip: MaterialLocalizations.of(context).backButtonTooltip,
style: Theme.of(context).textTheme.labelSmall?.copyWith( icon: Icons.chevron_left_rounded,
letterSpacing: 1.4, onPressed: () => Navigator.of(context).maybePop(),
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,
),
),
]),
), ),
), ),
SliverPadding( Positioned(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 40), top: MediaQuery.paddingOf(context).top + 14,
sliver: SliverList.builder( right: 14,
itemCount: drama.episodes.length, child: _moreMenu(context, l10n),
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),
),
);
},
),
), ),
], ],
), ),
); );
} }
double? get _continueFraction { Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) {
final record = logic.progress; final continueIndex = logic.continueIndex;
if (record == null || !record.hasPosition) return null; return Padding(
if (logic.continueIndex == null) return null; padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
return record.fraction; child: Column(
} crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// 下载当前续看集或第一集;业务判定完全交给服务端。 Text(
Widget _downloadAction(BuildContext context, AppLocalizations l10n) { l10n.episodeCount(drama.episodeCount),
final episodes = logic.drama.episodes; key: const Key('detail-episode-count'),
final index = logic.continueIndex ?? 0; style: const TextStyle(
final episode = episodes.isEmpty color: AppColors.secondary,
? null fontSize: 9,
: episodes[index.clamp(0, episodes.length - 1)]; fontWeight: FontWeight.w800,
return IconButton( letterSpacing: 1,
key: const Key('detail-download'), ),
tooltip: l10n.downloads, ),
icon: const Icon(Icons.download_outlined), const SizedBox(height: 7),
onPressed: episode == null Text(
? null localizedDramaTitle(l10n, drama),
: () => requestEpisodeDownload(context, episode.id), 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 _episodeChip(
Widget _reportAction(BuildContext context, AppLocalizations l10n) { 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<FavoriteController>().isSaved(logic.drama.id);
return PopupMenuButton<String>( return PopupMenuButton<String>(
key: const Key('detail-more'), key: const Key('detail-more'),
tooltip: l10n.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: (_) => [ 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<void> _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<void> _toggleFavorite(AppLocalizations l10n) async {
if (!await ensureSignedIn(context)) return;
if (!mounted) return;
unawaited(context.read<Haptics>().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<void> _reportDrama(AppLocalizations l10n) async { Future<void> _reportDrama(AppLocalizations l10n) async {
if (!await ensureSignedIn(context) || !mounted) return; if (!await ensureSignedIn(context) || !mounted) return;
final draft = await showReportDialog(context, title: l10n.reportDrama); final draft = await showReportDialog(context, title: l10n.reportDrama);
@@ -348,25 +529,11 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
); );
} }
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。 double? get _continueFraction {
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) { final record = logic.progress;
final favorites = context.watch<FavoriteController>(); if (record == null || !record.hasPosition) return null;
final saved = favorites.isSaved(logic.drama.id); if (logic.continueIndex == null) return null;
return IconButton( return record.fraction;
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<Haptics>().lightImpact());
final ok = await logic.toggleFavorite();
if (ok || !context.mounted) return;
// 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
},
);
} }
String _clock(Duration duration) { String _clock(Duration duration) {
@@ -378,7 +545,37 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
} }
} }
/// 续看进度条(详情页按钮下 / 剧集行内)。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 { class _ResumeBar extends StatelessWidget {
const _ResumeBar({required this.fraction, this.label}); const _ResumeBar({required this.fraction, this.label});
@@ -391,22 +588,19 @@ class _ResumeBar extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.progressRadius), borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: fraction, value: fraction,
minHeight: AppDimens.progressHeight, minHeight: 3,
backgroundColor: Colors.white24, backgroundColor: const Color(0xFF474248),
valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), valueColor: const AlwaysStoppedAnimation(AppColors.blue),
), ),
), ),
if (label != null) ...[ if (label != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
label!, label!,
style: const TextStyle( style: const TextStyle(color: AppColors.muted, fontSize: 10),
color: AppColors.muted,
fontSize: AppDimens.caption,
),
), ),
], ],
], ],
+6 -19
View File
@@ -15,7 +15,7 @@ class HomeLogic extends ChangeNotifier {
final scaffoldKey = GlobalKey<ScaffoldState>(); final scaffoldKey = GlobalKey<ScaffoldState>();
Drama currentDrama; Drama currentDrama;
int tab = 1; int tab = 0;
int searchRequest = 0; int searchRequest = 0;
int sectionRequest = 0; int sectionRequest = 0;
int resumeRequest = 0; int resumeRequest = 0;
@@ -49,21 +49,20 @@ class HomeLogic extends ChangeNotifier {
notify(); notify();
} }
void openMenu() { void setMenuOpen(bool value) {
drawerOpen = true; if (drawerOpen == value) return;
drawerOpen = value;
notify(); notify();
scaffoldKey.currentState?.openDrawer();
} }
void selectFromMenu(int nextTab, {int? mySection}) { void selectFromMenu(int nextTab, {int? mySection}) {
scaffoldKey.currentState?.closeDrawer(); drawerOpen = false;
select(nextTab, mySection: mySection); select(nextTab, mySection: mySection);
} }
Future<void> openCurrentSeries(BuildContext context) async { Future<void> openCurrentSeries(BuildContext context) async {
detailOpen = true; detailOpen = true;
notify(); notify();
scaffoldKey.currentState?.closeDrawer();
await WidgetsBinding.instance.endOfFrame; await WidgetsBinding.instance.endOfFrame;
if (!context.mounted) return; if (!context.mounted) return;
try { try {
@@ -102,13 +101,11 @@ class HomeLogic extends ChangeNotifier {
void openProfile() { void openProfile() {
profile = true; profile = true;
scaffoldKey.currentState?.closeDrawer();
select(2, mySection: 0); select(2, mySection: 0);
} }
void openLibrary({int section = 1}) { void openLibrary({int section = 1}) {
profile = false; profile = false;
scaffoldKey.currentState?.closeDrawer();
select(2, mySection: section); select(2, mySection: section);
} }
@@ -139,24 +136,14 @@ class HomeLogic extends ChangeNotifier {
} }
void onSystemBack() { void onSystemBack() {
if (drawerOpen) { if (videoImmersive) {
scaffoldKey.currentState?.closeDrawer();
} else if (videoImmersive) {
backRequest++; backRequest++;
notify(); notify();
} else if (tab != 1) { } else if (tab != 1) {
select(1); select(1);
} else {
openMenu();
} }
} }
void onDrawerChanged(bool opened) {
if (drawerOpen == opened) return;
drawerOpen = opened;
notify();
}
void onDramaChanged(Drama drama) { void onDramaChanged(Drama drama) {
currentDrama = drama; currentDrama = drama;
notify(); notify();
+39 -36
View File
@@ -24,7 +24,7 @@ import '../mine/mine_view.dart';
import '../player/player_view.dart'; import '../player/player_view.dart';
import 'home_binding.dart'; import 'home_binding.dart';
import 'home_logic.dart'; import 'home_logic.dart';
import 'widgets/home_quick_drawer.dart'; import 'widgets/home_menu_sheet.dart';
class HomePage extends StatelessWidget { class HomePage extends StatelessWidget {
const HomePage({super.key}); const HomePage({super.key});
@@ -173,21 +173,15 @@ class _HomeTabsState extends State<_HomeTabs> {
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (didPop) return; if (didPop) return;
logic.onSystemBack(); if (!logic.videoImmersive && logic.tab == 1) {
unawaited(_openMenu(context));
} else {
logic.onSystemBack();
}
}, },
child: Scaffold( child: Scaffold(
key: logic.scaffoldKey, key: logic.scaffoldKey,
backgroundColor: AppColors.ink, 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( body: IndexedStack(
index: logic.tab, index: logic.tab,
children: [ children: [
@@ -204,7 +198,7 @@ class _HomeTabsState extends State<_HomeTabs> {
onDiscover: () => logic.select(1), onDiscover: () => logic.select(1),
onMy: logic.openProfile, onMy: logic.openProfile,
onSearch: () => logic.select(1, search: true), onSearch: () => logic.select(1, search: true),
onMenu: logic.openMenu, onMenu: () => _openMenu(context),
onImmersiveChanged: logic.onImmersiveChanged, onImmersiveChanged: logic.onImmersiveChanged,
), ),
DiscoverPage( DiscoverPage(
@@ -243,36 +237,28 @@ class _HomeTabsState extends State<_HomeTabs> {
: AppBottomNav( : AppBottomNav(
barKey: const Key('home-tabs-bar'), barKey: const Key('home-tabs-bar'),
items: [ 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( AppBottomNavItem(
tabKey: const Key('tab-home'), tabKey: const Key('tab-home'),
icon: Icons.movie_outlined, icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.movie_rounded, selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.shortsTab, label: l10n.homeTab,
selected: logic.tab == 0, selected: logic.tab == 0,
onTap: () => _selectTab(context, 0), onTap: () => _selectTab(context, 0),
), ),
AppBottomNavItem( AppBottomNavItem(
tabKey: const Key('tab-my-list'), tabKey: const Key('tab-discover'),
icon: Icons.bookmark_border_rounded, icon: Icons.explore_outlined,
selectedIcon: Icons.bookmark_rounded, selectedIcon: Icons.explore_rounded,
label: l10n.myListTab, label: l10n.discoverTab,
selected: logic.tab == 2 && !logic.profile, selected: logic.tab == 1,
onTap: () => _selectTab(context, 2), onTap: () => _selectTab(context, 1),
), ),
AppBottomNavItem( AppBottomNavItem(
tabKey: const Key('tab-my'), tabKey: const Key('tab-my'),
icon: Icons.person_outline_rounded, icon: Icons.video_library_outlined,
selectedIcon: Icons.person_rounded, selectedIcon: Icons.video_library_rounded,
label: l10n.myTab, label: l10n.myTab,
selected: logic.tab == 2 && logic.profile, selected: logic.tab == 2,
onTap: () => _selectTab(context, 2, myProfile: true), onTap: () => _selectTab(context, 2, myProfile: true),
), ),
], ],
@@ -282,9 +268,7 @@ class _HomeTabsState extends State<_HomeTabs> {
} }
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) { void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
final same = tab == 2 final same = logic.tab == tab;
? logic.tab == 2 && logic.profile == myProfile
: logic.tab == tab;
if (!same) { if (!same) {
unawaited(context.read<Haptics>().selectionClick()); unawaited(context.read<Haptics>().selectionClick());
} }
@@ -294,4 +278,23 @@ class _HomeTabsState extends State<_HomeTabs> {
logic.select(tab); logic.select(tab);
} }
} }
Future<void> _openMenu(BuildContext context) async {
logic.setMenuOpen(true);
await showModalBottomSheet<void>(
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);
}
} }
+246
View File
@@ -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<LocaleController>().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),
),
],
),
),
),
);
}
}
@@ -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<LocaleController>().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),
),
],
),
),
),
);
}
File diff suppressed because it is too large. Load diff
+27 -52
View File
@@ -3,7 +3,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
@@ -69,12 +68,11 @@ class PlayerLogic extends ChangeNotifier {
List<Episode> sessionEpisodes = const []; List<Episode> sessionEpisodes = const [];
int pageRequest = 0; int pageRequest = 0;
bool muted = false; bool muted = false;
double playbackSpeed = 1.0;
bool stoppedAtFeedEnd = false; bool stoppedAtFeedEnd = false;
VideoPlayerPort? scrubbingPlayer; VideoPlayerPort? scrubbingPlayer;
bool fullscreen = false;
bool pip = false; bool pip = false;
bool pipPreparing = false; bool pipPreparing = false;
bool screenCleared = false;
final Map<String, int> pickedEpisode = {}; final Map<String, int> pickedEpisode = {};
bool Function() isMounted = () => false; bool Function() isMounted = () => false;
@@ -98,9 +96,9 @@ class PlayerLogic extends ChangeNotifier {
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
bool _started = false; 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) { Episode displayEpisode(Drama drama, Episode fallback) {
final number = pickedEpisode[drama.id]; final number = pickedEpisode[drama.id];
@@ -111,13 +109,6 @@ class PlayerLogic extends ChangeNotifier {
return fallback; return fallback;
} }
void setScreenCleared(bool value) {
if (screenCleared == value) return;
screenCleared = value;
notify();
notifyImmersive();
}
void notify() { void notify() {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
} }
@@ -262,13 +253,7 @@ class PlayerLogic extends ChangeNotifier {
final request = backRequest; final request = backRequest;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (isMounted() && backRequest == request) { if (isMounted() && backRequest == request) {
if (fullscreen) { onMenu?.call();
unawaited(setFullscreen(false));
} else if (screenCleared) {
setScreenCleared(false);
} else {
onMenu?.call();
}
} }
}); });
} }
@@ -295,12 +280,6 @@ class PlayerLogic extends ChangeNotifier {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
pictureInPicture.setStateListener(null); pictureInPicture.setStateListener(null);
if (fullscreen) {
unawaited(
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
);
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
}
unawaited(saveCurrentProgress()); unawaited(saveCurrentProgress());
if (_started) { if (_started) {
session.setRouteVisible(false); session.setRouteVisible(false);
@@ -313,32 +292,6 @@ class PlayerLogic extends ChangeNotifier {
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
Future<void> 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<void> enterPip(BuildContext context) async { Future<void> enterPip(BuildContext context) async {
if (pip || pipPreparing) return; if (pip || pipPreparing) return;
if (session.active is! VideoPlayerPort) { if (session.active is! VideoPlayerPort) {
@@ -347,7 +300,6 @@ class PlayerLogic extends ChangeNotifier {
); );
return; return;
} }
if (fullscreen) await setFullscreen(false);
pipPreparing = true; pipPreparing = true;
notify(); notify();
notifyImmersive(); notifyImmersive();
@@ -390,6 +342,7 @@ class PlayerLogic extends ChangeNotifier {
Future<void> preparePlayer(Episode episode, PlayerPort player) async { Future<void> preparePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return; if (player is! VideoPlayerPort) return;
try { try {
await player.controller.setPlaybackSpeed(playbackSpeed);
await pendingWrite; await pendingWrite;
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint(); if (resumePoint == null) await loadResumePoint();
@@ -409,6 +362,7 @@ class PlayerLogic extends ChangeNotifier {
final index = session.currentIndex; final index = session.currentIndex;
final drama = queue.entryAt(index).drama; final drama = queue.entryAt(index).drama;
await player.controller.setVolume(muted ? 0 : 1); await player.controller.setVolume(muted ? 0 : 1);
await player.controller.setPlaybackSpeed(playbackSpeed);
if (session.active != player) return; if (session.active != player) return;
trackedPlayer = player; trackedPlayer = player;
trackedEpisode = episode; trackedEpisode = episode;
@@ -531,6 +485,18 @@ class PlayerLogic extends ChangeNotifier {
: null; : null;
} }
bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null;
Future<void> playNextEpisode() async {
final next = nextVisibleIndex(session.currentIndex);
if (next == null || !pages.hasClients) return;
await pages.animateToPage(
visibleIndices.indexOf(next),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
void beginScrub(VideoPlayerPort player) { void beginScrub(VideoPlayerPort player) {
if (session.active != player) return; if (session.active != player) return;
scrubbingPlayer = player; scrubbingPlayer = player;
@@ -824,6 +790,15 @@ class PlayerLogic extends ChangeNotifier {
notify(); notify();
} }
Future<void> 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<void> showComments(BuildContext context, Drama drama) async { Future<void> showComments(BuildContext context, Drama drama) async {
session.setOverlayOpen(true); session.setOverlayOpen(true);
await session.playbackSettled; await session.playbackSettled;
+556 -317
View File
@@ -18,12 +18,15 @@ import '../../core/formatting/localized_content.dart';
import '../../core/platform/haptics.dart'; import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart'; import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'player_binding.dart'; import 'player_binding.dart';
import 'player_logic.dart'; import 'player_logic.dart';
import 'widgets/home_feed_overlay.dart';
import 'widgets/exit_clear_screen_button.dart';
import 'widgets/player_center_toggle.dart';
import 'widgets/player_featured_comment.dart';
import 'widgets/seek_progress_bar.dart'; import 'widgets/seek_progress_bar.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/playback_repository.dart';
@@ -68,6 +71,8 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver { class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic; late final PlayerLogic logic;
bool _chromeVisible = true;
double _playbackSpeed = 1.0;
@override @override
void initState() { void initState() {
@@ -161,7 +166,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
logic.session.preparedPlayerAt(index); logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null; final videoPort = port is VideoPlayerPort ? port : null;
final ready = videoPort?.controller.value.isInitialized ?? false; final ready = videoPort?.controller.value.isInitialized ?? false;
final theater = logic.fullscreen || logic.pip || logic.pipPreparing; final theater = logic.pip || logic.pipPreparing;
Widget? video; Widget? video;
if (ready) { if (ready) {
video = GestureDetector( video = GestureDetector(
@@ -186,9 +191,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
children: [ children: [
const ColoredBox(color: AppColors.ink), const ColoredBox(color: AppColors.ink),
?video, ?video,
if (!logic.chromeHidden) if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible))
const IgnorePointer( IgnorePointer(
child: DecoratedBox( key: widget.homeMode ? const Key('home-feed-scrim') : null,
child: const DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
@@ -204,6 +210,15 @@ class _PlayerPageState extends State<PlayerPage> 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) if (selected && logic.ticketLoading)
const Center( const Center(
@@ -300,41 +315,60 @@ class _PlayerPageState extends State<PlayerPage> 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) if (logic.pip || logic.pipPreparing)
const SizedBox.expand() const SizedBox.expand()
else if (logic.fullscreen) else if (widget.homeMode)
_FullscreenControls( _chromeVisible
l10n: l10n, ? HomeFeedOverlay(
controller: ready ? videoPort!.controller : null, l10n: l10n,
onClose: () => unawaited(logic.setFullscreen(false)), drama: drama,
onPip: logic.pictureInPicture.isPlatformSupported episode: episode,
? () => unawaited(logic.enterPip(context)) liked: interactions.isLiked(drama.id),
: null, saved: context.watch<FavoriteController>().isSaved(drama.id),
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onLike: () async {
onSeek: ready if (await ensureSignedIn(context)) {
? (position) => logic.seekTo(context, videoPort!, position) if (!context.mounted) return;
: null, unawaited(context.read<Haptics>().lightImpact());
) final result = await interactions.toggleDramaLike(
else if (!logic.screenCleared) 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<FavoriteController>();
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().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( _PlaybackOverlay(
l10n: l10n, l10n: l10n,
drama: drama, drama: drama,
@@ -346,9 +380,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onBack: widget.embedded onBack: widget.embedded
? null ? null
: () => unawaited(logic.leave(context)), : () => unawaited(logic.leave(context)),
onDiscover: () => logic.showDiscover(context),
onEpisodes: () => logic.showEpisodes(context), onEpisodes: () => logic.showEpisodes(context),
onMy: () => logic.showMy(context), onNext: logic.hasNextEpisode
? () => unawaited(logic.playNextEpisode())
: null,
playing: logic.session.userWantsPlay,
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 { onLike: () async {
if (await ensureSignedIn(context)) { if (await ensureSignedIn(context)) {
@@ -382,7 +425,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onDownload: () => requestEpisodeDownload(context, episode.id), onDownload: () => requestEpisodeDownload(context, episode.id),
onComments: () => logic.showComments(context, drama), onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama), onShare: () => logic.shareDrama(context, drama),
onClear: () => logic.setScreenCleared(true),
onPip: logic.pictureInPicture.isPlatformSupported onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context)) ? () => unawaited(logic.enterPip(context))
: null, : null,
@@ -390,81 +432,58 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onSeek: ready onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position) ? (position) => logic.seekTo(context, videoPort!, position)
: null, : null,
showBottomNav: false,
), ),
if (logic.screenCleared) if (widget.homeMode && !_chromeVisible)
SafeArea( Positioned.fill(
child: Align( child: GestureDetector(
alignment: Alignment.topRight, key: const Key('home-feed-video-tap'),
child: Padding( behavior: HitTestBehavior.opaque,
padding: const EdgeInsets.only(top: 4, right: 8), onTap: () =>
child: IconButton( logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
tooltip: l10n.exitClearScreen, ),
onPressed: () => logic.setScreenCleared(false), ),
style: IconButton.styleFrom( if (widget.homeMode && !_chromeVisible)
foregroundColor: Colors.white, Positioned(
backgroundColor: const Color(0x59000000), top: 0,
), right: 0,
icon: const Icon(Icons.close_fullscreen_rounded), 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 { Future<void> _openSpeedSheet(BuildContext context) async {
const _FullscreenControls({ final selected = await showModalBottomSheet<double>(
required this.l10n, context: context,
required this.controller, backgroundColor: AppColors.card,
required this.onClose, showDragHandle: true,
required this.onPip, builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed),
required this.onScrubStart, );
required this.onSeek, if (selected == null || !mounted) return;
}); setState(() => _playbackSpeed = selected);
await logic.setPlaybackSpeed(selected);
final AppLocalizations l10n; }
final VideoPlayerController? controller;
final VoidCallback onClose;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> 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,
),
),
],
),
);
} }
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 /// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
@@ -589,7 +608,7 @@ class _UnlockCard extends StatelessWidget {
} }
} }
class _PlaybackOverlay extends StatelessWidget { class _PlaybackOverlay extends StatefulWidget {
const _PlaybackOverlay({ const _PlaybackOverlay({
required this.l10n, required this.l10n,
required this.drama, required this.drama,
@@ -597,20 +616,24 @@ class _PlaybackOverlay extends StatelessWidget {
required this.controller, required this.controller,
required this.liked, required this.liked,
required this.saved, required this.saved,
required this.playing,
required this.playbackSpeed,
required this.onSpeedTap,
required this.controlsVisible,
required this.onTogglePlay,
required this.onClear,
required this.onExitClear,
required this.onBack, required this.onBack,
required this.onDiscover,
required this.onEpisodes, required this.onEpisodes,
required this.onMy, required this.onComments,
required this.onLike, required this.onLike,
required this.onSave, required this.onSave,
required this.onDownload, required this.onDownload,
required this.onComments,
required this.onShare, required this.onShare,
required this.onClear,
required this.onPip, required this.onPip,
required this.onScrubStart, required this.onScrubStart,
required this.onSeek, required this.onSeek,
required this.showBottomNav, this.onNext,
}); });
final AppLocalizations l10n; final AppLocalizations l10n;
@@ -619,230 +642,446 @@ class _PlaybackOverlay extends StatelessWidget {
final VideoPlayerController? controller; final VideoPlayerController? controller;
final bool liked; final bool liked;
final bool saved; final bool saved;
final bool playing;
final double playbackSpeed;
final VoidCallback onSpeedTap;
final bool controlsVisible;
final VoidCallback onTogglePlay;
final VoidCallback onClear;
final VoidCallback onExitClear;
final VoidCallback? onBack; final VoidCallback? onBack;
final VoidCallback onDiscover;
final VoidCallback onEpisodes; final VoidCallback onEpisodes;
final VoidCallback onMy; final VoidCallback onComments;
final VoidCallback? onNext;
final VoidCallback onLike; final VoidCallback onLike;
final VoidCallback onSave; final VoidCallback onSave;
final VoidCallback onDownload; final VoidCallback onDownload;
final VoidCallback onComments;
final VoidCallback onShare; final VoidCallback onShare;
final VoidCallback onClear;
final VoidCallback? onPip; final VoidCallback? onPip;
final VoidCallback? onScrubStart; final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek; final Future<void> Function(Duration)? onSeek;
final bool showBottomNav;
@override @override
Widget build(BuildContext context) => SafeArea( State<_PlaybackOverlay> createState() => _PlaybackOverlayState();
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,
),
],
);
} }
const double _railIconSize = 28; class _PlaybackOverlayState extends State<_PlaybackOverlay> {
const double _railIconWeight = 300; @override
const double _railTapSize = 44; 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 { Widget _topBar(BuildContext context) {
const _RailAction({ 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<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('player-more-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<double> _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, super.key,
required this.icon, required this.icon,
required this.label, required this.label,
required this.onTap, required this.onTap,
this.selected = false,
}); });
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback? onTap; final VoidCallback onTap;
final bool selected;
@override @override
Widget build(BuildContext context) => IconButton( Widget build(BuildContext context) {
tooltip: label, return Material(
onPressed: onTap, color: const Color(0xFF2A2A30),
padding: EdgeInsets.zero, borderRadius: BorderRadius.circular(9),
constraints: const BoxConstraints.tightFor( clipBehavior: Clip.antiAlias,
width: _railTapSize, child: InkWell(
height: _railTapSize, onTap: onTap,
), child: SizedBox(
color: selected ? AppColors.blueSoft : AppColors.cream, height: 32,
icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0), child: Row(
); mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 14, color: AppColors.secondary),
const SizedBox(width: 3),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}
} }
@@ -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),
),
);
}
}
@@ -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<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
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)],
),
),
],
),
),
),
),
);
}
}
@@ -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,
),
),
),
);
}
}
@@ -0,0 +1,158 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/repositories/comment_repository.dart';
import '../../../core/data/repositories/interaction_repository.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
class PlayerFeaturedComment extends StatefulWidget {
const PlayerFeaturedComment({
super.key,
required this.drama,
required this.onViewAll,
});
final Drama drama;
final VoidCallback onViewAll;
@override
State<PlayerFeaturedComment> createState() => _PlayerFeaturedCommentState();
}
class _PlayerFeaturedCommentState extends State<PlayerFeaturedComment> {
late final CommentController _controller;
@override
void initState() {
super.initState();
_controller = CommentController(
comments: context.read<CommentRepository>(),
interactions: context.read<InteractionRepository>(),
dramaId: widget.drama.id,
);
unawaited(_controller.load());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
if (_controller.comments.isEmpty) return const SizedBox.shrink();
return _card(context, _controller.comments.first);
},
);
}
Widget _card(BuildContext context, DramaComment comment) {
final l10n = AppLocalizations.of(context)!;
final time = _time(l10n, comment.createdAt);
return InkWell(
key: const Key('player-featured-comment'),
onTap: widget.onViewAll,
borderRadius: BorderRadius.circular(10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 18,
height: 18,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.secondary.withValues(alpha: 0.2),
),
child: Text(
_initial(comment.nickname),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 8,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 6),
Expanded(
child: Row(
children: [
Flexible(
child: Text(
comment.nickname.isEmpty
? l10n.localViewer
: comment.nickname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
if (time != null) ...[
const SizedBox(width: 4),
Text(
'· $time',
style: const TextStyle(
color: Color(0xFF777777),
fontSize: 9,
),
),
],
],
),
),
],
),
const SizedBox(height: 5),
Text(
comment.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFDDDDDD),
fontSize: 10,
height: 1.5,
),
),
],
),
),
);
}
String? _time(AppLocalizations l10n, DateTime? createdAt) {
if (createdAt == null) return null;
final diff = DateTime.now().difference(createdAt);
if (diff.inMinutes < 1) return null;
return l10n.minutesAgo(diff.inMinutes);
}
String _initial(String nickname) {
final trimmed = nickname.trim();
return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
}
}
@@ -26,6 +26,7 @@ class SeekProgressBar extends StatefulWidget {
this.onScrubStart, this.onScrubStart,
this.onSeek, this.onSeek,
this.showTimes = true, this.showTimes = true,
this.compact = false,
}); });
final ValueListenable<VideoPlayerValue>? valueListenable; final ValueListenable<VideoPlayerValue>? valueListenable;
@@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget {
final VoidCallback? onScrubStart; final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek; final Future<void> Function(Duration)? onSeek;
final bool showTimes; final bool showTimes;
final bool compact;
@override @override
State<SeekProgressBar> createState() => _SeekProgressBarState(); State<SeekProgressBar> createState() => _SeekProgressBarState();
@@ -116,17 +118,27 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
), ),
), ),
SizedBox( SizedBox(
height: scrubbing ? 48 : 44, height: widget.compact
? (scrubbing ? 32 : 20)
: (scrubbing ? 48 : 44),
child: SliderTheme( child: SliderTheme(
data: SliderTheme.of(context).copyWith( data: SliderTheme.of(context).copyWith(
trackHeight: scrubbing ? 4 : 3, trackHeight: widget.compact
activeTrackColor: AppColors.gold, ? (scrubbing ? 3 : 1.5)
inactiveTrackColor: Colors.white30, : (scrubbing ? 4 : 3),
thumbColor: Colors.white, activeTrackColor: AppColors.blue,
inactiveTrackColor: widget.compact
? Colors.white.withValues(alpha: 0.18)
: const Color(0xFF474248),
thumbColor: widget.compact ? AppColors.blue : Colors.white,
thumbShape: RoundSliderThumbShape( 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( child: Semantics(
label: widget.semanticsLabel, label: widget.semanticsLabel,
+52 -28
View File
@@ -61,10 +61,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.2" version: "1.1.3"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -208,10 +208,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: intl name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted 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: json_annotation:
dependency: transitive dependency: transitive
description: description:
@@ -256,10 +272,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.12.19" version: "0.12.20"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -272,10 +288,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.18.0" version: "1.19.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -292,6 +308,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.0.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@@ -368,26 +392,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.3.3" version: "2.5.6"
shared_preferences_android: 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 dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_android
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" sha256: b90caa27669d5f3ef9918d55343b44000f088ee9d4685ed822310848004c793e
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted 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: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@@ -397,7 +421,7 @@ packages:
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: "direct dev"
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
@@ -437,10 +461,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.12.1" version: "1.12.2"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -477,10 +501,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.7.11" version: "0.7.12"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -493,10 +517,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.2.0" version: "2.4.3"
video_player: video_player:
dependency: "direct main" dependency: "direct main"
description: description:
+26 -2
View File
@@ -40,8 +40,7 @@ dependencies:
dio: ^5.7.0 dio: ^5.7.0
provider: ^6.1.5+1 provider: ^6.1.5+1
video_player: ^2.14.0 video_player: ^2.14.0
shared_preferences: 2.3.3 shared_preferences: ^2.5.5
shared_preferences_android: 2.4.28
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
@@ -60,8 +59,13 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
shared_preferences_platform_interface: ^2.4.2
flutter_launcher_icons: ^0.14.4 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 # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@@ -78,6 +82,26 @@ flutter:
assets: assets:
- assets/icon/splash_mark.png - 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: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
@@ -46,6 +46,8 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-account-card')), findsOneWidget); expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
expect(find.byKey(const Key('mine-account-profile')), findsOneWidget); expect(find.byKey(const Key('mine-account-profile')), findsOneWidget);
+3
View File
@@ -82,4 +82,7 @@ Future<void> _pumpAuth(WidgetTester tester) async {
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
} }
+65
View File
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import '../support/fake_haptics.dart';
import '../support/fake_http_adapter.dart';
void main() {
testWidgets('should_show_three_login_entries_and_open_email_form', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final auth = AuthController(
AuthApi(
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
),
),
AuthSessionStore(),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
Provider<Haptics>.value(value: FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AuthPage(),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
await tester.tap(find.byKey(const Key('auth-entry-google')));
await tester.pumpAndSettle();
expect(find.text('Coming soon'), findsOneWidget);
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('auth-email')), findsOneWidget);
expect(find.byKey(const Key('auth-password')), findsOneWidget);
});
}
+3
View File
@@ -59,6 +59,9 @@ void main() {
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
} }
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', ( testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (
+21 -5
View File
@@ -29,6 +29,7 @@ void main() {
WidgetTester tester, { WidgetTester tester, {
AuthApi? api, AuthApi? api,
FakeHaptics? haptics, FakeHaptics? haptics,
bool openEmail = true,
}) async { }) async {
final auth = AuthController( final auth = AuthController(
api ?? api ??
@@ -57,20 +58,32 @@ void main() {
), ),
); );
await tester.pump(const Duration(milliseconds: 220)); await tester.pump(const Duration(milliseconds: 220));
if (openEmail) {
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
}
} }
testWidgets( testWidgets(
'should_render_branded_dark_layout_with_approved_tokens_when_page_opens', 'should_render_branded_dark_layout_with_approved_tokens_when_page_opens',
(tester) async { (tester) async {
await pumpAuthPage(tester); await pumpAuthPage(tester, openEmail: false);
expect(find.byKey(const Key('auth-brand')), findsOneWidget); expect(find.byKey(const Key('auth-landing')), findsOneWidget);
expect(find.text('FruitShorts'), findsOneWidget); expect(find.text('FruitShorts'), findsOneWidget);
expect(find.text('Short dramas made for Indonesia'), findsOneWidget);
expect( expect(
tester.getSize(find.byKey(const Key('auth-brand-mark'))), find.text('Sign in to sync progress, favorites and coins'),
const Size(48, 48), findsOneWidget,
); );
expect(
tester.getSize(find.byKey(const Key('auth-brand-logo'))),
const Size(64, 64),
);
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
final glow = tester.widget<Container>( final glow = tester.widget<Container>(
find.byKey(const Key('auth-background-glow')), find.byKey(const Key('auth-background-glow')),
@@ -81,6 +94,9 @@ void main() {
expect(color.a, lessThanOrEqualTo(0.18)); expect(color.a, lessThanOrEqualTo(0.18));
} }
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
final card = tester.widget<Container>(find.byKey(const Key('auth-card'))); final card = tester.widget<Container>(find.byKey(const Key('auth-card')));
final cardDecoration = card.decoration! as BoxDecoration; final cardDecoration = card.decoration! as BoxDecoration;
expect(cardDecoration.color, AppColors.card); expect(cardDecoration.color, AppColors.card);
+4 -1
View File
@@ -81,7 +81,10 @@ void main() {
isNull, isNull,
); );
expect(await ServerEndpoint.save(' http://39.162.50.99:8081/ '), isTrue); expect(
await ServerEndpoint.save(' http://39.162.50.99:8081/ '),
isTrue,
);
expect( expect(
await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey),
'http://39.162.50.99:8081', 'http://39.162.50.99:8081',
-193
View File
@@ -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<CoverItem> 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<Transform>(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<bool>(
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),
);
+307 -54
View File
@@ -7,11 +7,11 @@ import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/content_category.dart'; import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.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/category_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_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/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_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_catalog.dart';
import '../support/fake_interaction_repositories.dart'; import '../support/fake_interaction_repositories.dart';
@@ -25,7 +25,7 @@ void main() {
}); });
testWidgets( 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) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@@ -37,57 +37,94 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Continue Watching'), findsNothing); expect(find.text('Continue Watching'), findsNothing);
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('Recommended'), findsOneWidget);
expect(find.text('All'), 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<Icon>(
find.descendant(
of: find.byKey(key),
matching: find.byType(Icon),
),
)
.size,
18,
);
}
expect(find.text('For You'), findsNothing); expect(find.text('For You'), findsNothing);
expect( final masonry = tester.widget<Row>(
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<SliverGrid>(
find.byKey(const Key('home-all-dramas-grid')), find.byKey(const Key('home-all-dramas-grid')),
); );
final delegate = expect(masonry.children, hasLength(3));
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect((masonry.children[1] as SizedBox).width, 10);
expect(delegate.crossAxisCount, 3);
expect(delegate.crossAxisSpacing, 10);
expect(delegate.mainAxisSpacing, 14);
expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001));
expect( expect(
tester tester
.widget<SliverPadding>( .widget<Padding>(find.byKey(const Key('home-all-dramas-section')))
find.byKey(const Key('home-all-dramas-section')),
)
.padding, .padding,
const EdgeInsets.symmetric(horizontal: 16), const EdgeInsets.symmetric(horizontal: 14),
); );
expect(find.byType(GridView), findsNothing); expect(find.byType(GridView), findsNothing);
expect(find.byType(CustomScrollView), findsOneWidget); expect(find.byType(CustomScrollView), findsOneWidget);
expect(find.byType(RemoteImage), findsWidgets); 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<AspectRatio>( final cover = find.byKey(const Key('home-drama-cover-city-lights-demo'));
find.byKey(const Key('home-drama-cover-city-lights-demo')), expect(tester.getSize(cover).height, greaterThan(120));
expect(
tester
.widget<Image>(
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( expect(
tester tester
.widget<Text>( .widget<Text>(
find.byKey(const Key('home-drama-title-city-lights-demo')), find.byKey(const Key('home-drama-title-city-lights-demo')),
) )
.maxLines, .maxLines,
2, 1,
); );
expect( expect(
tester tester
@@ -111,14 +148,14 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsWidgets);
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets);
await tester.tap(find.text('Romance')); await tester.tap(find.text('Romance'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsNothing); expect(find.text('The Signal'), findsNothing);
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect( expect(
find.byKey(const Key('home-drama-card-the-signal-demo')), find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing, findsNothing,
@@ -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', ( testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', (
tester, tester,
) async { ) async {
@@ -146,17 +220,8 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
expect(find.text('No matching dramas.'), findsOneWidget); expect(find.text('No matching dramas.'), findsOneWidget);
final content = tester.getCenter( expect(find.byKey(const Key('empty-state-content')), findsOneWidget);
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));
await tester.tap(find.text('Romance')); await tester.tap(find.text('Romance'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -174,7 +239,6 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
expect(find.text('Categories could not be loaded.'), findsOneWidget); expect(find.text('Categories could not be loaded.'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget); expect(find.text('Retry'), findsOneWidget);
expect(find.byKey(const Key('empty-state-icon')), 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); 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.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
@@ -200,15 +264,164 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
expect(tester.takeException(), isNull); 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<TextField>(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<TextField>(
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<void> _pumpApp( Future<void> _pumpApp(
WidgetTester tester, { WidgetTester tester, {
required CategoryRepository categoryRepository, required CategoryRepository categoryRepository,
WatchHistoryRepository? history,
}) async { }) async {
final catalog = FakeCatalogRepository(); final catalog = FakeCatalogRepository();
await tester.pumpWidget( await tester.pumpWidget(
@@ -216,7 +429,7 @@ Future<void> _pumpApp(
playerFactory: createWidgetPlayer, playerFactory: createWidgetPlayer,
catalogRepository: catalog, catalogRepository: catalog,
categoryRepository: categoryRepository, categoryRepository: categoryRepository,
watchHistoryRepository: InMemoryWatchHistoryRepository(), watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(), favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(), playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(), 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<List<ContentCategory>> fetchCategories() {
categoryLoads++;
return delegate.fetchCategories();
}
@override
Future<HomeFeed> fetchDiscoverFeed() {
feedLoads++;
return delegate.fetchDiscoverFeed();
}
}
-100
View File
@@ -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<HomeFeed> 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<HomeFeed> fetchDiscoverFeed() async => HomeFeed(
sections: [FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas)],
);
@override
Future<Drama> fetchDrama(String dramaId) => _delegate.fetchDrama(dramaId);
}
@@ -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<Icon>(
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<Text>(
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<GridView>(
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<GridView>(
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<PlayerPage>(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<WatchHistoryRepository>.value(
value: InMemoryWatchHistoryRepository(),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<PlayerFactory>.value(value: createWidgetPlayer),
Provider(create: (_) => PlaybackCoordinator()),
Provider<InteractionRepository>.value(value: interactions),
Provider<CommentRepository>.value(value: FakeCommentRepository()),
Provider<Haptics>.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<int>.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' : '',
),
],
);
}
+44 -40
View File
@@ -18,17 +18,20 @@ void main() {
final dramas = [_drama('signal'), _drama('city')]; final dramas = [_drama('signal'), _drama('city')];
test('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () { test(
final logic = HomeLogic(dramas); 'should_make_first_catalog_entry_the_current_drama_when_catalog_loads',
() {
final logic = HomeLogic(dramas);
expect(logic.currentDrama.id, 'signal'); expect(logic.currentDrama.id, 'signal');
expect(logic.tab, 1); expect(logic.tab, 0);
expect(logic.mySection, 0); expect(logic.mySection, 0);
expect(logic.drawerOpen, isFalse); expect(logic.drawerOpen, isFalse);
expect(logic.profile, isFalse); expect(logic.profile, isFalse);
expect(logic.videoImmersive, isFalse); expect(logic.videoImmersive, isFalse);
logic.dispose(); logic.dispose();
}); },
);
test('should_not_build_shell_when_catalog_is_empty', () { test('should_not_build_shell_when_catalog_is_empty', () {
expect(() => HomeLogic(const []), throwsStateError); expect(() => HomeLogic(const []), throwsStateError);
@@ -82,35 +85,39 @@ void main() {
logic.dispose(); logic.dispose();
}); });
test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () { test(
final logic = HomeLogic(dramas); 'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used',
() {
final logic = HomeLogic(dramas);
logic.openProfile(); logic.openProfile();
expect(logic.profile, isTrue); expect(logic.profile, isTrue);
expect(logic.tab, 2); expect(logic.tab, 2);
expect(logic.mySection, 0); expect(logic.mySection, 0);
expect(logic.sectionRequest, 1); expect(logic.sectionRequest, 1);
logic.openLibrary(section: 1); logic.openLibrary(section: 1);
expect(logic.profile, isFalse); expect(logic.profile, isFalse);
expect(logic.mySection, 1); expect(logic.mySection, 1);
expect(logic.sectionRequest, 2); expect(logic.sectionRequest, 2);
logic.openLibrary(); logic.openLibrary();
expect(logic.profile, isFalse); expect(logic.profile, isFalse);
expect(logic.mySection, 1); expect(logic.mySection, 1);
expect(logic.sectionRequest, 3); expect(logic.sectionRequest, 3);
logic.dispose(); 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); final logic = HomeLogic(dramas);
// Home is the launch tab; the first back press lands on Discover.
logic.onSystemBack();
expect(logic.tab, 1);
logic.onSystemBack(); logic.onSystemBack();
expect(logic.drawerOpen, isTrue);
expect(logic.tab, 1); expect(logic.tab, 1);
expect(logic.backRequest, 0); expect(logic.backRequest, 0);
logic.onDrawerChanged(false);
logic.select(0); logic.select(0);
logic.onSystemBack(); logic.onSystemBack();
@@ -123,30 +130,25 @@ void main() {
expect(logic.backRequest, 1); expect(logic.backRequest, 1);
expect(logic.tab, 0); expect(logic.tab, 0);
logic.onDrawerChanged(true);
logic.onSystemBack();
expect(logic.backRequest, 1);
logic.onDrawerChanged(false);
logic.onImmersiveChanged(false); logic.onImmersiveChanged(false);
logic.onSystemBack(); logic.onSystemBack();
expect(logic.tab, 1); expect(logic.tab, 1);
logic.dispose(); 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); final logic = HomeLogic(dramas);
var notifications = 0; var notifications = 0;
logic.addListener(() => notifications++); logic.addListener(() => notifications++);
logic.onDrawerChanged(false); logic.setMenuOpen(false);
expect(notifications, 0); expect(notifications, 0);
logic.onDrawerChanged(true); logic.setMenuOpen(true);
expect(logic.drawerOpen, isTrue); expect(logic.drawerOpen, isTrue);
expect(notifications, 1); expect(notifications, 1);
logic.onDrawerChanged(true); logic.setMenuOpen(true);
expect(notifications, 1); expect(notifications, 1);
logic.dispose(); logic.dispose();
}); });
@@ -190,7 +192,9 @@ void main() {
expect(notifications, 0); 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); final logic = HomeLogic(dramas);
late BuildContext context; late BuildContext context;
await tester.pumpWidget( await tester.pumpWidget(
+32 -20
View File
@@ -18,9 +18,8 @@ import '../support/widget_player.dart';
const _keys = [ const _keys = [
Key('home-tabs-bar'), Key('home-tabs-bar'),
Key('tab-discover'),
Key('tab-home'), Key('tab-home'),
Key('tab-my-list'), Key('tab-discover'),
Key('tab-my'), 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, tester,
) async { ) async {
final haptics = FakeHaptics(); final haptics = FakeHaptics();
@@ -52,17 +51,15 @@ void main() {
final bar = tester.widget<Container>(barFinder); final bar = tester.widget<Container>(barFinder);
final decoration = bar.decoration as BoxDecoration; final decoration = bar.decoration as BoxDecoration;
final border = decoration.border as Border; final border = decoration.border as Border;
final context = tester.element(barFinder); expect(decoration.color, AppColors.bar.withValues(alpha: 0.95));
expect(decoration.color, AppColors.bar); expect(border.top.color, AppColors.outlineVariantDark);
expect(border.top.color, Theme.of(context).colorScheme.outlineVariant);
expect(border.top.color.computeLuminance(), lessThan(0.2));
expect(border.top.color, isNot(Colors.white)); expect(border.top.color, isNot(Colors.white));
expect( expect(
tester tester
.widget<AnimatedContainer>( .widget<AnimatedScale>(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-home')), of: find.byKey(const Key('tab-home')),
matching: find.byType(AnimatedContainer), matching: find.byType(AnimatedScale),
), ),
) )
.duration, .duration,
@@ -71,7 +68,7 @@ void main() {
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-home')), of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.movie_rounded), matching: find.byIcon(Icons.play_circle_fill_rounded),
), ),
findsOneWidget, findsOneWidget,
); );
@@ -85,29 +82,42 @@ void main() {
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-discover')), of: find.byKey(const Key('tab-discover')),
matching: find.byIcon(Icons.home_rounded), matching: find.byIcon(Icons.explore_rounded),
), ),
findsOneWidget, findsOneWidget,
); );
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('tab-home')), of: find.byKey(const Key('tab-home')),
matching: find.byIcon(Icons.movie_outlined), matching: find.byIcon(Icons.play_circle_outline_rounded),
), ),
findsOneWidget, findsOneWidget,
); );
}); });
testWidgets('should_hide_themed_navigation_when_immersive_mode_is_enabled', ( testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async {
tester,
) async {
await _pumpApp(tester); await _pumpApp(tester);
await tester.tap(find.byTooltip('Clear screen'));
await tester.pumpAndSettle();
for (final key in _keys) { expect(find.byKey(const Key('tab-home')), findsOneWidget);
expect(find.byKey(key), findsNothing); 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', ( testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
@@ -119,6 +129,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
haptics.reset(); haptics.reset();
await tester.drag(find.byType(ListView), const Offset(0, -700));
await tester.pumpAndSettle();
await tester.tap(find.text('App Language')); await tester.tap(find.text('App Language'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+158
View File
@@ -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<Icon>(
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<void> 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<Text>(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<Text>(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,
);
+116
View File
@@ -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<Icon>(
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<AnimatedContainer>(
find.descendant(
of: tab0,
matching: find.byType(AnimatedContainer),
),
)
.decoration
as BoxDecoration)
.color,
AppColors.blue,
);
expect(
(tester
.widget<AnimatedContainer>(
find.descendant(
of: tab1,
matching: find.byType(AnimatedContainer),
),
)
.decoration
as BoxDecoration)
.color,
Colors.transparent,
);
expect(tester.takeException(), isNull);
});
}
+2
View File
@@ -46,6 +46,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-notices')), findsOneWidget); expect(find.byKey(const Key('mine-notices')), findsOneWidget);
await tester.tap(find.byKey(const Key('mine-notices'))); await tester.tap(find.byKey(const Key('mine-notices')));
@@ -56,11 +56,13 @@ void main() {
await tester.pump(); await tester.pump();
expect(started, 1); expect(started, 1);
expect(target, isNull); expect(target, isNull);
expect(find.textContaining('/ 00:12'), findsOneWidget);
await gesture.up(); await gesture.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(target, isNotNull); expect(target, isNotNull);
expect(target!, greaterThan(const Duration(seconds: 3))); expect(target!, greaterThan(const Duration(seconds: 3)));
expect(target!, lessThan(const Duration(seconds: 12))); 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', ( testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
@@ -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<Container>(
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<Icon>(
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
);
expect(icon.size, 26);
}
});
}
+525
View File
@@ -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<Text>(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<PlayerCenterToggle>(
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<PlayerCenterToggle>(
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<PlayerCenterToggle>(
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<PlayerCenterToggle>(
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<PlayerCenterToggle>(
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<SeekProgressBar>(
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<PlayerCenterToggle>(
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<PlayerCenterToggle>(
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<void> _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();
}
+1 -27
View File
@@ -117,33 +117,7 @@ void main() {
}); });
group('surface flags', () { group('surface flags', () {
test('should_hide_chrome_until_restored_when_screen_is_cleared', () { test('should_set_immersive_when_picture_in_picture_callback_fires', () async {
final logic = _logic(FakeWatchHistory());
final immersive = <bool>[];
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 {
final factory = _RecordingFactory(); final factory = _RecordingFactory();
final drama = _drama('signal'); final drama = _drama('signal');
final logic = _logic(FakeWatchHistory()); final logic = _logic(FakeWatchHistory());
+5 -5
View File
@@ -6,18 +6,18 @@ import 'package:fruitshorts/core/theme/app_dimens.dart';
void main() { void main() {
test('should_expose_the_k20_spacing_and_type_scale', () { test('should_expose_the_k20_spacing_and_type_scale', () {
expect(AppDimens.pageHorizontal, 16); expect(AppDimens.pageHorizontal, 16);
expect(AppDimens.cardRadius, 16); expect(AppDimens.cardRadius, 12);
expect(AppDimens.innerRadius, 12); expect(AppDimens.innerRadius, 10);
expect(AppDimens.caption, 12); expect(AppDimens.caption, 12);
expect(AppDimens.body, 14); expect(AppDimens.body, 14);
expect(AppDimens.listTitle, 15); expect(AppDimens.listTitle, 15);
expect(AppDimens.sectionTitle, 16); expect(AppDimens.sectionTitle, 16);
expect(AppDimens.pageTitle, 20); expect(AppDimens.pageTitle, 20);
expect(AppDimens.navLabel, 11); expect(AppDimens.navLabel, 9);
expect(AppDimens.buttonHeight, 48); expect(AppDimens.buttonHeight, 48);
expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.primaryButtonHeight, 52);
expect(AppDimens.tapTarget, 44); expect(AppDimens.tapTarget, 44);
expect(AppDimens.navHeight, 64); expect(AppDimens.navHeight, 54);
expect(AppDimens.progressHeight, 4); expect(AppDimens.progressHeight, 3);
}); });
} }
+15 -8
View File
@@ -14,11 +14,18 @@ double _contrast(Color foreground, Color background) {
} }
void main() { void main() {
test('should_expose_indonesia_evening_brand_tokens', () { test('should_expose_obsidian_flare_brand_tokens', () {
expect(AppColors.blue, const Color(0xFFC4322A)); expect(AppColors.blue, const Color(0xFFFF5B3D));
expect(AppColors.bluePressed, const Color(0xFF8E221C)); expect(AppColors.bluePressed, const Color(0xFFFF4321));
expect(AppColors.blueSoft, const Color(0xFFFF8A72)); expect(AppColors.blueSoft, const Color(0xFF9278EA));
expect(AppColors.gold, const Color(0xFFF6C453)); 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(AppColors.onPrimary, const Color(0xFFFFFFFF));
expect( expect(
_contrast(AppColors.onGold, AppColors.gold), _contrast(AppColors.onGold, AppColors.gold),
@@ -29,16 +36,16 @@ void main() {
test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () { test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () {
expect( expect(
_contrast(AppColors.onPrimary, AppColors.blue), _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( expect(
_contrast(AppColors.blueSoft, AppColors.ink), _contrast(AppColors.blueSoft, AppColors.ink),
greaterThanOrEqualTo(3), 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()]) { for (final theme in [AppTheme.light(), AppTheme.dark()]) {
expect(theme.colorScheme.primary, AppColors.blue); expect(theme.colorScheme.primary, AppColors.blue);
expect(theme.colorScheme.onPrimary, AppColors.onPrimary); expect(theme.colorScheme.onPrimary, AppColors.onPrimary);
+43
View File
@@ -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<BackdropFilter>(find.byType(BackdropFilter));
expect(filter.filter, isNotNull);
expect(find.text('glass'), findsOneWidget);
final clip = tester.widget<ClipRRect>(find.byType(ClipRRect).first);
expect(clip.borderRadius, BorderRadius.circular(26));
final decorated = tester.widget<DecoratedBox>(
find
.descendant(
of: find.byType(GlassPanel),
matching: find.byType(DecoratedBox),
)
.first,
);
final decoration = decorated.decoration as BoxDecoration;
expect(decoration.gradient, isA<LinearGradient>());
expect(decoration.border!.top.color, AppColors.glassBorder);
});
}
@@ -42,6 +42,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520));
await tester.pumpAndSettle();
await tester.tap(find.text('Watch History')); await tester.tap(find.text('Watch History'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+88 -71
View File
@@ -110,41 +110,24 @@ void main() {
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
expect(find.byType(Slider), findsOneWidget); expect(find.byType(Slider), findsNothing);
expect(find.text('Home'), findsWidgets); expect(find.text('Home'), findsWidgets);
expect(find.textContaining('Playing now'), findsOneWidget); expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.text('Recommended'), findsNothing); expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
expect(find.text('Following'), findsNothing); expect(find.byKey(const Key('rail-like')), findsOneWidget);
expect(find.byTooltip('Episodes'), findsWidgets); expect(find.byKey(const Key('rail-comments')), findsOneWidget);
expect(find.byTooltip('Clear screen'), findsOneWidget); expect(find.byKey(const Key('rail-share')), findsOneWidget);
expect(find.byTooltip('Picture in picture'), 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.text('Watch Full'), findsNothing);
expect(find.byTooltip('Explore series'), 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( testWidgets(
'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used', 'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used',
(tester) async { (tester) async {
@@ -155,6 +138,8 @@ void main() {
await tester.pumpWidget(appUnderTest()); await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle(); 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); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -166,18 +151,12 @@ void main() {
); );
expect(find.text('Quick access'), findsOneWidget); expect(find.text('Quick access'), findsOneWidget);
expect(find.text('Bahasa Indonesia'), findsOneWidget); expect(find.text('Bahasa Indonesia'), findsOneWidget);
expect( expect(find.text('7 EPISODES'), findsOneWidget);
find.descendant(
of: find.byType(Drawer),
matching: find.text('7 EPISODES'),
),
findsOneWidget,
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const Key('drawer-open-series'))); await tester.tap(find.byKey(const Key('drawer-open-series')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Watch episode 1'), findsOneWidget); expect(find.byKey(const Key('detail-cta')), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
@@ -217,18 +196,20 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), 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.tap(find.text('App Language'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('简体中文').last); await tester.tap(find.text('简体中文').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('我的'), findsWidgets); expect(find.text('我的'), findsWidgets);
expect(find.text('我的账户'), findsOneWidget); await tester.drag(find.byType(ListView), const Offset(0, 700));
expect(find.text('首页'), findsOneWidget);
await tester.tap(find.text('首页'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('全部'), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
expect(find.text('热门'), findsOneWidget); expect(find.text('首页'), findsOneWidget);
await tester.tap(find.text('发现'));
await tester.pumpAndSettle();
expect(find.text('推荐'), findsOneWidget);
}); });
testWidgets( testWidgets(
@@ -249,7 +230,9 @@ void main() {
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('City Lights & Velvet Nights')); await tester.tap(
find.byKey(const Key('home-drama-card-city-lights-demo')),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget); expect(find.byType(Slider), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
@@ -272,7 +255,7 @@ void main() {
expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), findsOneWidget); expect(find.text('Coins'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.text('Shorts')); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
}, },
@@ -311,25 +294,37 @@ void main() {
await tester.pumpWidget(appUnderTest()); await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Home')); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('All'), findsOneWidget); expect(find.text('Recommended'), findsOneWidget);
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.text('Rewards'), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget);
expect(find.text(r'$ 0'), findsOneWidget);
expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget);
expect( expect(
find.text('Action · Thriller', skipOffstage: false), find.byKey(
const Key('home-drama-genre-city-lights-demo'),
skipOffstage: false,
),
findsOneWidget, 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); 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(); await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget); 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(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
}, },
@@ -345,10 +340,10 @@ void main() {
await tester.pumpWidget(appUnderTest()); await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Home')); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byTooltip('Search'), findsNothing); expect(find.byTooltip('Search'), findsOneWidget);
expect( expect(
Navigator.of( Navigator.of(
tester.element(find.byKey(const Key('home-all-dramas-grid'))), tester.element(find.byKey(const Key('home-all-dramas-grid'))),
@@ -356,28 +351,34 @@ void main() {
isFalse, 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.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( expect(
Navigator.of(tester.element(find.text('History'))).canPop(), Navigator.of(tester.element(find.text('History').first)).canPop(),
isFalse, isFalse,
); );
await tester.tap(find.text('Watchlist')); await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget); expect(find.text('No shows here yet.'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byTooltip('Search'), findsNothing); expect(find.byTooltip('Search'), findsOneWidget);
expect( expect(
Navigator.of( Navigator.of(
tester.element(find.byKey(const Key('home-all-dramas-grid'))), tester.element(find.byKey(const Key('home-all-dramas-grid'))),
).canPop(), ).canPop(),
isFalse, 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(); await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget); expect(find.text('No shows here yet.'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-home'))); 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.drag(find.byType(PageView), const Offset(0, -550));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
expect(find.textContaining('Episode 1'), findsOneWidget); expect(
tester
.widget<Text>(find.byKey(const Key('home-feed-episode-title')))
.data,
contains('Episode 1'),
);
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('City Lights & Velvet Nights')); await tester.tap(
find.byKey(const Key('home-drama-card-city-lights-demo')),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget); expect(find.byType(Slider), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.drag(find.byType(PageView), const Offset(0, -550));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Episode 2 · Playing now'), findsOneWidget); expect(
expect(find.text('City Lights & Velvet Nights'), findsWidgets); tester.widget<Text>(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(); await tester.pumpAndSettle();
final keys = [ final keys = [
const Key('rail-like'), const Key('rail-like'),
const Key('rail-save'),
const Key('rail-comments'), const Key('rail-comments'),
const Key('rail-share'), const Key('rail-share'),
const Key('rail-save'),
]; ];
final tops = keys final tops = keys
.map((key) => tester.getTopLeft(find.byKey(key)).dy) .map((key) => tester.getTopLeft(find.byKey(key)).dy)
@@ -545,6 +556,8 @@ void main() {
await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('drawer-open-series'))); await tester.tap(find.byKey(const Key('drawer-open-series')));
@@ -570,6 +583,8 @@ void main() {
await tester.pumpWidget(appUnderTest()); await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle(); 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); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -608,15 +623,17 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-save'))); await tester.tap(find.byKey(const Key('rail-save')));
await tester.pumpAndSettle(); 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.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(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(find.text('City Lights & Velvet Nights'), findsWidgets);
await tester.tap(find.text('The Signal')); await tester.tap(find.text('The Signal'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Watch episode 1'), findsOneWidget); expect(find.byKey(const Key('detail-cta')), findsOneWidget);
}, },
); );
} }