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