merge: K24 客户端 UI 对齐设计稿

This commit is contained in:
Zhou Weidong committed 2026-10-19 15:02:44 +08:00
commit 4553606ec2
40 files changed
+2145 -481

No files matched your search

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