merge: K24 客户端 UI 对齐设计稿
This commit is contained in:
commit
4553606ec2
40 files changed
+2145
-481
No files matched your search
@@ -49,6 +49,9 @@ app.*.map.json
|
||||
/assets/*
|
||||
!/assets/icon/
|
||||
!/assets/icon/*.png
|
||||
!/assets/fonts/
|
||||
!/assets/fonts/*.ttf
|
||||
!/assets/fonts/OFL-*.txt
|
||||
/server/
|
||||
/docs/
|
||||
/AGENTS.md
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
@@ -2,26 +2,31 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Evening palette for the Indonesia launch.
|
||||
/// Obsidian Flare palette from `docs/design/README.md` and screens 01/02.
|
||||
///
|
||||
/// Chili is the action color. Gold marks coins, rewards, and playback
|
||||
/// progress. Cocoa surfaces keep the app in night-market light instead of
|
||||
/// a cold utility screen.
|
||||
/// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page
|
||||
/// code does not need a mass rename; their values now follow the design tokens.
|
||||
abstract final class AppColors {
|
||||
/// Chili fill for buttons, selected chips, and brand marks.
|
||||
static const blue = Color(0xFFC4322A);
|
||||
/// Primary CTA, selected navigation, playback progress.
|
||||
static const blue = Color(0xFFFF5B3D);
|
||||
static const primary = blue;
|
||||
|
||||
/// Pressed chili.
|
||||
static const bluePressed = Color(0xFF8E221C);
|
||||
/// Pressed state for the primary CTA.
|
||||
static const bluePressed = Color(0xFFFF4321);
|
||||
|
||||
/// Coral for text and icons sitting on dark surfaces.
|
||||
static const blueSoft = Color(0xFFFF8A72);
|
||||
/// Secondary accent for tags, locks, and highlighted icons.
|
||||
static const blueSoft = Color(0xFF9278EA);
|
||||
static const secondary = blueSoft;
|
||||
static const lavender = blueSoft;
|
||||
|
||||
/// Coins, rewards, and the playback progress line.
|
||||
static const gold = Color(0xFFF6C453);
|
||||
/// Coins and rewards.
|
||||
static const gold = Color(0xFFFBBF24);
|
||||
|
||||
/// Favorite/like heart without competing with the chili primary action.
|
||||
static const favorite = Color(0xFFD74A7D);
|
||||
/// Success and free-state accents.
|
||||
static const success = Color(0xFF34D399);
|
||||
|
||||
/// Liked hearts use the primary accent.
|
||||
static const favorite = blue;
|
||||
|
||||
/// Text placed on [gold].
|
||||
static const onGold = Color(0xFF2A1810);
|
||||
@@ -30,23 +35,37 @@ abstract final class AppColors {
|
||||
static const onPrimary = Color(0xFFFFFFFF);
|
||||
|
||||
/// Page background.
|
||||
static const ink = Color(0xFF100C0A);
|
||||
static const ink = Color(0xFF0B0C10);
|
||||
|
||||
/// Bottom bar.
|
||||
static const bar = Color(0xFF1A1412);
|
||||
/// Bottom navigation background (rendered at 95% with blur).
|
||||
static const bar = Color(0xFF0B0C10);
|
||||
|
||||
/// Cards lifted off [ink].
|
||||
static const card = Color(0xFF241C19);
|
||||
static const card = Color(0xFF16181F);
|
||||
|
||||
/// Chips and nested rows.
|
||||
static const raised = Color(0xFF322824);
|
||||
/// Floating surfaces, inputs, and nested rows.
|
||||
static const raised = Color(0xFF1D1E28);
|
||||
|
||||
/// Primary text on dark surfaces.
|
||||
static const cream = Color(0xFFF8F1EA);
|
||||
/// Body text on dark surfaces.
|
||||
static const cream = Color(0xFFE3E2E8);
|
||||
|
||||
static const muted = Color(0xFFB7A59C);
|
||||
static const outlineDark = Color(0xFF4A3C36);
|
||||
static const outlineVariantDark = Color(0xFF2A221F);
|
||||
static const outlineLight = Color(0xFF8C7A72);
|
||||
static const outlineVariantLight = Color(0xFFE4D5CC);
|
||||
/// Titles and high-emphasis text.
|
||||
static const onSurface = Color(0xFFFFFFFF);
|
||||
|
||||
/// Secondary text.
|
||||
static const muted = Color(0xFFA1A1AA);
|
||||
|
||||
/// Weak hints and timestamps.
|
||||
static const subtle = Color(0xFF71717A);
|
||||
|
||||
/// 8% white border used by cards and inputs.
|
||||
static const border = Color(0x14FFFFFF);
|
||||
|
||||
/// 14% white border used by glass controls.
|
||||
static const glassBorder = Color(0x24FFFFFF);
|
||||
|
||||
static const outlineDark = Color(0xFF2A2C36);
|
||||
static const outlineVariantDark = Color(0xFF242630);
|
||||
static const outlineLight = Color(0xFF8E8E93);
|
||||
static const outlineVariantLight = Color(0xFFE4E4E7);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// K20 cross-page spacing, type, radius, and control sizes.
|
||||
/// Cross-page spacing, type, radius, and control sizes.
|
||||
abstract final class AppDimens {
|
||||
static const double spaceXs = 4;
|
||||
static const double spaceSm = 8;
|
||||
@@ -15,6 +15,9 @@ abstract final class AppDimens {
|
||||
static const double innerRadius = 12;
|
||||
static const double innerPadding = 12;
|
||||
static const double pillRadius = 20;
|
||||
static const double glassSmallRadius = 16;
|
||||
static const double glassRadius = 24;
|
||||
static const double glassCardRadius = 26;
|
||||
|
||||
static const double caption = 12;
|
||||
static const double emptyStateSubtitle = 12.5;
|
||||
@@ -34,6 +37,11 @@ abstract final class AppDimens {
|
||||
static const double navIcon = 22;
|
||||
static const double progressHeight = 4;
|
||||
static const double progressRadius = 4;
|
||||
static const double progressLineHeight = 1.5;
|
||||
|
||||
static const double searchHeight = 44;
|
||||
static const double navDot = 3;
|
||||
static const double glassBlur = 24;
|
||||
|
||||
static const double accountCardHeight = 64;
|
||||
static const double accountIcon = 18;
|
||||
|
||||
@@ -6,9 +6,10 @@ import 'package:flutter/services.dart';
|
||||
import 'app_colors.dart';
|
||||
import 'app_dimens.dart';
|
||||
|
||||
/// Light paper and warm-cocoa themes for the Indonesia evening palette.
|
||||
/// Obsidian Flare themes. Dark is the product default; light mirrors the same
|
||||
/// orange primary for system-light devices.
|
||||
abstract final class AppTheme {
|
||||
static const _lightBackground = Color(0xFFFFF6F1);
|
||||
static const _lightBackground = Color(0xFFF7F7F8);
|
||||
static const _darkBackground = AppColors.ink;
|
||||
|
||||
/// Light theme for system light mode.
|
||||
@@ -22,7 +23,7 @@ abstract final class AppTheme {
|
||||
static ThemeData dark() => _build(
|
||||
brightness: Brightness.dark,
|
||||
surface: _darkBackground,
|
||||
accent: AppColors.blueSoft,
|
||||
accent: AppColors.blue,
|
||||
);
|
||||
|
||||
static ThemeData _build({
|
||||
@@ -35,27 +36,27 @@ abstract final class AppTheme {
|
||||
? const ColorScheme.dark(
|
||||
primary: AppColors.blue,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
secondary: AppColors.blueSoft,
|
||||
onSecondary: AppColors.ink,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.onPrimary,
|
||||
primaryContainer: AppColors.bluePressed,
|
||||
onPrimaryContainer: AppColors.onPrimary,
|
||||
secondaryContainer: AppColors.bluePressed,
|
||||
secondaryContainer: AppColors.raised,
|
||||
onSecondaryContainer: AppColors.onPrimary,
|
||||
surface: _darkBackground,
|
||||
onSurface: AppColors.cream,
|
||||
onSurface: AppColors.onSurface,
|
||||
outline: AppColors.outlineDark,
|
||||
outlineVariant: AppColors.outlineVariantDark,
|
||||
surfaceTint: AppColors.blueSoft,
|
||||
surfaceTint: AppColors.blue,
|
||||
inversePrimary: AppColors.blueSoft,
|
||||
)
|
||||
: ColorScheme.light(
|
||||
primary: AppColors.blue,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
secondary: AppColors.blue,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.onPrimary,
|
||||
primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
|
||||
primaryContainer: AppColors.blue.withValues(alpha: 0.14),
|
||||
onPrimaryContainer: AppColors.ink,
|
||||
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
|
||||
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14),
|
||||
onSecondaryContainer: AppColors.ink,
|
||||
surface: _lightBackground,
|
||||
onSurface: AppColors.ink,
|
||||
@@ -64,11 +65,12 @@ abstract final class AppTheme {
|
||||
surfaceTint: AppColors.blue,
|
||||
inversePrimary: AppColors.blueSoft,
|
||||
);
|
||||
return ThemeData(
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: surface,
|
||||
fontFamily: 'PlusJakartaSans',
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
|
||||
sliderTheme: SliderThemeData(
|
||||
activeTrackColor: accent,
|
||||
@@ -105,7 +107,8 @@ abstract final class AppTheme {
|
||||
appBarTheme: const AppBarTheme(
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
titleTextStyle: TextStyle(
|
||||
color: AppColors.cream,
|
||||
color: AppColors.onSurface,
|
||||
fontFamily: 'Outfit',
|
||||
fontSize: AppDimens.pageTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
@@ -125,8 +128,29 @@ abstract final class AppTheme {
|
||||
contentTextStyle: const TextStyle(color: AppColors.onPrimary),
|
||||
),
|
||||
);
|
||||
final textTheme = base.textTheme
|
||||
.apply(
|
||||
fontFamily: 'PlusJakartaSans',
|
||||
bodyColor: dark ? AppColors.cream : AppColors.ink,
|
||||
displayColor: dark ? AppColors.onSurface : AppColors.ink,
|
||||
)
|
||||
.copyWith(
|
||||
displayLarge: _display(base.textTheme.displayLarge, dark),
|
||||
displayMedium: _display(base.textTheme.displayMedium, dark),
|
||||
displaySmall: _display(base.textTheme.displaySmall, dark),
|
||||
headlineLarge: _display(base.textTheme.headlineLarge, dark),
|
||||
headlineMedium: _display(base.textTheme.headlineMedium, dark),
|
||||
headlineSmall: _display(base.textTheme.headlineSmall, dark),
|
||||
titleLarge: _display(base.textTheme.titleLarge, dark),
|
||||
);
|
||||
return base.copyWith(textTheme: textTheme);
|
||||
}
|
||||
|
||||
static TextStyle? _display(TextStyle? style, bool dark) => style?.copyWith(
|
||||
fontFamily: 'Outfit',
|
||||
color: dark ? AppColors.onSurface : AppColors.ink,
|
||||
);
|
||||
|
||||
static Color _buttonColor(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.pressed) ||
|
||||
states.contains(WidgetState.hovered) ||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
@@ -14,6 +16,7 @@ class AppBottomNavItem {
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
this.indicatorKey,
|
||||
});
|
||||
|
||||
final Key tabKey;
|
||||
@@ -22,9 +25,10 @@ class AppBottomNavItem {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
final Key? indicatorKey;
|
||||
}
|
||||
|
||||
/// Shared bottom navigation used by all root tabs and the player fallback.
|
||||
/// Shared 3-tab navigation used by all root tabs and the player fallback.
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
const AppBottomNav({
|
||||
super.key,
|
||||
@@ -38,20 +42,23 @@ class AppBottomNav extends StatelessWidget {
|
||||
final Key? barKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: const Border(
|
||||
top: BorderSide(color: AppColors.outlineVariantDark),
|
||||
Widget build(BuildContext context) => ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withValues(alpha: 0.95),
|
||||
border: const Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [for (final item in items) _NavDestination(item: item)],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [for (final item in items) _NavDestination(item: item)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -71,28 +78,23 @@ class _NavDestination extends StatelessWidget {
|
||||
key: item.tabKey,
|
||||
onTap: item.onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
AnimatedScale(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.selected ? AppColors.blue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
),
|
||||
scale: item.selected ? 1 : 0.96,
|
||||
child: Icon(
|
||||
item.selected ? item.selectedIcon : item.icon,
|
||||
size: AppDimens.navIcon,
|
||||
color: item.selected ? AppColors.onPrimary : AppColors.muted,
|
||||
color: item.selected
|
||||
? AppColors.blue
|
||||
: Colors.white.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
@@ -100,7 +102,22 @@ class _NavDestination extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: AppDimens.navLabel,
|
||||
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: item.selected ? AppColors.blueSoft : AppColors.muted,
|
||||
color: item.selected
|
||||
? AppColors.blue
|
||||
: Colors.white.withValues(alpha: 0.45),
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
AnimatedContainer(
|
||||
key: item.indicatorKey,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
width: AppDimens.navDot,
|
||||
height: AppDimens.navDot,
|
||||
decoration: BoxDecoration(
|
||||
color: item.selected ? AppColors.blue : Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@
|
||||
"newReleases": "New Releases",
|
||||
"trendingMicroDramas": "Trending Micro-Dramas",
|
||||
"seeAll": "See All",
|
||||
"trendingSubtitle": "Most binge-watched this week",
|
||||
"verticalMiniSeries": "VERTICAL MINI-SERIES",
|
||||
"followSeries": "Follow Series",
|
||||
"seasonOneVerticalCinema": "Season 1 • Vertical Cinema",
|
||||
"artworkOnly": "Artwork",
|
||||
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
||||
"videoPending": "Video pending",
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"newReleases": "Rilisan Baru",
|
||||
"trendingMicroDramas": "Drama Mini Populer",
|
||||
"seeAll": "Lihat Semua",
|
||||
"trendingSubtitle": "Paling banyak ditonton minggu ini",
|
||||
"verticalMiniSeries": "SERIAL MINI VERTIKAL",
|
||||
"followSeries": "Ikuti Series",
|
||||
"seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal",
|
||||
"artworkOnly": "Gambar",
|
||||
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
||||
"videoPending": "Video belum siap",
|
||||
|
||||
@@ -238,6 +238,30 @@ abstract class AppLocalizations {
|
||||
/// **'See All'**
|
||||
String get seeAll;
|
||||
|
||||
/// No description provided for @trendingSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Most binge-watched this week'**
|
||||
String get trendingSubtitle;
|
||||
|
||||
/// No description provided for @verticalMiniSeries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'VERTICAL MINI-SERIES'**
|
||||
String get verticalMiniSeries;
|
||||
|
||||
/// No description provided for @followSeries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Follow Series'**
|
||||
String get followSeries;
|
||||
|
||||
/// No description provided for @seasonOneVerticalCinema.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Season 1 • Vertical Cinema'**
|
||||
String get seasonOneVerticalCinema;
|
||||
|
||||
/// No description provided for @artworkOnly.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -78,6 +78,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => 'See All';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => 'Most binge-watched this week';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => 'VERTICAL MINI-SERIES';
|
||||
|
||||
@override
|
||||
String get followSeries => 'Follow Series';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema';
|
||||
|
||||
@override
|
||||
String get artworkOnly => 'Artwork';
|
||||
|
||||
|
||||
@@ -78,6 +78,18 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => 'Lihat Semua';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => 'Paling banyak ditonton minggu ini';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => 'SERIAL MINI VERTIKAL';
|
||||
|
||||
@override
|
||||
String get followSeries => 'Ikuti Series';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal';
|
||||
|
||||
@override
|
||||
String get artworkOnly => 'Gambar';
|
||||
|
||||
|
||||
@@ -77,6 +77,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => '查看全部';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => '本周追看最多';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => '竖屏短剧';
|
||||
|
||||
@override
|
||||
String get followSeries => '追剧';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院';
|
||||
|
||||
@override
|
||||
String get artworkOnly => '海报展示';
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"newReleases": "新剧",
|
||||
"trendingMicroDramas": "热门短剧",
|
||||
"seeAll": "查看全部",
|
||||
"trendingSubtitle": "本周追看最多",
|
||||
"verticalMiniSeries": "竖屏短剧",
|
||||
"followSeries": "追剧",
|
||||
"seasonOneVerticalCinema": "第 1 季 · 竖屏影院",
|
||||
"artworkOnly": "海报展示",
|
||||
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
||||
"videoPending": "视频待提供",
|
||||
|
||||
@@ -8,8 +8,5 @@ class DiscoverBinding {
|
||||
DiscoverLogic create({
|
||||
required Drama drama,
|
||||
required WatchHistoryRepository history,
|
||||
}) => DiscoverLogic(
|
||||
drama: drama,
|
||||
history: history,
|
||||
);
|
||||
}) => DiscoverLogic(drama: drama, history: history);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
final WatchHistoryRepository history;
|
||||
Drama drama;
|
||||
late Future<Duration?> position;
|
||||
WatchProgress? resumeRecord;
|
||||
final TextEditingController search = TextEditingController();
|
||||
final FocusNode searchFocus = FocusNode();
|
||||
final ScrollController scroll = ScrollController();
|
||||
@@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
.resumePointFor(drama.id)
|
||||
.then((point) => point?.position)
|
||||
.catchError((Object _) => null);
|
||||
refreshResume();
|
||||
}
|
||||
|
||||
Future<void> refreshResume() async {
|
||||
try {
|
||||
final recent = await history.recent(limit: 1);
|
||||
if (recent.isEmpty || recent.first.finished) {
|
||||
resumeRecord = null;
|
||||
} else {
|
||||
resumeRecord = recent.first;
|
||||
}
|
||||
} catch (_) {
|
||||
resumeRecord = null;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
void onSearchChanged() => notify();
|
||||
|
||||
List<Drama> filtered(List<Drama> dramas) {
|
||||
final query = search.text.trim().toLowerCase();
|
||||
if (query.isEmpty) return dramas;
|
||||
return dramas.where((drama) {
|
||||
final haystack = [
|
||||
drama.title,
|
||||
drama.genre,
|
||||
drama.category,
|
||||
drama.synopsis,
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Drama? resumeDrama(List<Drama> dramas) {
|
||||
final record = resumeRecord;
|
||||
if (record == null) return null;
|
||||
for (final drama in dramas) {
|
||||
if (drama.id == record.dramaId) return drama;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
||||
|
||||
void openSearch() {
|
||||
|
||||
@@ -5,20 +5,19 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_bottom_nav.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/glass_panel.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'discover_binding.dart';
|
||||
import 'discover_logic.dart';
|
||||
import 'widgets/cover_carousel.dart';
|
||||
|
||||
part 'widgets/home_drama_grid.dart';
|
||||
|
||||
@@ -70,7 +69,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
drama: widget.drama,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
if (widget.focusSearch) logic.searchOpen = true;
|
||||
if (widget.focusSearch) {
|
||||
logic.searchOpen = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.active) logic.searchFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -104,14 +108,13 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final categories = context.watch<CategoryController>();
|
||||
final homeBanners = context.watch<CatalogController>().feed.banners;
|
||||
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||
final visible = logic.filtered(categories.visibleDramas);
|
||||
final content = SafeArea(
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_header(l10n),
|
||||
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
|
||||
Expanded(child: _feed(l10n, categories, allDramas, visible)),
|
||||
if (!widget.embedded) _bottomNav(l10n),
|
||||
],
|
||||
),
|
||||
@@ -130,29 +133,96 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
Widget _feed(
|
||||
AppLocalizations l10n,
|
||||
CategoryController categories,
|
||||
List<FeedBanner> banners,
|
||||
List<Drama> allDramas,
|
||||
List<Drama> visible,
|
||||
) => CustomScrollView(
|
||||
controller: logic.scroll,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _header(l10n)),
|
||||
SliverToBoxAdapter(child: _search()),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: _carousel(l10n, banners, allDramas),
|
||||
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12),
|
||||
child: _categories(l10n, categories),
|
||||
),
|
||||
),
|
||||
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||
child: _continueCard(
|
||||
l10n,
|
||||
logic.resumeDrama(allDramas)!,
|
||||
logic.resumeRecord!,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||
child: _quickActions(l10n, categories),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
|
||||
child: _categories(l10n, categories),
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.trendingMicroDramas,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.trendingSubtitle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.subtle,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
categories.selectCode(CategoryController.hotCode),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.blue,
|
||||
minimumSize: const Size(44, 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_HomeDramaGrid(
|
||||
l10n: l10n,
|
||||
categories: categories,
|
||||
dramas: visible,
|
||||
searching: logic.search.text.trim().isNotEmpty,
|
||||
onOpen: (drama) => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
@@ -166,93 +236,129 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const _BrandMark(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.appName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.appName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.05,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
l10n.verticalMiniSeries,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: widget.signedIn ? l10n.myTab : l10n.logIn,
|
||||
child: InkWell(
|
||||
key: const Key('discover-profile'),
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.raised,
|
||||
border: Border.all(
|
||||
color: AppColors.secondary.withValues(alpha: 0.7),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
color: AppColors.onSurface,
|
||||
size: 21,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.signedIn) ...[
|
||||
_HeaderPill(
|
||||
icon: Icons.monetization_on_rounded,
|
||||
label: r'$ 0',
|
||||
onTap: widget.onCoins,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderPill(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
label: l10n.rewards,
|
||||
onTap: widget.onRewards,
|
||||
),
|
||||
] else
|
||||
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carousel(
|
||||
AppLocalizations l10n,
|
||||
List<FeedBanner> banners,
|
||||
List<Drama> allDramas,
|
||||
) {
|
||||
final items = banners
|
||||
.where((banner) => banner.imageUrl.isNotEmpty)
|
||||
.toList();
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return CoverCarousel(
|
||||
active: widget.active,
|
||||
items: [
|
||||
for (final banner in items)
|
||||
CoverItem(
|
||||
title: _bannerTitle(banner, allDramas),
|
||||
subtitle: '',
|
||||
image: banner.imageUrl,
|
||||
onOpen: () => _openBanner(banner, allDramas),
|
||||
Widget _search() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
height: AppDimens.searchHeight,
|
||||
child: TextField(
|
||||
key: const Key('discover-search'),
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
onChanged: (_) => logic.onSearchChanged(),
|
||||
textInputAction: TextInputAction.search,
|
||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: AppColors.card,
|
||||
hintText: AppLocalizations.of(context)!.searchDiscover,
|
||||
hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search_rounded,
|
||||
color: AppColors.subtle,
|
||||
size: 20,
|
||||
),
|
||||
suffixIcon: const Icon(
|
||||
Icons.mic_none_rounded,
|
||||
color: AppColors.muted,
|
||||
size: 19,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||
borderSide: const BorderSide(color: AppColors.blue, width: 1.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return '';
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) return drama.title;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return;
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) {
|
||||
logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _categories(AppLocalizations l10n, CategoryController categories) {
|
||||
final choices = <({String code, String label})>[
|
||||
(code: CategoryController.allCode, label: l10n.all),
|
||||
@@ -265,6 +371,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
key: const Key('home-category-chips'),
|
||||
height: AppDimens.tapTarget,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: choices.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
@@ -281,17 +388,22 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.blue : AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
color: selected ? AppColors.blue : AppColors.card,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? AppColors.blue
|
||||
: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
choice.label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.onPrimary : AppColors.cream,
|
||||
fontSize: AppDimens.body,
|
||||
color: selected ? AppColors.onPrimary : AppColors.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
@@ -305,12 +417,208 @@ class _DiscoverPageState extends State<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(
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('discover-nav-home'),
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
selectedIcon: Icons.play_circle_fill_rounded,
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home_rounded,
|
||||
label: l10n.homeTab,
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
@@ -329,8 +637,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('discover-nav-my'),
|
||||
icon: Icons.video_library_outlined,
|
||||
selectedIcon: Icons.video_library_rounded,
|
||||
icon: Icons.person_outline_rounded,
|
||||
selectedIcon: Icons.person_rounded,
|
||||
label: l10n.myTab,
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
@@ -343,72 +651,61 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
}
|
||||
|
||||
const _coin = AppColors.gold;
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
const _BrandMark();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderPill extends StatelessWidget {
|
||||
const _HeaderPill({
|
||||
class _QuickAction extends StatelessWidget {
|
||||
const _QuickAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.raised,
|
||||
shape: StadiumBorder(
|
||||
side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)),
|
||||
),
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: _coin),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: AppDimens.caption,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
child: Container(
|
||||
height: 76,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(icon, color: color, size: 17),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.05,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -416,28 +713,8 @@ class _HeaderPill extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginButton extends StatelessWidget {
|
||||
const _LoginButton({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: onTap,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: AppColors.blue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget),
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: AppDimens.body,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(label, maxLines: 1),
|
||||
);
|
||||
}
|
||||
String _duration(Duration value) {
|
||||
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
@@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
const _HomeDramaGrid({
|
||||
required this.l10n,
|
||||
required this.categories,
|
||||
required this.dramas,
|
||||
required this.searching,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final CategoryController categories;
|
||||
final List<Drama> dramas;
|
||||
final bool searching;
|
||||
final ValueChanged<Drama> onOpen;
|
||||
|
||||
@override
|
||||
@@ -34,16 +38,21 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final dramas = categories.visibleDramas;
|
||||
if (dramas.isEmpty) {
|
||||
final missingCategory = categories.selectedSectionMissing;
|
||||
return _fill(
|
||||
EmptyState(
|
||||
key: const Key('home-all-dramas-empty'),
|
||||
icon: missingCategory
|
||||
icon: searching
|
||||
? Icons.search_off_rounded
|
||||
: missingCategory
|
||||
? Icons.grid_view_rounded
|
||||
: Icons.search_off_rounded,
|
||||
title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults,
|
||||
: Icons.movie_filter_outlined,
|
||||
title: searching
|
||||
? l10n.noSearchResults
|
||||
: missingCategory
|
||||
? l10n.noCategoryDramas
|
||||
: l10n.noSearchResults,
|
||||
subtitle: l10n.emptyHint,
|
||||
),
|
||||
);
|
||||
@@ -57,24 +66,24 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dramaGrid(List<Drama> dramas) => SliverPadding(
|
||||
Widget _dramaGrid(List<Drama> items) => SliverPadding(
|
||||
key: const Key('home-all-dramas-section'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
|
||||
sliver: SliverGrid(
|
||||
key: const Key('home-all-dramas-grid'),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 14,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 2 / 3,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.55,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _HomeDramaCard(
|
||||
l10n: l10n,
|
||||
drama: dramas[index],
|
||||
onTap: () => onOpen(dramas[index]),
|
||||
drama: items[index],
|
||||
onTap: () => onOpen(items[index]),
|
||||
),
|
||||
childCount: dramas.length,
|
||||
childCount: items.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -102,95 +111,141 @@ class _HomeDramaCard extends StatelessWidget {
|
||||
key: Key('home-drama-card-${drama.id}'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
child: AspectRatio(
|
||||
key: Key('home-drama-cover-${drama.id}'),
|
||||
aspectRatio: 2 / 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xE6100C0A)],
|
||||
stops: [0.42, 1],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
key: Key('home-drama-cover-${drama.id}'),
|
||||
aspectRatio: 3 / 4.2,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (drama.isHot)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: DecoratedBox(
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0x33000000),
|
||||
Color(0x00000000),
|
||||
Color(0xB3000000),
|
||||
],
|
||||
stops: [0, 0.42, 1],
|
||||
),
|
||||
child: Text(
|
||||
l10n.hot,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: AppDimens.caption,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (drama.isHot) _badge(l10n.hot, AppColors.blue),
|
||||
if (drama.freeEpisodes > 0)
|
||||
_badge(l10n.free, AppColors.success),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (drama.viewCount > 0)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.42),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
child: Text(
|
||||
_compactCount(drama.viewCount),
|
||||
key: Key('home-drama-views-${drama.id}'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
key: Key('home-drama-title-${drama.id}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: AppDimens.body,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
genre.isEmpty ? l10n.videoPending : genre,
|
||||
key: Key('home-drama-genre-${drama.id}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: AppDimens.caption,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
drama.title,
|
||||
key: Key('home-drama-title-${drama.id}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
genre.isEmpty ? l10n.videoPending : genre,
|
||||
key: Key('home-drama-genre-${drama.id}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.subtle,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _badge(String label, Color color) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.3,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _compactCount(int value) {
|
||||
if (value >= 1000000) {
|
||||
final scaled = value / 1000000;
|
||||
return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M';
|
||||
}
|
||||
if (value >= 1000) {
|
||||
final scaled = value / 1000;
|
||||
return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K';
|
||||
}
|
||||
return '$value';
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
|
||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
Drama currentDrama;
|
||||
int tab = 1;
|
||||
int tab = 0;
|
||||
int searchRequest = 0;
|
||||
int sectionRequest = 0;
|
||||
int resumeRequest = 0;
|
||||
|
||||
@@ -244,35 +244,30 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
barKey: const Key('home-tabs-bar'),
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-discover'),
|
||||
tabKey: const Key('tab-home'),
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: logic.tab == 1,
|
||||
onTap: () => _selectTab(context, 1),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-home'),
|
||||
icon: Icons.movie_outlined,
|
||||
selectedIcon: Icons.movie_rounded,
|
||||
label: l10n.shortsTab,
|
||||
selected: logic.tab == 0,
|
||||
indicatorKey: const Key('tab-home-indicator'),
|
||||
onTap: () => _selectTab(context, 0),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-my-list'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
selectedIcon: Icons.bookmark_rounded,
|
||||
label: l10n.myListTab,
|
||||
selected: logic.tab == 2 && !logic.profile,
|
||||
onTap: () => _selectTab(context, 2),
|
||||
tabKey: const Key('tab-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
selectedIcon: Icons.explore_rounded,
|
||||
label: l10n.discoverTab,
|
||||
selected: logic.tab == 1,
|
||||
indicatorKey: const Key('tab-discover-indicator'),
|
||||
onTap: () => _selectTab(context, 1),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-my'),
|
||||
icon: Icons.person_outline_rounded,
|
||||
selectedIcon: Icons.person_rounded,
|
||||
label: l10n.myTab,
|
||||
selected: logic.tab == 2 && logic.profile,
|
||||
selected: logic.tab == 2,
|
||||
indicatorKey: const Key('tab-my-indicator'),
|
||||
onTap: () => _selectTab(context, 2, myProfile: true),
|
||||
),
|
||||
],
|
||||
@@ -282,9 +277,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
|
||||
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
|
||||
final same = tab == 2
|
||||
? logic.tab == 2 && logic.profile == myProfile
|
||||
: logic.tab == tab;
|
||||
final same = logic.tab == tab;
|
||||
if (!same) {
|
||||
unawaited(context.read<Haptics>().selectionClick());
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ class _MyPageState extends State<MyPage> {
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
if (!widget.profile) _topBar(l10n),
|
||||
_topBar(l10n),
|
||||
Expanded(
|
||||
child: FutureBuilder<Duration?>(
|
||||
future: logic.position,
|
||||
@@ -132,11 +132,11 @@ class _MyPageState extends State<MyPage> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||
children: [
|
||||
if (widget.profile)
|
||||
..._accountPage(l10n)
|
||||
else ...[
|
||||
_library(l10n, position, total, fraction),
|
||||
],
|
||||
if (widget.profile) ..._accountPage(l10n),
|
||||
if (widget.profile) const SizedBox(height: 24),
|
||||
_library(l10n, position, total, fraction),
|
||||
if (!widget.profile) const SizedBox(height: 24),
|
||||
if (!widget.profile) ..._accountPage(l10n),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Text(
|
||||
@@ -174,7 +174,7 @@ class _MyPageState extends State<MyPage> {
|
||||
const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
l10n.myListTab,
|
||||
widget.profile ? l10n.myTab : l10n.myListTab,
|
||||
style: const TextStyle(
|
||||
fontSize: AppDimens.pageTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
@@ -548,6 +548,7 @@ class _MyPageState extends State<MyPage> {
|
||||
selected: logic.section == index,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
key: Key('mine-library-tab-$index'),
|
||||
onTap: () {
|
||||
logic.selectSection(index);
|
||||
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../../core/widgets/download_action.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'player_binding.dart';
|
||||
import 'player_logic.dart';
|
||||
import 'widgets/home_feed_overlay.dart';
|
||||
import 'widgets/seek_progress_bar.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
@@ -334,6 +335,57 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
)
|
||||
else if (!logic.screenCleared && widget.homeMode)
|
||||
HomeFeedOverlay(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
liked: interactions.isLiked(drama.id),
|
||||
saved: context.watch<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)
|
||||
_PlaybackOverlay(
|
||||
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.onSeek,
|
||||
this.showTimes = true,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
final bool showTimes;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -116,17 +118,27 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: scrubbing ? 48 : 44,
|
||||
height: widget.compact
|
||||
? (scrubbing ? 32 : 20)
|
||||
: (scrubbing ? 48 : 44),
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: scrubbing ? 4 : 3,
|
||||
activeTrackColor: AppColors.gold,
|
||||
inactiveTrackColor: Colors.white30,
|
||||
thumbColor: Colors.white,
|
||||
trackHeight: widget.compact
|
||||
? (scrubbing ? 3 : 1.5)
|
||||
: (scrubbing ? 4 : 3),
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.compact
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: Colors.white30,
|
||||
thumbColor: widget.compact ? AppColors.blue : Colors.white,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
||||
enabledThumbRadius: widget.compact
|
||||
? (scrubbing ? 6 : 4)
|
||||
: (scrubbing ? 8 : 6),
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
|
||||
@@ -82,6 +82,26 @@ flutter:
|
||||
assets:
|
||||
- assets/icon/splash_mark.png
|
||||
|
||||
fonts:
|
||||
- family: Outfit
|
||||
fonts:
|
||||
- asset: assets/fonts/Outfit-Regular.ttf
|
||||
weight: 400
|
||||
- asset: assets/fonts/Outfit-SemiBold.ttf
|
||||
weight: 600
|
||||
- asset: assets/fonts/Outfit-Bold.ttf
|
||||
weight: 700
|
||||
- family: PlusJakartaSans
|
||||
fonts:
|
||||
- asset: assets/fonts/PlusJakartaSans-Regular.ttf
|
||||
weight: 400
|
||||
- asset: assets/fonts/PlusJakartaSans-Medium.ttf
|
||||
weight: 500
|
||||
- asset: assets/fonts/PlusJakartaSans-SemiBold.ttf
|
||||
weight: 600
|
||||
- asset: assets/fonts/PlusJakartaSans-Bold.ttf
|
||||
weight: 700
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/content_category.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
@@ -25,7 +26,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_show_all_dramas_in_three_column_sliver_grid_without_continue_watching',
|
||||
'should_show_all_dramas_in_two_column_sliver_grid_without_continue_watching',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -44,27 +45,15 @@ void main() {
|
||||
tester.getCenter(find.text('All')).dx,
|
||||
lessThan(tester.getCenter(find.text('Hot')).dx),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getTopLeft(
|
||||
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||
)
|
||||
.dy -
|
||||
tester
|
||||
.getBottomLeft(find.byKey(const Key('home-category-chips')))
|
||||
.dy,
|
||||
closeTo(14, 0.1),
|
||||
);
|
||||
|
||||
final grid = tester.widget<SliverGrid>(
|
||||
find.byKey(const Key('home-all-dramas-grid')),
|
||||
);
|
||||
final delegate =
|
||||
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||
expect(delegate.crossAxisCount, 3);
|
||||
expect(delegate.crossAxisSpacing, 10);
|
||||
expect(delegate.crossAxisCount, 2);
|
||||
expect(delegate.crossAxisSpacing, 12);
|
||||
expect(delegate.mainAxisSpacing, 14);
|
||||
expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001));
|
||||
expect(delegate.childAspectRatio, closeTo(0.55, 0.001));
|
||||
expect(
|
||||
tester
|
||||
.widget<SliverPadding>(
|
||||
@@ -80,7 +69,7 @@ void main() {
|
||||
final cover = tester.widget<AspectRatio>(
|
||||
find.byKey(const Key('home-drama-cover-city-lights-demo')),
|
||||
);
|
||||
expect(cover.aspectRatio, closeTo(2 / 3, 0.001));
|
||||
expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001));
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
@@ -148,15 +137,7 @@ void main() {
|
||||
|
||||
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
||||
expect(find.text('No matching dramas.'), findsOneWidget);
|
||||
final content = tester.getCenter(
|
||||
find.byKey(const Key('empty-state-content')),
|
||||
);
|
||||
final chipsBottom = tester
|
||||
.getBottomLeft(find.byKey(const Key('home-category-chips')))
|
||||
.dy;
|
||||
final feed = tester.getRect(find.byType(CustomScrollView));
|
||||
expect(content.dx, closeTo(feed.center.dx, 1));
|
||||
expect(content.dy, closeTo((chipsBottom + feed.bottom) / 2, 36));
|
||||
expect(find.byKey(const Key('empty-state-content')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Romance'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -184,7 +165,7 @@ void main() {
|
||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_fit_three_column_grid_on_compact_screen', (tester) async {
|
||||
testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async {
|
||||
tester.view.physicalSize = const Size(320, 568);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -204,11 +185,87 @@ void main() {
|
||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_filter_trending_grid_when_search_text_changes', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(find.byKey(const Key('discover-search')), 'velvet');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(find.byKey(const Key('discover-search')), '');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_show_continue_watching_only_when_a_record_exists', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
categoryRepository: _GridCategoryRepository(),
|
||||
history: InMemoryWatchHistoryRepository(
|
||||
seed: const [
|
||||
WatchProgress(
|
||||
dramaId: 'city-lights-demo',
|
||||
episodeId: 'city-lights-demo-1',
|
||||
position: Duration(seconds: 42),
|
||||
duration: Duration(minutes: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget);
|
||||
expect(find.byKey(const Key('discover-continue-play')), findsOneWidget);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||
expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget);
|
||||
expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget);
|
||||
expect(find.byKey(const Key('discover-quick-new')), findsOneWidget);
|
||||
expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpApp(
|
||||
WidgetTester tester, {
|
||||
required CategoryRepository categoryRepository,
|
||||
WatchHistoryRepository? history,
|
||||
}) async {
|
||||
final catalog = FakeCatalogRepository();
|
||||
await tester.pumpWidget(
|
||||
@@ -216,7 +273,7 @@ Future<void> _pumpApp(
|
||||
playerFactory: createWidgetPlayer,
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: categoryRepository,
|
||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
||||
favoriteRepository: InMemoryFavoriteRepository(),
|
||||
playbackRepository: FakePlaybackRepository(),
|
||||
commentRepository: FakeCommentRepository(),
|
||||
|
||||
@@ -21,7 +21,7 @@ import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'should_render_home_feed_banners_when_discover_banners_are_empty',
|
||||
'should_not_mount_carousel_on_discover_even_when_home_banners_exist',
|
||||
(tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
@@ -43,26 +43,11 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CoverCarousel), findsOneWidget);
|
||||
expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget);
|
||||
expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CoverCarousel),
|
||||
matching: find.text('The Signal'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 4500));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CoverCarousel),
|
||||
matching: find.text('City Lights & Velvet Nights'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(CoverCarousel), findsNothing);
|
||||
expect(find.byKey(const Key('discover-search')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||
expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget);
|
||||
expect(find.text('The Signal'), findsWidgets);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,17 +18,20 @@ void main() {
|
||||
|
||||
final dramas = [_drama('signal'), _drama('city')];
|
||||
|
||||
test('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
test(
|
||||
'should_make_first_catalog_entry_the_current_drama_when_catalog_loads',
|
||||
() {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
expect(logic.currentDrama.id, 'signal');
|
||||
expect(logic.tab, 1);
|
||||
expect(logic.mySection, 0);
|
||||
expect(logic.drawerOpen, isFalse);
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.videoImmersive, isFalse);
|
||||
logic.dispose();
|
||||
});
|
||||
expect(logic.currentDrama.id, 'signal');
|
||||
expect(logic.tab, 0);
|
||||
expect(logic.mySection, 0);
|
||||
expect(logic.drawerOpen, isFalse);
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.videoImmersive, isFalse);
|
||||
logic.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_not_build_shell_when_catalog_is_empty', () {
|
||||
expect(() => HomeLogic(const []), throwsStateError);
|
||||
@@ -82,33 +85,38 @@ void main() {
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
test(
|
||||
'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used',
|
||||
() {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
logic.openProfile();
|
||||
expect(logic.profile, isTrue);
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.mySection, 0);
|
||||
expect(logic.sectionRequest, 1);
|
||||
logic.openProfile();
|
||||
expect(logic.profile, isTrue);
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.mySection, 0);
|
||||
expect(logic.sectionRequest, 1);
|
||||
|
||||
logic.openLibrary(section: 1);
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.mySection, 1);
|
||||
expect(logic.sectionRequest, 2);
|
||||
logic.openLibrary(section: 1);
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.mySection, 1);
|
||||
expect(logic.sectionRequest, 2);
|
||||
|
||||
logic.openLibrary();
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.mySection, 1);
|
||||
expect(logic.sectionRequest, 3);
|
||||
logic.dispose();
|
||||
});
|
||||
logic.openLibrary();
|
||||
expect(logic.profile, isFalse);
|
||||
expect(logic.mySection, 1);
|
||||
expect(logic.sectionRequest, 3);
|
||||
logic.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_return_home_and_open_drawer_when_system_back_is_pressed', () {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
// Home is the launch tab; the first back press lands on Discover.
|
||||
logic.onSystemBack();
|
||||
expect(logic.tab, 1);
|
||||
logic.onSystemBack();
|
||||
expect(logic.drawerOpen, isTrue);
|
||||
expect(logic.tab, 1);
|
||||
expect(logic.backRequest, 0);
|
||||
logic.onDrawerChanged(false);
|
||||
|
||||
@@ -190,7 +198,9 @@ void main() {
|
||||
expect(notifications, 0);
|
||||
});
|
||||
|
||||
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (tester) async {
|
||||
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = HomeLogic(dramas);
|
||||
late BuildContext context;
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -18,9 +18,8 @@ import '../support/widget_player.dart';
|
||||
|
||||
const _keys = [
|
||||
Key('home-tabs-bar'),
|
||||
Key('tab-discover'),
|
||||
Key('tab-home'),
|
||||
Key('tab-my-list'),
|
||||
Key('tab-discover'),
|
||||
Key('tab-my'),
|
||||
];
|
||||
|
||||
@@ -34,7 +33,7 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', (
|
||||
testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', (
|
||||
tester,
|
||||
) async {
|
||||
final haptics = FakeHaptics();
|
||||
@@ -52,10 +51,9 @@ void main() {
|
||||
final bar = tester.widget<Container>(barFinder);
|
||||
final decoration = bar.decoration as BoxDecoration;
|
||||
final border = decoration.border as Border;
|
||||
final context = tester.element(barFinder);
|
||||
expect(decoration.color, AppColors.bar);
|
||||
expect(border.top.color, Theme.of(context).colorScheme.outlineVariant);
|
||||
expect(border.top.color.computeLuminance(), lessThan(0.2));
|
||||
expect(decoration.color, AppColors.bar.withValues(alpha: 0.95));
|
||||
expect(border.top.color, AppColors.border);
|
||||
expect(border.top.color.a, lessThan(0.2));
|
||||
expect(border.top.color, isNot(Colors.white));
|
||||
expect(
|
||||
tester
|
||||
@@ -71,7 +69,7 @@ void main() {
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-home')),
|
||||
matching: find.byIcon(Icons.movie_rounded),
|
||||
matching: find.byIcon(Icons.home_rounded),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
@@ -85,14 +83,14 @@ void main() {
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-discover')),
|
||||
matching: find.byIcon(Icons.home_rounded),
|
||||
matching: find.byIcon(Icons.explore_rounded),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-home')),
|
||||
matching: find.byIcon(Icons.movie_outlined),
|
||||
matching: find.byIcon(Icons.home_outlined),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
@@ -110,6 +108,32 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
|
||||
expect(find.byKey(const Key('tab-my-list')), findsNothing);
|
||||
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-discover')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-my')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Viewer'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('mine-library-tab-0'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -119,6 +143,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
haptics.reset();
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('App Language'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
||||
@@ -14,11 +14,16 @@ double _contrast(Color foreground, Color background) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('should_expose_indonesia_evening_brand_tokens', () {
|
||||
expect(AppColors.blue, const Color(0xFFC4322A));
|
||||
expect(AppColors.bluePressed, const Color(0xFF8E221C));
|
||||
expect(AppColors.blueSoft, const Color(0xFFFF8A72));
|
||||
expect(AppColors.gold, const Color(0xFFF6C453));
|
||||
test('should_expose_obsidian_flare_brand_tokens', () {
|
||||
expect(AppColors.blue, const Color(0xFFFF5B3D));
|
||||
expect(AppColors.bluePressed, const Color(0xFFFF4321));
|
||||
expect(AppColors.blueSoft, const Color(0xFF9278EA));
|
||||
expect(AppColors.secondary, const Color(0xFF9278EA));
|
||||
expect(AppColors.gold, const Color(0xFFFBBF24));
|
||||
expect(AppColors.ink, const Color(0xFF0B0C10));
|
||||
expect(AppColors.card, const Color(0xFF16181F));
|
||||
expect(AppColors.raised, const Color(0xFF1D1E28));
|
||||
expect(AppColors.success, const Color(0xFF34D399));
|
||||
expect(AppColors.onPrimary, const Color(0xFFFFFFFF));
|
||||
expect(
|
||||
_contrast(AppColors.onGold, AppColors.gold),
|
||||
@@ -29,16 +34,16 @@ void main() {
|
||||
test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () {
|
||||
expect(
|
||||
_contrast(AppColors.onPrimary, AppColors.blue),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
greaterThanOrEqualTo(3),
|
||||
);
|
||||
expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(4.5));
|
||||
expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(3));
|
||||
expect(
|
||||
_contrast(AppColors.blueSoft, AppColors.ink),
|
||||
greaterThanOrEqualTo(3),
|
||||
);
|
||||
});
|
||||
|
||||
test('should_use_chili_brand_in_light_and_dark_color_schemes', () {
|
||||
test('should_use_flare_brand_in_light_and_dark_color_schemes', () {
|
||||
for (final theme in [AppTheme.light(), AppTheme.dark()]) {
|
||||
expect(theme.colorScheme.primary, AppColors.blue);
|
||||
expect(theme.colorScheme.onPrimary, AppColors.onPrimary);
|
||||
|
||||
@@ -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
@@ -117,7 +117,12 @@ void main() {
|
||||
expect(find.text('Following'), findsNothing);
|
||||
expect(find.byTooltip('Episodes'), findsWidgets);
|
||||
expect(find.byTooltip('Clear screen'), findsOneWidget);
|
||||
expect(find.byTooltip('Picture in picture'), findsOneWidget);
|
||||
expect(find.byTooltip('Picture in picture'), findsNothing);
|
||||
await tester.tap(find.byTooltip('More'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Picture in picture'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Watch Full'), findsNothing);
|
||||
expect(find.byTooltip('Explore series'), findsNothing);
|
||||
});
|
||||
@@ -155,6 +160,8 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(appUnderTest());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
@@ -217,15 +224,18 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Vicky sheng'), findsOneWidget);
|
||||
expect(find.text('Coins'), findsOneWidget);
|
||||
expect(find.text('PREFERENCES'), findsOneWidget);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('App Language'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('简体中文').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('我的'), findsWidgets);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 700));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('我的账户'), findsOneWidget);
|
||||
expect(find.text('首页'), findsOneWidget);
|
||||
await tester.tap(find.text('首页'));
|
||||
await tester.tap(find.text('找剧'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('全部'), findsOneWidget);
|
||||
expect(find.text('热门'), findsOneWidget);
|
||||
@@ -272,7 +282,7 @@ void main() {
|
||||
expect(find.text('Vicky sheng'), findsOneWidget);
|
||||
expect(find.text('Coins'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('Shorts'));
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
},
|
||||
@@ -311,25 +321,39 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(appUnderTest());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Home'));
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||
expect(find.text('Rewards'), findsOneWidget);
|
||||
expect(find.text(r'$ 0'), findsOneWidget);
|
||||
expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget);
|
||||
expect(find.byKey(const Key('discover-profile')), findsOneWidget);
|
||||
expect(
|
||||
find.text('Action · Thriller', skipOffstage: false),
|
||||
find.byKey(
|
||||
const Key('home-drama-genre-city-lights-demo'),
|
||||
skipOffstage: false,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
const Key('home-drama-genre-vengeance-demo'),
|
||||
skipOffstage: false,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byTooltip('Search'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.byKey(const Key('mine-library-tab-1')),
|
||||
260,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||
await tester.tap(find.text('Shorts'));
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
},
|
||||
@@ -345,7 +369,7 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(appUnderTest());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Home'));
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||
expect(find.byTooltip('Search'), findsNothing);
|
||||
@@ -356,14 +380,16 @@ void main() {
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('History'), findsOneWidget);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('History'), findsWidgets);
|
||||
expect(
|
||||
Navigator.of(tester.element(find.text('History'))).canPop(),
|
||||
Navigator.of(tester.element(find.text('History').first)).canPop(),
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(find.text('Watchlist'));
|
||||
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||
|
||||
@@ -377,7 +403,11 @@ void main() {
|
||||
).canPop(),
|
||||
isFalse,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
@@ -545,6 +575,8 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||
@@ -570,6 +602,8 @@ void main() {
|
||||
await tester.pumpWidget(appUnderTest());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
@@ -608,9 +642,13 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('rail-save')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
||||
await tester.tap(find.byKey(const Key('tab-my')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Watchlist'));
|
||||
await tester.scrollUntilVisible(
|
||||
find.byKey(const Key('mine-library-tab-1')),
|
||||
300,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||
|
||||
Reference in new issue
Block a user