chore(release): 合并 develop 到 release/v1.0.0
This commit is contained in:
commit
35364c949a
69 files changed
+6115
-2941
No files matched your search
@@ -49,6 +49,9 @@ app.*.map.json
|
|||||||
/assets/*
|
/assets/*
|
||||||
!/assets/icon/
|
!/assets/icon/
|
||||||
!/assets/icon/*.png
|
!/assets/icon/*.png
|
||||||
|
!/assets/fonts/
|
||||||
|
!/assets/fonts/*.ttf
|
||||||
|
!/assets/fonts/OFL-*.txt
|
||||||
/server/
|
/server/
|
||||||
/docs/
|
/docs/
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
|
|||||||
@@ -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.
@@ -39,11 +39,11 @@ abstract final class ServerEndpoint {
|
|||||||
/// In release builds this returns immediately without reading preferences.
|
/// In release builds this returns immediately without reading preferences.
|
||||||
static Future<void> load({
|
static Future<void> load({
|
||||||
bool debug = kDebugMode,
|
bool debug = kDebugMode,
|
||||||
SharedPreferences? preferences,
|
SharedPreferencesAsync? preferences,
|
||||||
}) async {
|
}) async {
|
||||||
if (!debug) return;
|
if (!debug) return;
|
||||||
try {
|
try {
|
||||||
final prefs = preferences ?? await SharedPreferences.getInstance();
|
final prefs = preferences ?? SharedPreferencesAsync();
|
||||||
final stored = await prefs.getString(preferenceKey);
|
final stored = await prefs.getString(preferenceKey);
|
||||||
AppConfig.applyRuntimeBaseUrl(
|
AppConfig.applyRuntimeBaseUrl(
|
||||||
resolve(
|
resolve(
|
||||||
@@ -60,12 +60,12 @@ abstract final class ServerEndpoint {
|
|||||||
/// Persists a normalized runtime endpoint.
|
/// Persists a normalized runtime endpoint.
|
||||||
static Future<bool> save(
|
static Future<bool> save(
|
||||||
String raw, {
|
String raw, {
|
||||||
SharedPreferences? preferences,
|
SharedPreferencesAsync? preferences,
|
||||||
}) async {
|
}) async {
|
||||||
final normalized = normalize(raw);
|
final normalized = normalize(raw);
|
||||||
if (normalized == null) return false;
|
if (normalized == null) return false;
|
||||||
try {
|
try {
|
||||||
await (preferences ?? await SharedPreferences.getInstance()).setString(
|
await (preferences ?? SharedPreferencesAsync()).setString(
|
||||||
preferenceKey,
|
preferenceKey,
|
||||||
normalized,
|
normalized,
|
||||||
);
|
);
|
||||||
@@ -76,9 +76,9 @@ abstract final class ServerEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the persisted runtime endpoint.
|
/// Removes the persisted runtime endpoint.
|
||||||
static Future<void> clear({SharedPreferences? preferences}) async {
|
static Future<void> clear({SharedPreferencesAsync? preferences}) async {
|
||||||
try {
|
try {
|
||||||
await (preferences ?? await SharedPreferences.getInstance()).remove(preferenceKey);
|
await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Clearing an optional developer override is best effort.
|
// Clearing an optional developer override is best effort.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AuthSessionStore {
|
|||||||
|
|
||||||
Future<String> deviceId() async {
|
Future<String> deviceId() async {
|
||||||
if (_cachedDeviceId != null) return _cachedDeviceId!;
|
if (_cachedDeviceId != null) return _cachedDeviceId!;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = SharedPreferencesAsync();
|
||||||
var value = await prefs.getString(_kDeviceId) ?? '';
|
var value = await prefs.getString(_kDeviceId) ?? '';
|
||||||
if (value.isEmpty) {
|
if (value.isEmpty) {
|
||||||
final random = Random();
|
final random = Random();
|
||||||
@@ -37,7 +37,7 @@ class AuthSessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthSession?> read() async {
|
Future<AuthSession?> read() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = SharedPreferencesAsync();
|
||||||
final accessToken = await prefs.getString(_kAccessToken) ?? '';
|
final accessToken = await prefs.getString(_kAccessToken) ?? '';
|
||||||
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
|
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
|
||||||
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
|
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
|
||||||
@@ -58,14 +58,14 @@ class AuthSessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> save(AuthSession session) async {
|
Future<void> save(AuthSession session) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = SharedPreferencesAsync();
|
||||||
await prefs.setString(_kAccessToken, session.accessToken);
|
await prefs.setString(_kAccessToken, session.accessToken);
|
||||||
await prefs.setString(_kRefreshToken, session.refreshToken);
|
await prefs.setString(_kRefreshToken, session.refreshToken);
|
||||||
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
|
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = SharedPreferencesAsync();
|
||||||
await prefs.remove(_kAccessToken);
|
await prefs.remove(_kAccessToken);
|
||||||
await prefs.remove(_kRefreshToken);
|
await prefs.remove(_kRefreshToken);
|
||||||
await prefs.remove(_kUser);
|
await prefs.remove(_kUser);
|
||||||
|
|||||||
@@ -2,26 +2,31 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Evening palette for the Indonesia launch.
|
/// Obsidian Flare palette from `docs/design/README.md` and screens 01/02.
|
||||||
///
|
///
|
||||||
/// Chili is the action color. Gold marks coins, rewards, and playback
|
/// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page
|
||||||
/// progress. Cocoa surfaces keep the app in night-market light instead of
|
/// code does not need a mass rename; their values now follow the design tokens.
|
||||||
/// a cold utility screen.
|
|
||||||
abstract final class AppColors {
|
abstract final class AppColors {
|
||||||
/// Chili fill for buttons, selected chips, and brand marks.
|
/// Primary CTA, selected navigation, playback progress.
|
||||||
static const blue = Color(0xFFC4322A);
|
static const blue = Color(0xFFFF5B3D);
|
||||||
|
static const primary = blue;
|
||||||
|
|
||||||
/// Pressed chili.
|
/// Pressed state for the primary CTA.
|
||||||
static const bluePressed = Color(0xFF8E221C);
|
static const bluePressed = Color(0xFFFF4321);
|
||||||
|
|
||||||
/// Coral for text and icons sitting on dark surfaces.
|
/// Secondary accent for tags, locks, and highlighted icons.
|
||||||
static const blueSoft = Color(0xFFFF8A72);
|
static const blueSoft = Color(0xFF9278EA);
|
||||||
|
static const secondary = blueSoft;
|
||||||
|
static const lavender = blueSoft;
|
||||||
|
|
||||||
/// Coins, rewards, and the playback progress line.
|
/// Coins and rewards.
|
||||||
static const gold = Color(0xFFF6C453);
|
static const gold = Color(0xFFFBBF24);
|
||||||
|
|
||||||
/// Favorite/like heart without competing with the chili primary action.
|
/// Success and free-state accents.
|
||||||
static const favorite = Color(0xFFD74A7D);
|
static const success = Color(0xFF34D399);
|
||||||
|
|
||||||
|
/// Liked hearts use the primary accent.
|
||||||
|
static const favorite = blue;
|
||||||
|
|
||||||
/// Text placed on [gold].
|
/// Text placed on [gold].
|
||||||
static const onGold = Color(0xFF2A1810);
|
static const onGold = Color(0xFF2A1810);
|
||||||
@@ -30,23 +35,46 @@ abstract final class AppColors {
|
|||||||
static const onPrimary = Color(0xFFFFFFFF);
|
static const onPrimary = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
/// Page background.
|
/// Page background.
|
||||||
static const ink = Color(0xFF100C0A);
|
static const ink = Color(0xFF0B0C10);
|
||||||
|
|
||||||
/// Bottom bar.
|
/// Bottom navigation background (rendered at 95% with blur).
|
||||||
static const bar = Color(0xFF1A1412);
|
static const bar = Color(0xFF0D0D11);
|
||||||
|
|
||||||
/// Cards lifted off [ink].
|
/// Cards lifted off [ink].
|
||||||
static const card = Color(0xFF241C19);
|
static const card = Color(0xFF1B1C21);
|
||||||
|
|
||||||
/// Chips and nested rows.
|
/// Alternate card surface from the mobile preview.
|
||||||
static const raised = Color(0xFF322824);
|
static const cardAlt = Color(0xFF1C1D21);
|
||||||
|
|
||||||
/// Primary text on dark surfaces.
|
/// Floating surfaces, inputs, and nested rows.
|
||||||
static const cream = Color(0xFFF8F1EA);
|
static const raised = Color(0xFF1C1D21);
|
||||||
|
static const quick = Color(0xFF202126);
|
||||||
|
static const input = Color(0xFF25252A);
|
||||||
|
static const goldCard = Color(0xFF201B1E);
|
||||||
|
|
||||||
static const muted = Color(0xFFB7A59C);
|
/// Body text on dark surfaces.
|
||||||
static const outlineDark = Color(0xFF4A3C36);
|
static const cream = Color(0xFFE6E1E3);
|
||||||
static const outlineVariantDark = Color(0xFF2A221F);
|
|
||||||
static const outlineLight = Color(0xFF8C7A72);
|
/// Titles and high-emphasis text.
|
||||||
static const outlineVariantLight = Color(0xFFE4D5CC);
|
static const onSurface = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
|
/// Secondary text.
|
||||||
|
static const muted = Color(0xFFD2CDD0);
|
||||||
|
|
||||||
|
/// Weak hints and timestamps.
|
||||||
|
static const subtle = Color(0xFF9A96A3);
|
||||||
|
|
||||||
|
/// Soft orange used for continue labels and secondary times.
|
||||||
|
static const accent = Color(0xFFFFA490);
|
||||||
|
|
||||||
|
/// 8% white border used by cards and inputs.
|
||||||
|
static const border = Color(0x14FFFFFF);
|
||||||
|
|
||||||
|
/// 14% white border used by glass controls.
|
||||||
|
static const glassBorder = Color(0x24FFFFFF);
|
||||||
|
|
||||||
|
static const outlineDark = Color(0xFF2A2C36);
|
||||||
|
static const outlineVariantDark = Color(0xFF242329);
|
||||||
|
static const outlineLight = Color(0xFF8E8E93);
|
||||||
|
static const outlineVariantLight = Color(0xFFE4E4E7);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
/// K20 cross-page spacing, type, radius, and control sizes.
|
/// Cross-page spacing, type, radius, and control sizes.
|
||||||
abstract final class AppDimens {
|
abstract final class AppDimens {
|
||||||
static const double spaceXs = 4;
|
static const double spaceXs = 4;
|
||||||
static const double spaceSm = 8;
|
static const double spaceSm = 8;
|
||||||
@@ -9,12 +9,17 @@ abstract final class AppDimens {
|
|||||||
static const double spaceXl = 24;
|
static const double spaceXl = 24;
|
||||||
|
|
||||||
static const double pageHorizontal = 16;
|
static const double pageHorizontal = 16;
|
||||||
static const double cardRadius = 16;
|
static const double cardRadius = 12;
|
||||||
static const double authCardRadius = 20;
|
static const double authCardRadius = 20;
|
||||||
static const double cardPadding = 16;
|
static const double cardPadding = 16;
|
||||||
static const double innerRadius = 12;
|
static const double innerRadius = 10;
|
||||||
static const double innerPadding = 12;
|
static const double innerPadding = 12;
|
||||||
static const double pillRadius = 20;
|
static const double pillRadius = 20;
|
||||||
|
static const double glassSmallRadius = 12;
|
||||||
|
static const double glassRadius = 14;
|
||||||
|
static const double glassCardRadius = 14;
|
||||||
|
static const double buttonRadius = 9;
|
||||||
|
static const double coverRadius = 10;
|
||||||
|
|
||||||
static const double caption = 12;
|
static const double caption = 12;
|
||||||
static const double emptyStateSubtitle = 12.5;
|
static const double emptyStateSubtitle = 12.5;
|
||||||
@@ -24,16 +29,29 @@ abstract final class AppDimens {
|
|||||||
static const double sectionTitle = 16;
|
static const double sectionTitle = 16;
|
||||||
static const double pageTitle = 20;
|
static const double pageTitle = 20;
|
||||||
static const double detailHeadline = 24;
|
static const double detailHeadline = 24;
|
||||||
static const double navLabel = 11;
|
static const double detailHeadlineSmall = 18;
|
||||||
|
static const double previewCardTitle = 13;
|
||||||
|
static const double previewSectionTitle = 14;
|
||||||
|
static const double chipFont = 10;
|
||||||
|
static const double navLabel = 9;
|
||||||
|
|
||||||
static const double buttonHeight = 48;
|
static const double buttonHeight = 48;
|
||||||
static const double primaryButtonHeight = 52;
|
static const double primaryButtonHeight = 52;
|
||||||
|
static const double buttonCompact = 32;
|
||||||
|
static const double buttonSmall = 34;
|
||||||
|
static const double buttonMedium = 40;
|
||||||
|
static const double buttonLarge = 44;
|
||||||
static const double tapTarget = 44;
|
static const double tapTarget = 44;
|
||||||
|
|
||||||
static const double navHeight = 64;
|
static const double navHeight = 54;
|
||||||
static const double navIcon = 22;
|
static const double navIcon = 17;
|
||||||
static const double progressHeight = 4;
|
static const double progressHeight = 3;
|
||||||
static const double progressRadius = 4;
|
static const double progressRadius = 2;
|
||||||
|
static const double progressLineHeight = 3;
|
||||||
|
|
||||||
|
static const double searchHeight = 44;
|
||||||
|
static const double navDot = 3;
|
||||||
|
static const double glassBlur = 24;
|
||||||
|
|
||||||
static const double accountCardHeight = 64;
|
static const double accountCardHeight = 64;
|
||||||
static const double accountIcon = 18;
|
static const double accountIcon = 18;
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import 'package:flutter/services.dart';
|
|||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
import 'app_dimens.dart';
|
import 'app_dimens.dart';
|
||||||
|
|
||||||
/// Light paper and warm-cocoa themes for the Indonesia evening palette.
|
/// Obsidian Flare themes. Dark is the product default; light mirrors the same
|
||||||
|
/// orange primary for system-light devices.
|
||||||
abstract final class AppTheme {
|
abstract final class AppTheme {
|
||||||
static const _lightBackground = Color(0xFFFFF6F1);
|
static const _lightBackground = Color(0xFFF7F7F8);
|
||||||
static const _darkBackground = AppColors.ink;
|
static const _darkBackground = AppColors.ink;
|
||||||
|
|
||||||
/// Light theme for system light mode.
|
/// Light theme for system light mode.
|
||||||
@@ -22,7 +23,7 @@ abstract final class AppTheme {
|
|||||||
static ThemeData dark() => _build(
|
static ThemeData dark() => _build(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
surface: _darkBackground,
|
surface: _darkBackground,
|
||||||
accent: AppColors.blueSoft,
|
accent: AppColors.blue,
|
||||||
);
|
);
|
||||||
|
|
||||||
static ThemeData _build({
|
static ThemeData _build({
|
||||||
@@ -35,27 +36,27 @@ abstract final class AppTheme {
|
|||||||
? const ColorScheme.dark(
|
? const ColorScheme.dark(
|
||||||
primary: AppColors.blue,
|
primary: AppColors.blue,
|
||||||
onPrimary: AppColors.onPrimary,
|
onPrimary: AppColors.onPrimary,
|
||||||
secondary: AppColors.blueSoft,
|
secondary: AppColors.secondary,
|
||||||
onSecondary: AppColors.ink,
|
onSecondary: AppColors.onPrimary,
|
||||||
primaryContainer: AppColors.bluePressed,
|
primaryContainer: AppColors.bluePressed,
|
||||||
onPrimaryContainer: AppColors.onPrimary,
|
onPrimaryContainer: AppColors.onPrimary,
|
||||||
secondaryContainer: AppColors.bluePressed,
|
secondaryContainer: AppColors.raised,
|
||||||
onSecondaryContainer: AppColors.onPrimary,
|
onSecondaryContainer: AppColors.onPrimary,
|
||||||
surface: _darkBackground,
|
surface: _darkBackground,
|
||||||
onSurface: AppColors.cream,
|
onSurface: AppColors.onSurface,
|
||||||
outline: AppColors.outlineDark,
|
outline: AppColors.outlineDark,
|
||||||
outlineVariant: AppColors.outlineVariantDark,
|
outlineVariant: AppColors.outlineVariantDark,
|
||||||
surfaceTint: AppColors.blueSoft,
|
surfaceTint: AppColors.blue,
|
||||||
inversePrimary: AppColors.blueSoft,
|
inversePrimary: AppColors.blueSoft,
|
||||||
)
|
)
|
||||||
: ColorScheme.light(
|
: ColorScheme.light(
|
||||||
primary: AppColors.blue,
|
primary: AppColors.blue,
|
||||||
onPrimary: AppColors.onPrimary,
|
onPrimary: AppColors.onPrimary,
|
||||||
secondary: AppColors.blue,
|
secondary: AppColors.secondary,
|
||||||
onSecondary: AppColors.onPrimary,
|
onSecondary: AppColors.onPrimary,
|
||||||
primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
|
primaryContainer: AppColors.blue.withValues(alpha: 0.14),
|
||||||
onPrimaryContainer: AppColors.ink,
|
onPrimaryContainer: AppColors.ink,
|
||||||
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
|
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14),
|
||||||
onSecondaryContainer: AppColors.ink,
|
onSecondaryContainer: AppColors.ink,
|
||||||
surface: _lightBackground,
|
surface: _lightBackground,
|
||||||
onSurface: AppColors.ink,
|
onSurface: AppColors.ink,
|
||||||
@@ -64,11 +65,12 @@ abstract final class AppTheme {
|
|||||||
surfaceTint: AppColors.blue,
|
surfaceTint: AppColors.blue,
|
||||||
inversePrimary: AppColors.blueSoft,
|
inversePrimary: AppColors.blueSoft,
|
||||||
);
|
);
|
||||||
return ThemeData(
|
final base = ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
colorScheme: scheme,
|
colorScheme: scheme,
|
||||||
scaffoldBackgroundColor: surface,
|
scaffoldBackgroundColor: surface,
|
||||||
|
fontFamily: 'PlusJakartaSans',
|
||||||
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
|
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
|
||||||
sliderTheme: SliderThemeData(
|
sliderTheme: SliderThemeData(
|
||||||
activeTrackColor: accent,
|
activeTrackColor: accent,
|
||||||
@@ -105,7 +107,8 @@ abstract final class AppTheme {
|
|||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||||
titleTextStyle: TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
color: AppColors.cream,
|
color: AppColors.onSurface,
|
||||||
|
fontFamily: 'Outfit',
|
||||||
fontSize: AppDimens.pageTitle,
|
fontSize: AppDimens.pageTitle,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
@@ -125,8 +128,29 @@ abstract final class AppTheme {
|
|||||||
contentTextStyle: const TextStyle(color: AppColors.onPrimary),
|
contentTextStyle: const TextStyle(color: AppColors.onPrimary),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final textTheme = base.textTheme
|
||||||
|
.apply(
|
||||||
|
fontFamily: 'PlusJakartaSans',
|
||||||
|
bodyColor: dark ? AppColors.cream : AppColors.ink,
|
||||||
|
displayColor: dark ? AppColors.onSurface : AppColors.ink,
|
||||||
|
)
|
||||||
|
.copyWith(
|
||||||
|
displayLarge: _display(base.textTheme.displayLarge, dark),
|
||||||
|
displayMedium: _display(base.textTheme.displayMedium, dark),
|
||||||
|
displaySmall: _display(base.textTheme.displaySmall, dark),
|
||||||
|
headlineLarge: _display(base.textTheme.headlineLarge, dark),
|
||||||
|
headlineMedium: _display(base.textTheme.headlineMedium, dark),
|
||||||
|
headlineSmall: _display(base.textTheme.headlineSmall, dark),
|
||||||
|
titleLarge: _display(base.textTheme.titleLarge, dark),
|
||||||
|
);
|
||||||
|
return base.copyWith(textTheme: textTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TextStyle? _display(TextStyle? style, bool dark) => style?.copyWith(
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: dark ? AppColors.onSurface : AppColors.ink,
|
||||||
|
);
|
||||||
|
|
||||||
static Color _buttonColor(Set<WidgetState> states) {
|
static Color _buttonColor(Set<WidgetState> states) {
|
||||||
if (states.contains(WidgetState.pressed) ||
|
if (states.contains(WidgetState.pressed) ||
|
||||||
states.contains(WidgetState.hovered) ||
|
states.contains(WidgetState.hovered) ||
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
@@ -24,7 +26,7 @@ class AppBottomNavItem {
|
|||||||
final bool selected;
|
final bool selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared bottom navigation used by all root tabs and the player fallback.
|
/// Shared 3-tab navigation used by all root tabs and the player fallback.
|
||||||
class AppBottomNav extends StatelessWidget {
|
class AppBottomNav extends StatelessWidget {
|
||||||
const AppBottomNav({
|
const AppBottomNav({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -38,20 +40,25 @@ class AppBottomNav extends StatelessWidget {
|
|||||||
final Key? barKey;
|
final Key? barKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => SafeArea(
|
Widget build(BuildContext context) => ClipRect(
|
||||||
top: false,
|
child: BackdropFilter(
|
||||||
child: Container(
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||||
key: barKey,
|
child: SafeArea(
|
||||||
height: AppDimens.navHeight,
|
top: false,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: backgroundColor,
|
key: barKey,
|
||||||
border: const Border(
|
height: AppDimens.navHeight,
|
||||||
top: BorderSide(color: AppColors.outlineVariantDark),
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor.withValues(alpha: 0.95),
|
||||||
|
border: const Border(
|
||||||
|
top: BorderSide(color: AppColors.outlineVariantDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [for (final item in items) _NavDestination(item: item)],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
|
||||||
children: [for (final item in items) _NavDestination(item: item)],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,25 +78,20 @@ class _NavDestination extends StatelessWidget {
|
|||||||
key: item.tabKey,
|
key: item.tabKey,
|
||||||
onTap: item.onTap,
|
onTap: item.onTap,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
constraints: const BoxConstraints(minHeight: 48),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
AnimatedScale(
|
||||||
duration: const Duration(milliseconds: 160),
|
duration: const Duration(milliseconds: 160),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
padding: const EdgeInsets.symmetric(
|
scale: item.selected ? 1 : 0.96,
|
||||||
horizontal: 14,
|
|
||||||
vertical: 3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: item.selected ? AppColors.blue : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
|
||||||
),
|
|
||||||
child: Icon(
|
child: Icon(
|
||||||
item.selected ? item.selectedIcon : item.icon,
|
item.selected ? item.selectedIcon : item.icon,
|
||||||
size: AppDimens.navIcon,
|
size: AppDimens.navIcon,
|
||||||
color: item.selected ? AppColors.onPrimary : AppColors.muted,
|
color: item.selected
|
||||||
|
? AppColors.blue
|
||||||
|
: Colors.white.withValues(alpha: 0.45),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -100,7 +102,10 @@ class _NavDestination extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppDimens.navLabel,
|
fontSize: AppDimens.navLabel,
|
||||||
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
|
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
color: item.selected ? AppColors.blueSoft : AppColors.muted,
|
color: item.selected
|
||||||
|
? AppColors.blue
|
||||||
|
: Colors.white.withValues(alpha: 0.45),
|
||||||
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../theme/app_dimens.dart';
|
||||||
|
|
||||||
|
/// Dark glass surface used by the home info card and floating controls.
|
||||||
|
class GlassPanel extends StatelessWidget {
|
||||||
|
const GlassPanel({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.radius = AppDimens.glassRadius,
|
||||||
|
this.blur = 10,
|
||||||
|
this.padding,
|
||||||
|
this.borderColor = AppColors.glassBorder,
|
||||||
|
this.gradient,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final double radius;
|
||||||
|
final double blur;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
final Color borderColor;
|
||||||
|
final Gradient? gradient;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final borderRadius = BorderRadius.circular(radius);
|
||||||
|
final surface = DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
border: Border.all(color: borderColor),
|
||||||
|
gradient:
|
||||||
|
gradient ??
|
||||||
|
const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0x24FFFFFF), Color(0x0DFFFFFF)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(padding: padding ?? EdgeInsets.zero, child: child),
|
||||||
|
);
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: surface,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-5
@@ -15,6 +15,14 @@
|
|||||||
"discoverTab": "Discover",
|
"discoverTab": "Discover",
|
||||||
"searchDiscover": "Search dramas, actors, genres...",
|
"searchDiscover": "Search dramas, actors, genres...",
|
||||||
"featured": "Featured",
|
"featured": "Featured",
|
||||||
|
"recommended": "Recommended",
|
||||||
|
"fruitShortsBrand": "FruitShorts",
|
||||||
|
"authLandingSubtitle": "Sign in to sync progress, favorites and coins",
|
||||||
|
"comingSoon": "Coming soon",
|
||||||
|
"authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.",
|
||||||
|
"authContinueGoogle": "Continue with Google",
|
||||||
|
"authPhoneSignIn": "Phone sign in",
|
||||||
|
"authEmailSignIn": "Email sign in",
|
||||||
"suspense": "Suspense",
|
"suspense": "Suspense",
|
||||||
"romance": "Romance",
|
"romance": "Romance",
|
||||||
"actionGenre": "Action",
|
"actionGenre": "Action",
|
||||||
@@ -23,6 +31,30 @@
|
|||||||
"newReleases": "New Releases",
|
"newReleases": "New Releases",
|
||||||
"trendingMicroDramas": "Trending Micro-Dramas",
|
"trendingMicroDramas": "Trending Micro-Dramas",
|
||||||
"seeAll": "See All",
|
"seeAll": "See All",
|
||||||
|
"trendingSubtitle": "Most binge-watched this week",
|
||||||
|
"verticalMiniSeries": "VERTICAL MINI-SERIES",
|
||||||
|
"followSeries": "Follow Series",
|
||||||
|
"seasonOneVerticalCinema": "Season 1 • Vertical Cinema",
|
||||||
|
"seasonOneOriginalRelease": "Season 1 • Original Release",
|
||||||
|
"startWatching": "Start Watching",
|
||||||
|
"showLess": "Less",
|
||||||
|
"views": "views",
|
||||||
|
"episodeOfTotal": "Episode {current} / {total}",
|
||||||
|
"@episodeOfTotal": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": {"type": "int"},
|
||||||
|
"total": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clearScreen": "Clear screen",
|
||||||
|
"exitClearScreen": "Exit clear screen",
|
||||||
|
"playbackSpeed": "Playback speed",
|
||||||
|
"minutesAgo": "{count} min ago",
|
||||||
|
"@minutesAgo": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"artworkOnly": "Artwork",
|
"artworkOnly": "Artwork",
|
||||||
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
||||||
"videoPending": "Video pending",
|
"videoPending": "Video pending",
|
||||||
@@ -44,7 +76,7 @@
|
|||||||
},
|
},
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"watchlist": "Watchlist",
|
"watchlist": "Favorites",
|
||||||
"likedTab": "Liked",
|
"likedTab": "Liked",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"inProgress": "In Progress",
|
"inProgress": "In Progress",
|
||||||
@@ -113,8 +145,6 @@
|
|||||||
"reportFailed": "Could not submit report.",
|
"reportFailed": "Could not submit report.",
|
||||||
"shareFailed": "Could not open the share sheet.",
|
"shareFailed": "Could not open the share sheet.",
|
||||||
"nowPlaying": "Playing now",
|
"nowPlaying": "Playing now",
|
||||||
"watchFullscreen": "Watch in landscape",
|
|
||||||
"exitFullscreen": "Exit fullscreen",
|
|
||||||
"pictureInPicture": "Picture in picture",
|
"pictureInPicture": "Picture in picture",
|
||||||
"pipUnavailable": "Picture in picture is unavailable on this device",
|
"pipUnavailable": "Picture in picture is unavailable on this device",
|
||||||
"originalTag": "Original",
|
"originalTag": "Original",
|
||||||
@@ -243,8 +273,6 @@
|
|||||||
"myListTab": "My List",
|
"myListTab": "My List",
|
||||||
"watchFull": "Watch Full",
|
"watchFull": "Watch Full",
|
||||||
"swipeForNext": "Swipe up for next",
|
"swipeForNext": "Swipe up for next",
|
||||||
"clearScreen": "Clear screen",
|
|
||||||
"exitClearScreen": "Exit clear screen",
|
|
||||||
"logIn": "Log in",
|
"logIn": "Log in",
|
||||||
"logInHint": "Keep your coins and list",
|
"logInHint": "Keep your coins and list",
|
||||||
"rewards": "Rewards",
|
"rewards": "Rewards",
|
||||||
|
|||||||
+33
-5
@@ -12,9 +12,17 @@
|
|||||||
"openQuickMenu": "Buka menu cepat",
|
"openQuickMenu": "Buka menu cepat",
|
||||||
"quickAccess": "Akses cepat",
|
"quickAccess": "Akses cepat",
|
||||||
"currentSeries": "Serial saat ini",
|
"currentSeries": "Serial saat ini",
|
||||||
"discoverTab": "Jelajahi",
|
"discoverTab": "Temukan",
|
||||||
"searchDiscover": "Cari drama, aktor, genre...",
|
"searchDiscover": "Cari drama, aktor, genre...",
|
||||||
"featured": "Unggulan",
|
"featured": "Unggulan",
|
||||||
|
"recommended": "Rekomendasi",
|
||||||
|
"fruitShortsBrand": "FruitShorts",
|
||||||
|
"authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin",
|
||||||
|
"comingSoon": "Segera hadir",
|
||||||
|
"authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.",
|
||||||
|
"authContinueGoogle": "Lanjutkan dengan Google",
|
||||||
|
"authPhoneSignIn": "Masuk dengan ponsel",
|
||||||
|
"authEmailSignIn": "Masuk dengan email",
|
||||||
"suspense": "Thriller",
|
"suspense": "Thriller",
|
||||||
"romance": "Romantis",
|
"romance": "Romantis",
|
||||||
"actionGenre": "Aksi",
|
"actionGenre": "Aksi",
|
||||||
@@ -23,6 +31,30 @@
|
|||||||
"newReleases": "Rilisan Baru",
|
"newReleases": "Rilisan Baru",
|
||||||
"trendingMicroDramas": "Drama Mini Populer",
|
"trendingMicroDramas": "Drama Mini Populer",
|
||||||
"seeAll": "Lihat Semua",
|
"seeAll": "Lihat Semua",
|
||||||
|
"trendingSubtitle": "Paling banyak ditonton minggu ini",
|
||||||
|
"verticalMiniSeries": "SERIAL MINI VERTIKAL",
|
||||||
|
"followSeries": "Ikuti Series",
|
||||||
|
"seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal",
|
||||||
|
"seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal",
|
||||||
|
"startWatching": "Mulai Menonton",
|
||||||
|
"showLess": "Lebih sedikit",
|
||||||
|
"views": "tayangan",
|
||||||
|
"episodeOfTotal": "Episode {current} / {total}",
|
||||||
|
"@episodeOfTotal": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": {"type": "int"},
|
||||||
|
"total": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clearScreen": "Bersihkan layar",
|
||||||
|
"exitClearScreen": "Keluar dari layar bersih",
|
||||||
|
"playbackSpeed": "Kecepatan putar",
|
||||||
|
"minutesAgo": "{count} mnt lalu",
|
||||||
|
"@minutesAgo": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"artworkOnly": "Gambar",
|
"artworkOnly": "Gambar",
|
||||||
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
||||||
"videoPending": "Video belum siap",
|
"videoPending": "Video belum siap",
|
||||||
@@ -113,8 +145,6 @@
|
|||||||
"reportFailed": "Laporan tidak dapat dikirim.",
|
"reportFailed": "Laporan tidak dapat dikirim.",
|
||||||
"shareFailed": "Tidak bisa membuka menu berbagi.",
|
"shareFailed": "Tidak bisa membuka menu berbagi.",
|
||||||
"nowPlaying": "Sedang diputar",
|
"nowPlaying": "Sedang diputar",
|
||||||
"watchFullscreen": "Tonton mode lanskap",
|
|
||||||
"exitFullscreen": "Keluar dari layar penuh",
|
|
||||||
"pictureInPicture": "Gambar dalam gambar",
|
"pictureInPicture": "Gambar dalam gambar",
|
||||||
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
|
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
|
||||||
"originalTag": "Original",
|
"originalTag": "Original",
|
||||||
@@ -243,8 +273,6 @@
|
|||||||
"myListTab": "Daftar Saya",
|
"myListTab": "Daftar Saya",
|
||||||
"watchFull": "Tonton Penuh",
|
"watchFull": "Tonton Penuh",
|
||||||
"swipeForNext": "Geser ke atas untuk lanjut",
|
"swipeForNext": "Geser ke atas untuk lanjut",
|
||||||
"clearScreen": "Bersihkan layar",
|
|
||||||
"exitClearScreen": "Keluar dari mode bersih",
|
|
||||||
"logIn": "Masuk",
|
"logIn": "Masuk",
|
||||||
"logInHint": "Simpan koin dan daftar kamu",
|
"logInHint": "Simpan koin dan daftar kamu",
|
||||||
"rewards": "Hadiah",
|
"rewards": "Hadiah",
|
||||||
|
|||||||
+127
-25
@@ -190,6 +190,54 @@ abstract class AppLocalizations {
|
|||||||
/// **'Featured'**
|
/// **'Featured'**
|
||||||
String get featured;
|
String get featured;
|
||||||
|
|
||||||
|
/// No description provided for @recommended.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recommended'**
|
||||||
|
String get recommended;
|
||||||
|
|
||||||
|
/// No description provided for @fruitShortsBrand.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'FruitShorts'**
|
||||||
|
String get fruitShortsBrand;
|
||||||
|
|
||||||
|
/// No description provided for @authLandingSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign in to sync progress, favorites and coins'**
|
||||||
|
String get authLandingSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @comingSoon.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Coming soon'**
|
||||||
|
String get comingSoon;
|
||||||
|
|
||||||
|
/// No description provided for @authLegal.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'By continuing, you agree to the Terms of Service and Privacy Policy.'**
|
||||||
|
String get authLegal;
|
||||||
|
|
||||||
|
/// No description provided for @authContinueGoogle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Continue with Google'**
|
||||||
|
String get authContinueGoogle;
|
||||||
|
|
||||||
|
/// No description provided for @authPhoneSignIn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Phone sign in'**
|
||||||
|
String get authPhoneSignIn;
|
||||||
|
|
||||||
|
/// No description provided for @authEmailSignIn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Email sign in'**
|
||||||
|
String get authEmailSignIn;
|
||||||
|
|
||||||
/// No description provided for @suspense.
|
/// No description provided for @suspense.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -238,6 +286,84 @@ abstract class AppLocalizations {
|
|||||||
/// **'See All'**
|
/// **'See All'**
|
||||||
String get seeAll;
|
String get seeAll;
|
||||||
|
|
||||||
|
/// No description provided for @trendingSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Most binge-watched this week'**
|
||||||
|
String get trendingSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @verticalMiniSeries.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'VERTICAL MINI-SERIES'**
|
||||||
|
String get verticalMiniSeries;
|
||||||
|
|
||||||
|
/// No description provided for @followSeries.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Follow Series'**
|
||||||
|
String get followSeries;
|
||||||
|
|
||||||
|
/// No description provided for @seasonOneVerticalCinema.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Season 1 • Vertical Cinema'**
|
||||||
|
String get seasonOneVerticalCinema;
|
||||||
|
|
||||||
|
/// No description provided for @seasonOneOriginalRelease.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Season 1 • Original Release'**
|
||||||
|
String get seasonOneOriginalRelease;
|
||||||
|
|
||||||
|
/// No description provided for @startWatching.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Start Watching'**
|
||||||
|
String get startWatching;
|
||||||
|
|
||||||
|
/// No description provided for @showLess.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Less'**
|
||||||
|
String get showLess;
|
||||||
|
|
||||||
|
/// No description provided for @views.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'views'**
|
||||||
|
String get views;
|
||||||
|
|
||||||
|
/// No description provided for @episodeOfTotal.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Episode {current} / {total}'**
|
||||||
|
String episodeOfTotal(int current, int total);
|
||||||
|
|
||||||
|
/// No description provided for @clearScreen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear screen'**
|
||||||
|
String get clearScreen;
|
||||||
|
|
||||||
|
/// No description provided for @exitClearScreen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Exit clear screen'**
|
||||||
|
String get exitClearScreen;
|
||||||
|
|
||||||
|
/// No description provided for @playbackSpeed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Playback speed'**
|
||||||
|
String get playbackSpeed;
|
||||||
|
|
||||||
|
/// No description provided for @minutesAgo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} min ago'**
|
||||||
|
String minutesAgo(int count);
|
||||||
|
|
||||||
/// No description provided for @artworkOnly.
|
/// No description provided for @artworkOnly.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -325,7 +451,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @watchlist.
|
/// No description provided for @watchlist.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Watchlist'**
|
/// **'Favorites'**
|
||||||
String get watchlist;
|
String get watchlist;
|
||||||
|
|
||||||
/// No description provided for @likedTab.
|
/// No description provided for @likedTab.
|
||||||
@@ -664,18 +790,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Playing now'**
|
/// **'Playing now'**
|
||||||
String get nowPlaying;
|
String get nowPlaying;
|
||||||
|
|
||||||
/// No description provided for @watchFullscreen.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Watch in landscape'**
|
|
||||||
String get watchFullscreen;
|
|
||||||
|
|
||||||
/// No description provided for @exitFullscreen.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Exit fullscreen'**
|
|
||||||
String get exitFullscreen;
|
|
||||||
|
|
||||||
/// No description provided for @pictureInPicture.
|
/// No description provided for @pictureInPicture.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1114,18 +1228,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Swipe up for next'**
|
/// **'Swipe up for next'**
|
||||||
String get swipeForNext;
|
String get swipeForNext;
|
||||||
|
|
||||||
/// No description provided for @clearScreen.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Clear screen'**
|
|
||||||
String get clearScreen;
|
|
||||||
|
|
||||||
/// No description provided for @exitClearScreen.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Exit clear screen'**
|
|
||||||
String get exitClearScreen;
|
|
||||||
|
|
||||||
/// No description provided for @logIn.
|
/// No description provided for @logIn.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -54,6 +54,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get featured => 'Featured';
|
String get featured => 'Featured';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recommended => 'Recommended';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fruitShortsBrand => 'FruitShorts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authLandingSubtitle =>
|
||||||
|
'Sign in to sync progress, favorites and coins';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get comingSoon => 'Coming soon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authLegal =>
|
||||||
|
'By continuing, you agree to the Terms of Service and Privacy Policy.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authContinueGoogle => 'Continue with Google';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authPhoneSignIn => 'Phone sign in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authEmailSignIn => 'Email sign in';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get suspense => 'Suspense';
|
String get suspense => 'Suspense';
|
||||||
|
|
||||||
@@ -78,6 +104,49 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get seeAll => 'See All';
|
String get seeAll => 'See All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trendingSubtitle => 'Most binge-watched this week';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get verticalMiniSeries => 'VERTICAL MINI-SERIES';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get followSeries => 'Follow Series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seasonOneOriginalRelease => 'Season 1 • Original Release';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startWatching => 'Start Watching';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showLess => 'Less';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get views => 'views';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String episodeOfTotal(int current, int total) {
|
||||||
|
return 'Episode $current / $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearScreen => 'Clear screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exitClearScreen => 'Exit clear screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackSpeed => 'Playback speed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String minutesAgo(int count) {
|
||||||
|
return '$count min ago';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artworkOnly => 'Artwork';
|
String get artworkOnly => 'Artwork';
|
||||||
|
|
||||||
@@ -125,7 +194,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get history => 'History';
|
String get history => 'History';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get watchlist => 'Watchlist';
|
String get watchlist => 'Favorites';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get likedTab => 'Liked';
|
String get likedTab => 'Liked';
|
||||||
@@ -300,12 +369,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get nowPlaying => 'Playing now';
|
String get nowPlaying => 'Playing now';
|
||||||
|
|
||||||
@override
|
|
||||||
String get watchFullscreen => 'Watch in landscape';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitFullscreen => 'Exit fullscreen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pictureInPicture => 'Picture in picture';
|
String get pictureInPicture => 'Picture in picture';
|
||||||
|
|
||||||
@@ -544,12 +607,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get swipeForNext => 'Swipe up for next';
|
String get swipeForNext => 'Swipe up for next';
|
||||||
|
|
||||||
@override
|
|
||||||
String get clearScreen => 'Clear screen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitClearScreen => 'Exit clear screen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get logIn => 'Log in';
|
String get logIn => 'Log in';
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get currentSeries => 'Serial saat ini';
|
String get currentSeries => 'Serial saat ini';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discoverTab => 'Jelajahi';
|
String get discoverTab => 'Temukan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchDiscover => 'Cari drama, aktor, genre...';
|
String get searchDiscover => 'Cari drama, aktor, genre...';
|
||||||
@@ -54,6 +54,32 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get featured => 'Unggulan';
|
String get featured => 'Unggulan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recommended => 'Rekomendasi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fruitShortsBrand => 'FruitShorts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authLandingSubtitle =>
|
||||||
|
'Masuk untuk menyinkronkan progres, favorit, dan koin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get comingSoon => 'Segera hadir';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authLegal =>
|
||||||
|
'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authContinueGoogle => 'Lanjutkan dengan Google';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authPhoneSignIn => 'Masuk dengan ponsel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authEmailSignIn => 'Masuk dengan email';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get suspense => 'Thriller';
|
String get suspense => 'Thriller';
|
||||||
|
|
||||||
@@ -78,6 +104,49 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get seeAll => 'Lihat Semua';
|
String get seeAll => 'Lihat Semua';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trendingSubtitle => 'Paling banyak ditonton minggu ini';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get verticalMiniSeries => 'SERIAL MINI VERTIKAL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get followSeries => 'Ikuti Series';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seasonOneOriginalRelease => 'Musim 1 • Rilis Orisinal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startWatching => 'Mulai Menonton';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showLess => 'Lebih sedikit';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get views => 'tayangan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String episodeOfTotal(int current, int total) {
|
||||||
|
return 'Episode $current / $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearScreen => 'Bersihkan layar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exitClearScreen => 'Keluar dari layar bersih';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackSpeed => 'Kecepatan putar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String minutesAgo(int count) {
|
||||||
|
return '$count mnt lalu';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artworkOnly => 'Gambar';
|
String get artworkOnly => 'Gambar';
|
||||||
|
|
||||||
@@ -300,12 +369,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get nowPlaying => 'Sedang diputar';
|
String get nowPlaying => 'Sedang diputar';
|
||||||
|
|
||||||
@override
|
|
||||||
String get watchFullscreen => 'Tonton mode lanskap';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitFullscreen => 'Keluar dari layar penuh';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pictureInPicture => 'Gambar dalam gambar';
|
String get pictureInPicture => 'Gambar dalam gambar';
|
||||||
|
|
||||||
@@ -546,12 +609,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get swipeForNext => 'Geser ke atas untuk lanjut';
|
String get swipeForNext => 'Geser ke atas untuk lanjut';
|
||||||
|
|
||||||
@override
|
|
||||||
String get clearScreen => 'Bersihkan layar';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitClearScreen => 'Keluar dari mode bersih';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get logIn => 'Masuk';
|
String get logIn => 'Masuk';
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get currentSeries => '当前短剧';
|
String get currentSeries => '当前短剧';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discoverTab => '找剧';
|
String get discoverTab => '发现';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchDiscover => '搜索短剧、演员、类型…';
|
String get searchDiscover => '搜索短剧、演员、类型…';
|
||||||
@@ -53,6 +53,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get featured => '精选';
|
String get featured => '精选';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recommended => '推荐';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fruitShortsBrand => '水果短剧';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authLandingSubtitle => '登录后同步进度、收藏与金币权益';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get comingSoon => '即将开放';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authLegal => '继续即代表同意《用户协议》与《隐私政策》';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authContinueGoogle => '使用 Google 继续';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authPhoneSignIn => '手机号登录';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authEmailSignIn => '邮箱登录';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get suspense => '悬疑';
|
String get suspense => '悬疑';
|
||||||
|
|
||||||
@@ -77,6 +101,49 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get seeAll => '查看全部';
|
String get seeAll => '查看全部';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trendingSubtitle => '本周追看最多';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get verticalMiniSeries => '竖屏短剧';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get followSeries => '追剧';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seasonOneOriginalRelease => '第 1 季 · 原创上线';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startWatching => '开始观看';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showLess => '收起';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get views => '次播放';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String episodeOfTotal(int current, int total) {
|
||||||
|
return '第 $current 集 / 共 $total 集';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearScreen => '清屏';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exitClearScreen => '退出清屏';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackSpeed => '倍速';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String minutesAgo(int count) {
|
||||||
|
return '$count 分钟前';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artworkOnly => '海报展示';
|
String get artworkOnly => '海报展示';
|
||||||
|
|
||||||
@@ -122,7 +189,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get history => '历史';
|
String get history => '历史';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get watchlist => '追剧';
|
String get watchlist => '收藏';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get likedTab => '喜欢';
|
String get likedTab => '喜欢';
|
||||||
@@ -296,12 +363,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get nowPlaying => '正在播放';
|
String get nowPlaying => '正在播放';
|
||||||
|
|
||||||
@override
|
|
||||||
String get watchFullscreen => '横屏全屏观看';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitFullscreen => '退出全屏';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pictureInPicture => '画中画';
|
String get pictureInPicture => '画中画';
|
||||||
|
|
||||||
@@ -535,12 +596,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get swipeForNext => '上滑看下一部';
|
String get swipeForNext => '上滑看下一部';
|
||||||
|
|
||||||
@override
|
|
||||||
String get clearScreen => '清屏';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitClearScreen => '退出清屏';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get logIn => '登录';
|
String get logIn => '登录';
|
||||||
|
|
||||||
|
|||||||
+34
-6
@@ -12,9 +12,17 @@
|
|||||||
"openQuickMenu": "打开快捷菜单",
|
"openQuickMenu": "打开快捷菜单",
|
||||||
"quickAccess": "快捷入口",
|
"quickAccess": "快捷入口",
|
||||||
"currentSeries": "当前短剧",
|
"currentSeries": "当前短剧",
|
||||||
"discoverTab": "找剧",
|
"discoverTab": "发现",
|
||||||
"searchDiscover": "搜索短剧、演员、类型…",
|
"searchDiscover": "搜索短剧、演员、类型…",
|
||||||
"featured": "精选",
|
"featured": "精选",
|
||||||
|
"recommended": "推荐",
|
||||||
|
"fruitShortsBrand": "水果短剧",
|
||||||
|
"authLandingSubtitle": "登录后同步进度、收藏与金币权益",
|
||||||
|
"comingSoon": "即将开放",
|
||||||
|
"authLegal": "继续即代表同意《用户协议》与《隐私政策》",
|
||||||
|
"authContinueGoogle": "使用 Google 继续",
|
||||||
|
"authPhoneSignIn": "手机号登录",
|
||||||
|
"authEmailSignIn": "邮箱登录",
|
||||||
"suspense": "悬疑",
|
"suspense": "悬疑",
|
||||||
"romance": "都市爱情",
|
"romance": "都市爱情",
|
||||||
"actionGenre": "动作",
|
"actionGenre": "动作",
|
||||||
@@ -23,6 +31,30 @@
|
|||||||
"newReleases": "新剧",
|
"newReleases": "新剧",
|
||||||
"trendingMicroDramas": "热门短剧",
|
"trendingMicroDramas": "热门短剧",
|
||||||
"seeAll": "查看全部",
|
"seeAll": "查看全部",
|
||||||
|
"trendingSubtitle": "本周追看最多",
|
||||||
|
"verticalMiniSeries": "竖屏短剧",
|
||||||
|
"followSeries": "追剧",
|
||||||
|
"seasonOneVerticalCinema": "第 1 季 · 竖屏影院",
|
||||||
|
"seasonOneOriginalRelease": "第 1 季 · 原创上线",
|
||||||
|
"startWatching": "开始观看",
|
||||||
|
"showLess": "收起",
|
||||||
|
"views": "次播放",
|
||||||
|
"episodeOfTotal": "第 {current} 集 / 共 {total} 集",
|
||||||
|
"@episodeOfTotal": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": {"type": "int"},
|
||||||
|
"total": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clearScreen": "清屏",
|
||||||
|
"exitClearScreen": "退出清屏",
|
||||||
|
"playbackSpeed": "倍速",
|
||||||
|
"minutesAgo": "{count} 分钟前",
|
||||||
|
"@minutesAgo": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"artworkOnly": "海报展示",
|
"artworkOnly": "海报展示",
|
||||||
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
||||||
"videoPending": "视频待提供",
|
"videoPending": "视频待提供",
|
||||||
@@ -44,7 +76,7 @@
|
|||||||
},
|
},
|
||||||
"resume": "继续播放",
|
"resume": "继续播放",
|
||||||
"history": "历史",
|
"history": "历史",
|
||||||
"watchlist": "追剧",
|
"watchlist": "收藏",
|
||||||
"likedTab": "喜欢",
|
"likedTab": "喜欢",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"inProgress": "未看完",
|
"inProgress": "未看完",
|
||||||
@@ -113,8 +145,6 @@
|
|||||||
"reportFailed": "举报提交失败,请重试。",
|
"reportFailed": "举报提交失败,请重试。",
|
||||||
"shareFailed": "无法打开系统分享面板。",
|
"shareFailed": "无法打开系统分享面板。",
|
||||||
"nowPlaying": "正在播放",
|
"nowPlaying": "正在播放",
|
||||||
"watchFullscreen": "横屏全屏观看",
|
|
||||||
"exitFullscreen": "退出全屏",
|
|
||||||
"pictureInPicture": "画中画",
|
"pictureInPicture": "画中画",
|
||||||
"pipUnavailable": "此设备无法使用画中画",
|
"pipUnavailable": "此设备无法使用画中画",
|
||||||
"originalTag": "原创",
|
"originalTag": "原创",
|
||||||
@@ -205,8 +235,6 @@
|
|||||||
"myListTab": "片单",
|
"myListTab": "片单",
|
||||||
"watchFull": "看全集",
|
"watchFull": "看全集",
|
||||||
"swipeForNext": "上滑看下一部",
|
"swipeForNext": "上滑看下一部",
|
||||||
"clearScreen": "清屏",
|
|
||||||
"exitClearScreen": "退出清屏",
|
|
||||||
"logIn": "登录",
|
"logIn": "登录",
|
||||||
"logInHint": "登录后保存金币和片单",
|
"logInHint": "登录后保存金币和片单",
|
||||||
"rewards": "奖励",
|
"rewards": "奖励",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
late final FocusNode _emailFocus;
|
late final FocusNode _emailFocus;
|
||||||
late final FocusNode _passwordFocus;
|
late final FocusNode _passwordFocus;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
bool _emailEntryOpen = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -243,7 +244,13 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
tooltip: MaterialLocalizations.of(
|
tooltip: MaterialLocalizations.of(
|
||||||
context,
|
context,
|
||||||
).backButtonTooltip,
|
).backButtonTooltip,
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () {
|
||||||
|
if (_emailEntryOpen) {
|
||||||
|
setState(() => _emailEntryOpen = false);
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.arrow_back_rounded,
|
Icons.arrow_back_rounded,
|
||||||
color: AppColors.onPrimary,
|
color: AppColors.onPrimary,
|
||||||
@@ -272,16 +279,107 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: _emailEntryOpen
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
? Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
|
children: [
|
||||||
SizedBox(height: compact ? _compactBrandGap : 28),
|
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
|
||||||
_authCard(l10n),
|
SizedBox(height: compact ? _compactBrandGap : 28),
|
||||||
],
|
_authCard(l10n),
|
||||||
),
|
],
|
||||||
|
)
|
||||||
|
: _landing(l10n),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Widget _landing(AppLocalizations l10n) => Column(
|
||||||
|
key: const Key('auth-landing'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
key: const Key('auth-brand-logo'),
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [AppColors.blue, AppColors.secondary],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
l10n.fruitShortsBrand,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
l10n.authLandingSubtitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_AuthEntryButton(
|
||||||
|
key: const Key('auth-entry-google'),
|
||||||
|
label: 'G ${l10n.authContinueGoogle}',
|
||||||
|
background: Colors.white,
|
||||||
|
foreground: const Color(0xFF1A1A1A),
|
||||||
|
leadingColor: const Color(0xFF4285F4),
|
||||||
|
onTap: () => _comingSoon(l10n),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 11),
|
||||||
|
_AuthEntryButton(
|
||||||
|
key: const Key('auth-entry-phone'),
|
||||||
|
label: l10n.authPhoneSignIn,
|
||||||
|
icon: Icons.phone_android_rounded,
|
||||||
|
background: AppColors.input,
|
||||||
|
foreground: AppColors.onSurface,
|
||||||
|
onTap: () => _comingSoon(l10n),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 11),
|
||||||
|
_AuthEntryButton(
|
||||||
|
key: const Key('auth-entry-email'),
|
||||||
|
label: l10n.authEmailSignIn,
|
||||||
|
icon: Icons.mail_outline_rounded,
|
||||||
|
background: AppColors.input,
|
||||||
|
foreground: AppColors.onSurface,
|
||||||
|
onTap: () => setState(() => _emailEntryOpen = true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
l10n.authLegal,
|
||||||
|
key: const Key('auth-legal'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF6B6772),
|
||||||
|
fontSize: 9,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
void _comingSoon(AppLocalizations l10n) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.comingSoon)));
|
||||||
|
}
|
||||||
|
|
||||||
Widget _authCard(AppLocalizations l10n) => _AuthCard(
|
Widget _authCard(AppLocalizations l10n) => _AuthCard(
|
||||||
state: _AuthCardState(
|
state: _AuthCardState(
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
@@ -320,3 +418,57 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
setState(() => _logic.toggleMode());
|
setState(() => _logic.toggleMode());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AuthEntryButton extends StatelessWidget {
|
||||||
|
const _AuthEntryButton({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.background,
|
||||||
|
required this.foreground,
|
||||||
|
required this.onTap,
|
||||||
|
this.icon,
|
||||||
|
this.leadingColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final Color background;
|
||||||
|
final Color foreground;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final IconData? icon;
|
||||||
|
final Color? leadingColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (icon != null)
|
||||||
|
Icon(icon, size: 16, color: leadingColor ?? foreground),
|
||||||
|
if (icon != null) const SizedBox(width: 7),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: foreground,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,5 @@ class DiscoverBinding {
|
|||||||
DiscoverLogic create({
|
DiscoverLogic create({
|
||||||
required Drama drama,
|
required Drama drama,
|
||||||
required WatchHistoryRepository history,
|
required WatchHistoryRepository history,
|
||||||
}) => DiscoverLogic(
|
}) => DiscoverLogic(drama: drama, history: history);
|
||||||
drama: drama,
|
|
||||||
history: history,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/models/watch_progress.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
Drama drama;
|
Drama drama;
|
||||||
late Future<Duration?> position;
|
late Future<Duration?> position;
|
||||||
|
WatchProgress? resumeRecord;
|
||||||
final TextEditingController search = TextEditingController();
|
final TextEditingController search = TextEditingController();
|
||||||
final FocusNode searchFocus = FocusNode();
|
final FocusNode searchFocus = FocusNode();
|
||||||
final ScrollController scroll = ScrollController();
|
final ScrollController scroll = ScrollController();
|
||||||
@@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier {
|
|||||||
.resumePointFor(drama.id)
|
.resumePointFor(drama.id)
|
||||||
.then((point) => point?.position)
|
.then((point) => point?.position)
|
||||||
.catchError((Object _) => null);
|
.catchError((Object _) => null);
|
||||||
|
refreshResume();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshResume() async {
|
||||||
|
try {
|
||||||
|
final recent = await history.recent(limit: 1);
|
||||||
|
if (recent.isEmpty || recent.first.finished) {
|
||||||
|
resumeRecord = null;
|
||||||
|
} else {
|
||||||
|
resumeRecord = recent.first;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
resumeRecord = null;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSearchChanged() => notify();
|
void onSearchChanged() => notify();
|
||||||
|
|
||||||
|
List<Drama> filtered(List<Drama> dramas) {
|
||||||
|
final query = search.text.trim().toLowerCase();
|
||||||
|
if (query.isEmpty) return dramas;
|
||||||
|
return dramas.where((drama) {
|
||||||
|
final haystack = [
|
||||||
|
drama.title,
|
||||||
|
drama.genre,
|
||||||
|
drama.category,
|
||||||
|
drama.synopsis,
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return haystack.contains(query);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Drama? resumeDrama(List<Drama> dramas) {
|
||||||
|
final record = resumeRecord;
|
||||||
|
if (record == null) return null;
|
||||||
|
for (final drama in dramas) {
|
||||||
|
if (drama.id == record.dramaId) return drama;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
||||||
|
|
||||||
void openSearch() {
|
void openSearch() {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/models/home_feed.dart';
|
import '../../core/data/models/watch_progress.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/catalog_controller.dart';
|
|
||||||
import '../../core/data/services/category_controller.dart';
|
import '../../core/data/services/category_controller.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
@@ -18,7 +18,6 @@ import '../../core/widgets/remote_image.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'discover_binding.dart';
|
import 'discover_binding.dart';
|
||||||
import 'discover_logic.dart';
|
import 'discover_logic.dart';
|
||||||
import 'widgets/cover_carousel.dart';
|
|
||||||
|
|
||||||
part 'widgets/home_drama_grid.dart';
|
part 'widgets/home_drama_grid.dart';
|
||||||
|
|
||||||
@@ -70,7 +69,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
drama: widget.drama,
|
drama: widget.drama,
|
||||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||||
);
|
);
|
||||||
if (widget.focusSearch) logic.searchOpen = true;
|
if (widget.focusSearch) {
|
||||||
|
logic.searchOpen = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && widget.active) logic.searchFocus.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -104,14 +108,14 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
Widget _page(BuildContext context) {
|
Widget _page(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final categories = context.watch<CategoryController>();
|
final categories = context.watch<CategoryController>();
|
||||||
final homeBanners = context.watch<CatalogController>().feed.banners;
|
|
||||||
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||||
|
final visible = logic.filtered(categories.visibleDramas);
|
||||||
final content = SafeArea(
|
final content = SafeArea(
|
||||||
bottom: !widget.embedded,
|
bottom: !widget.embedded,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_header(l10n),
|
_header(l10n),
|
||||||
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
|
Expanded(child: _feed(l10n, categories, allDramas, visible)),
|
||||||
if (!widget.embedded) _bottomNav(l10n),
|
if (!widget.embedded) _bottomNav(l10n),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -130,134 +134,216 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
Widget _feed(
|
Widget _feed(
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
CategoryController categories,
|
CategoryController categories,
|
||||||
List<FeedBanner> banners,
|
|
||||||
List<Drama> allDramas,
|
List<Drama> allDramas,
|
||||||
) => CustomScrollView(
|
List<Drama> visible,
|
||||||
controller: logic.scroll,
|
) => RefreshIndicator(
|
||||||
slivers: [
|
key: const Key('discover-refresh'),
|
||||||
SliverToBoxAdapter(
|
color: AppColors.blue,
|
||||||
child: Padding(
|
backgroundColor: AppColors.card,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
onRefresh: () async {
|
||||||
child: ClipRRect(
|
await Future.wait([categories.load(), logic.refreshResume()]);
|
||||||
borderRadius: BorderRadius.circular(18),
|
},
|
||||||
child: _carousel(l10n, banners, allDramas),
|
child: CustomScrollView(
|
||||||
|
controller: logic.scroll,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(child: _search()),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12),
|
||||||
|
child: _categories(l10n, categories),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||||
child: _categories(l10n, categories),
|
child: _continueCard(
|
||||||
),
|
l10n,
|
||||||
),
|
logic.resumeDrama(allDramas)!,
|
||||||
_HomeDramaGrid(
|
logic.resumeRecord!,
|
||||||
l10n: l10n,
|
|
||||||
categories: categories,
|
|
||||||
onOpen: (drama) => logic.openCard(
|
|
||||||
context,
|
|
||||||
drama,
|
|
||||||
embedded: widget.embedded,
|
|
||||||
onDramaSelected: widget.onDramaSelected,
|
|
||||||
onAction: widget.onAction,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _header(AppLocalizations l10n) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const _BrandMark(),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.appName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.cream,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
height: 1.1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
SliverToBoxAdapter(
|
||||||
if (widget.signedIn) ...[
|
child: Padding(
|
||||||
_HeaderPill(
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||||
icon: Icons.monetization_on_rounded,
|
child: _quickActions(l10n, categories),
|
||||||
label: r'$ 0',
|
),
|
||||||
onTap: widget.onCoins,
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 3,
|
||||||
|
height: 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.blue,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.trendingMicroDramas,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: AppDimens.previewSectionTitle,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
categories.selectCode(CategoryController.allCode),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.accent,
|
||||||
|
minimumSize: const Size(44, 44),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${l10n.all} ›',
|
||||||
|
style: const TextStyle(fontSize: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_HomeDramaGrid(
|
||||||
|
l10n: l10n,
|
||||||
|
categories: categories,
|
||||||
|
dramas: visible,
|
||||||
|
searching: logic.search.text.trim().isNotEmpty,
|
||||||
|
onOpen: (drama) => logic.openCard(
|
||||||
|
context,
|
||||||
|
drama,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onDramaSelected: widget.onDramaSelected,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _header(AppLocalizations l10n) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 46,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.blue,
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_HeaderPill(
|
Text(
|
||||||
icon: Icons.card_giftcard_rounded,
|
l10n.discoverTab,
|
||||||
label: l10n.rewards,
|
style: const TextStyle(
|
||||||
onTap: widget.onRewards,
|
fontFamily: 'Outfit',
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
] else
|
const Spacer(),
|
||||||
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
|
_HeaderIcon(
|
||||||
],
|
key: const Key('discover-search-button'),
|
||||||
|
icon: Icons.search_rounded,
|
||||||
|
tooltip: l10n.search,
|
||||||
|
onTap: logic.openSearch,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Semantics(
|
||||||
|
button: true,
|
||||||
|
label: widget.signedIn ? l10n.myTab : l10n.logIn,
|
||||||
|
child: InkWell(
|
||||||
|
key: const Key('discover-profile'),
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF6A5CFF), AppColors.blue],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _carousel(
|
Widget _search() {
|
||||||
AppLocalizations l10n,
|
return Padding(
|
||||||
List<FeedBanner> banners,
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
List<Drama> allDramas,
|
child: SizedBox(
|
||||||
) {
|
height: 36,
|
||||||
final items = banners
|
child: TextField(
|
||||||
.where((banner) => banner.imageUrl.isNotEmpty)
|
key: const Key('discover-search'),
|
||||||
.toList();
|
controller: logic.search,
|
||||||
if (items.isEmpty) return const SizedBox.shrink();
|
focusNode: logic.searchFocus,
|
||||||
return CoverCarousel(
|
onChanged: (_) => logic.onSearchChanged(),
|
||||||
active: widget.active,
|
onTapOutside: (_) => logic.searchFocus.unfocus(),
|
||||||
items: [
|
textInputAction: TextInputAction.search,
|
||||||
for (final banner in items)
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
||||||
CoverItem(
|
decoration: InputDecoration(
|
||||||
title: _bannerTitle(banner, allDramas),
|
isDense: true,
|
||||||
subtitle: '',
|
filled: true,
|
||||||
image: banner.imageUrl,
|
fillColor: AppColors.input,
|
||||||
onOpen: () => _openBanner(banner, allDramas),
|
hintText: AppLocalizations.of(context)!.searchDiscover,
|
||||||
|
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
|
||||||
|
prefixIcon: const Icon(
|
||||||
|
Icons.search_rounded,
|
||||||
|
color: AppColors.subtle,
|
||||||
|
size: 17,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
|
|
||||||
final dramaId = banner.dramaId;
|
|
||||||
if (dramaId == null) return '';
|
|
||||||
for (final drama in allDramas) {
|
|
||||||
if (drama.id == dramaId) return drama.title;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
|
|
||||||
final dramaId = banner.dramaId;
|
|
||||||
if (dramaId == null) return;
|
|
||||||
for (final drama in allDramas) {
|
|
||||||
if (drama.id == dramaId) {
|
|
||||||
logic.openCard(
|
|
||||||
context,
|
|
||||||
drama,
|
|
||||||
embedded: widget.embedded,
|
|
||||||
onDramaSelected: widget.onDramaSelected,
|
|
||||||
onAction: widget.onAction,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _categories(AppLocalizations l10n, CategoryController categories) {
|
Widget _categories(AppLocalizations l10n, CategoryController categories) {
|
||||||
final choices = <({String code, String label})>[
|
final choices = <({String code, String label})>[
|
||||||
(code: CategoryController.allCode, label: l10n.all),
|
(code: CategoryController.allCode, label: l10n.recommended),
|
||||||
(code: CategoryController.hotCode, label: l10n.hot),
|
|
||||||
(code: CategoryController.newCode, label: l10n.newest),
|
|
||||||
for (final category in categories.categories)
|
for (final category in categories.categories)
|
||||||
(code: 'category-${category.id}', label: category.name),
|
(code: 'category-${category.id}', label: category.name),
|
||||||
];
|
];
|
||||||
@@ -265,6 +351,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
key: const Key('home-category-chips'),
|
key: const Key('home-category-chips'),
|
||||||
height: AppDimens.tapTarget,
|
height: AppDimens.tapTarget,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: choices.length,
|
itemCount: choices.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
@@ -281,17 +368,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? AppColors.blue : AppColors.raised,
|
color: selected ? AppColors.blue : const Color(0xFF29272B),
|
||||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? AppColors.blue : Colors.transparent,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
choice.label,
|
choice.label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: selected ? AppColors.onPrimary : AppColors.cream,
|
color: selected ? AppColors.onPrimary : AppColors.muted,
|
||||||
fontSize: AppDimens.body,
|
fontSize: AppDimens.chipFont,
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
height: 1.1,
|
height: 1.1,
|
||||||
),
|
),
|
||||||
@@ -305,6 +395,173 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _continueCard(
|
||||||
|
AppLocalizations l10n,
|
||||||
|
Drama drama,
|
||||||
|
WatchProgress record,
|
||||||
|
) {
|
||||||
|
final progress = record.fraction;
|
||||||
|
return Container(
|
||||||
|
key: const Key('discover-continue-watching'),
|
||||||
|
padding: const EdgeInsets.all(9),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.cardAlt,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 52,
|
||||||
|
height: 66,
|
||||||
|
child: RemoteImage(
|
||||||
|
drama.posterUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
showFallbackIcon: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 9),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.continueWatching,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
drama.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_resumeShort(drama, record),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 7),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress <= 0 ? null : progress,
|
||||||
|
minHeight: 3,
|
||||||
|
backgroundColor: const Color(0xFF474248),
|
||||||
|
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('discover-continue-play'),
|
||||||
|
onPressed: () => logic.openCard(
|
||||||
|
context,
|
||||||
|
drama,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onDramaSelected: widget.onDramaSelected,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.blue,
|
||||||
|
foregroundColor: AppColors.onPrimary,
|
||||||
|
minimumSize: const Size(56, 28),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(l10n.resume),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resumeShort(Drama drama, WatchProgress record) {
|
||||||
|
final label = AppLocalizations.of(context)!;
|
||||||
|
Episode? episode;
|
||||||
|
for (final item in drama.episodes) {
|
||||||
|
if (item.id == record.episodeId) {
|
||||||
|
episode = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (episode == null) return _duration(record.position);
|
||||||
|
return label.episodeOfTotal(episode.number, drama.episodeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _QuickAction(
|
||||||
|
key: const Key('discover-quick-filter'),
|
||||||
|
icon: Icons.tune_rounded,
|
||||||
|
label: l10n.filter,
|
||||||
|
color: AppColors.secondary,
|
||||||
|
onTap: logic.openSearch,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
Expanded(
|
||||||
|
child: _QuickAction(
|
||||||
|
key: const Key('discover-quick-charts'),
|
||||||
|
icon: Icons.local_fire_department_rounded,
|
||||||
|
label: l10n.topCharts,
|
||||||
|
color: AppColors.blue,
|
||||||
|
onTap: () => categories.selectCode(CategoryController.hotCode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
Expanded(
|
||||||
|
child: _QuickAction(
|
||||||
|
key: const Key('discover-quick-new'),
|
||||||
|
icon: Icons.auto_awesome_rounded,
|
||||||
|
label: l10n.newReleases,
|
||||||
|
color: AppColors.gold,
|
||||||
|
onTap: () => categories.selectCode(CategoryController.newCode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
Expanded(
|
||||||
|
child: _QuickAction(
|
||||||
|
key: const Key('discover-quick-watchlist'),
|
||||||
|
icon: Icons.bookmark_border_rounded,
|
||||||
|
label: l10n.watchlist,
|
||||||
|
color: AppColors.success,
|
||||||
|
onTap: () => logic.act(
|
||||||
|
context,
|
||||||
|
DiscoverPageAction.watchlist,
|
||||||
|
embedded: widget.embedded,
|
||||||
|
onAction: widget.onAction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
|
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
|
||||||
items: [
|
items: [
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
@@ -343,72 +600,81 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const _coin = AppColors.gold;
|
class _HeaderIcon extends StatelessWidget {
|
||||||
|
const _HeaderIcon({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
class _BrandMark extends StatelessWidget {
|
final IconData icon;
|
||||||
const _BrandMark();
|
final String tooltip;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return SizedBox(
|
||||||
width: 28,
|
width: 36,
|
||||||
height: 28,
|
height: 36,
|
||||||
alignment: Alignment.center,
|
child: IconButton(
|
||||||
decoration: BoxDecoration(
|
tooltip: tooltip,
|
||||||
color: AppColors.blue,
|
onPressed: onTap,
|
||||||
borderRadius: BorderRadius.circular(8),
|
padding: EdgeInsets.zero,
|
||||||
),
|
style: IconButton.styleFrom(
|
||||||
child: const Icon(
|
backgroundColor: const Color(0xFF2C2D33),
|
||||||
Icons.play_arrow_rounded,
|
foregroundColor: AppColors.onSurface,
|
||||||
color: Colors.white,
|
),
|
||||||
size: 20,
|
icon: Icon(icon, size: 18),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HeaderPill extends StatelessWidget {
|
class _QuickAction extends StatelessWidget {
|
||||||
const _HeaderPill({
|
const _QuickAction({
|
||||||
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
|
required this.color,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onTap;
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Material(
|
||||||
color: AppColors.raised,
|
color: AppColors.quick,
|
||||||
shape: StadiumBorder(
|
borderRadius: BorderRadius.circular(12),
|
||||||
side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)),
|
clipBehavior: Clip.antiAlias,
|
||||||
),
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const StadiumBorder(),
|
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: ConstrainedBox(
|
child: Container(
|
||||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
height: 62,
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 15, color: _coin),
|
Icon(icon, color: color, size: 16),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
maxLines: 1,
|
maxLines: 2,
|
||||||
style: const TextStyle(
|
textAlign: TextAlign.center,
|
||||||
color: AppColors.cream,
|
overflow: TextOverflow.ellipsis,
|
||||||
fontSize: AppDimens.caption,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
color: AppColors.muted,
|
||||||
height: 1.1,
|
fontSize: 9,
|
||||||
),
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.05,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -416,28 +682,8 @@ class _HeaderPill extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginButton extends StatelessWidget {
|
String _duration(Duration value) {
|
||||||
const _LoginButton({required this.label, required this.onTap});
|
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
final String label;
|
return '$minutes:$seconds';
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return TextButton(
|
|
||||||
onPressed: onTap,
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
backgroundColor: AppColors.blue,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
||||||
minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget),
|
|
||||||
shape: const StadiumBorder(),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontSize: AppDimens.body,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(label, maxLines: 1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
|
||||||
import '../../../core/widgets/remote_image.dart';
|
|
||||||
import '../../../l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
const Duration _autoPlayInterval = Duration(milliseconds: 4500);
|
|
||||||
|
|
||||||
class CoverItem {
|
|
||||||
const CoverItem({
|
|
||||||
required this.title,
|
|
||||||
required this.subtitle,
|
|
||||||
required this.image,
|
|
||||||
required this.onOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final String image;
|
|
||||||
final VoidCallback onOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
|
|
||||||
class CoverCarousel extends StatefulWidget {
|
|
||||||
const CoverCarousel({super.key, required this.items, this.active = true});
|
|
||||||
|
|
||||||
final List<CoverItem> items;
|
|
||||||
final bool active;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CoverCarousel> createState() => _CoverCarouselState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CoverCarouselState extends State<CoverCarousel>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final AnimationController _snap;
|
|
||||||
double _page = 0;
|
|
||||||
double _from = 0;
|
|
||||||
double _to = 0;
|
|
||||||
int? _pointer;
|
|
||||||
double _drag = 0;
|
|
||||||
double _dragPage = 0;
|
|
||||||
Timer? _autoTimer;
|
|
||||||
bool _tickerEnabled = true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_snap = AnimationController(vsync: this)..addListener(_onSnap);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_autoTimer?.cancel();
|
|
||||||
_snap.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
final enabled = TickerMode.valuesOf(context).enabled;
|
|
||||||
if (enabled != _tickerEnabled) {
|
|
||||||
_tickerEnabled = enabled;
|
|
||||||
_syncAutoTimer();
|
|
||||||
} else if (_autoTimer == null) {
|
|
||||||
_syncAutoTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant CoverCarousel oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (_page > _last) _page = _last;
|
|
||||||
if (oldWidget.items.length != widget.items.length ||
|
|
||||||
oldWidget.active != widget.active) {
|
|
||||||
_syncAutoTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double get _last => math.max(0, widget.items.length - 1).toDouble();
|
|
||||||
|
|
||||||
void _onSnap() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final t = Curves.easeOutCubic.transform(_snap.value);
|
|
||||||
setState(() => _page = _from + (_to - _from) * t);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _turnTo(double target) {
|
|
||||||
if (!mounted || widget.items.isEmpty) return;
|
|
||||||
_from = _page;
|
|
||||||
_to = target.clamp(0, _last);
|
|
||||||
_snap.duration = const Duration(milliseconds: 420);
|
|
||||||
_snap.forward(from: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncAutoTimer() {
|
|
||||||
_autoTimer?.cancel();
|
|
||||||
_autoTimer = null;
|
|
||||||
if (!mounted ||
|
|
||||||
!widget.active ||
|
|
||||||
!_tickerEnabled ||
|
|
||||||
widget.items.length <= 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_autoTimer = Timer.periodic(_autoPlayInterval, (_) {
|
|
||||||
if (!mounted || _pointer != null || !widget.active || !_tickerEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final current = _page.round().clamp(0, _last.toInt());
|
|
||||||
if (current >= _last.toInt()) {
|
|
||||||
setState(() => _page = 0);
|
|
||||||
} else {
|
|
||||||
_turnTo((current + 1).toDouble());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (widget.items.isEmpty) return const SizedBox.shrink();
|
|
||||||
final current = _page.round().clamp(0, widget.items.length - 1);
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final width = constraints.maxWidth;
|
|
||||||
final height = math.min(width * 0.52, 210).toDouble();
|
|
||||||
return Semantics(
|
|
||||||
container: true,
|
|
||||||
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: height,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(child: _stage(width)),
|
|
||||||
_indicator(l10n, current),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _stage(double width) {
|
|
||||||
return Listener(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onPointerDown: (event) {
|
|
||||||
_autoTimer?.cancel();
|
|
||||||
_autoTimer = null;
|
|
||||||
_snap.stop();
|
|
||||||
_pointer = event.pointer;
|
|
||||||
_drag = 0;
|
|
||||||
_dragPage = _page;
|
|
||||||
},
|
|
||||||
onPointerMove: (event) {
|
|
||||||
if (event.pointer != _pointer) return;
|
|
||||||
_drag += event.delta.dx;
|
|
||||||
setState(() {
|
|
||||||
_page = (_dragPage - _drag / width).clamp(0.0, _last);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onPointerUp: (event) {
|
|
||||||
if (event.pointer != _pointer) return;
|
|
||||||
_pointer = null;
|
|
||||||
_turnTo(_page.roundToDouble());
|
|
||||||
_syncAutoTimer();
|
|
||||||
},
|
|
||||||
onPointerCancel: (event) {
|
|
||||||
if (event.pointer != _pointer) return;
|
|
||||||
_pointer = null;
|
|
||||||
_turnTo(_page.roundToDouble());
|
|
||||||
_syncAutoTimer();
|
|
||||||
},
|
|
||||||
child: ClipRect(
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [for (final index in _visible) _face(index, width)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _indicator(AppLocalizations l10n, int current) {
|
|
||||||
return Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 8,
|
|
||||||
child: Semantics(
|
|
||||||
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
for (var index = 0; index < widget.items.length; index++)
|
|
||||||
Container(
|
|
||||||
key: Key('carousel-dot-$index'),
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: index == current
|
|
||||||
? AppColors.onPrimary
|
|
||||||
: AppColors.muted.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<int> get _visible {
|
|
||||||
final first = _page.floor();
|
|
||||||
final indexes = <int>[first, first + 1];
|
|
||||||
indexes.retainWhere((index) => index >= 0 && index < widget.items.length);
|
|
||||||
indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs()));
|
|
||||||
return indexes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _face(int index, double width) {
|
|
||||||
final delta = index - _page;
|
|
||||||
if (delta <= -1 || delta >= 1) return const SizedBox.shrink();
|
|
||||||
final item = widget.items[index];
|
|
||||||
final angle = delta * math.pi / 2;
|
|
||||||
// Push the face out to the cube surface, spin the whole cube on its
|
|
||||||
// vertical center, then bring that center back to the banner.
|
|
||||||
final face = Matrix4.identity()
|
|
||||||
..translateByDouble(0, 0, -width / 2, 1)
|
|
||||||
..rotateY(angle)
|
|
||||||
..translateByDouble(0, 0, width / 2, 1);
|
|
||||||
final transform = Matrix4.identity()
|
|
||||||
..setEntry(3, 2, 0.0018)
|
|
||||||
..multiply(face);
|
|
||||||
return Positioned.fill(
|
|
||||||
child: Transform(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
filterQuality: FilterQuality.medium,
|
|
||||||
transform: transform,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
if ((_page - index).abs() < 0.08) item.onOpen();
|
|
||||||
},
|
|
||||||
child: _poster(item),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _poster(CoverItem item) {
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
RemoteImage(
|
|
||||||
item.image,
|
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackIconKey: const Key('carousel-image-fallback'),
|
|
||||||
),
|
|
||||||
const DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)],
|
|
||||||
stops: [0, 0.5, 1],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
bottom: 30,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.title,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
height: 1.15,
|
|
||||||
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
item.subtitle,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xF2FFFFFF),
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
height: 1.2,
|
|
||||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
const _HomeDramaGrid({
|
const _HomeDramaGrid({
|
||||||
required this.l10n,
|
required this.l10n,
|
||||||
required this.categories,
|
required this.categories,
|
||||||
|
required this.dramas,
|
||||||
|
required this.searching,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
final CategoryController categories;
|
final CategoryController categories;
|
||||||
|
final List<Drama> dramas;
|
||||||
|
final bool searching;
|
||||||
final ValueChanged<Drama> onOpen;
|
final ValueChanged<Drama> onOpen;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -33,55 +37,65 @@ class _HomeDramaGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dramas = categories.visibleDramas;
|
|
||||||
if (dramas.isEmpty) {
|
if (dramas.isEmpty) {
|
||||||
final missingCategory = categories.selectedSectionMissing;
|
|
||||||
return _fill(
|
return _fill(
|
||||||
EmptyState(
|
EmptyState(
|
||||||
key: const Key('home-all-dramas-empty'),
|
key: const Key('home-all-dramas-empty'),
|
||||||
icon: missingCategory
|
icon: searching
|
||||||
? Icons.grid_view_rounded
|
? Icons.search_off_rounded
|
||||||
: Icons.search_off_rounded,
|
: Icons.movie_filter_outlined,
|
||||||
title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults,
|
title: searching
|
||||||
|
? l10n.noSearchResults
|
||||||
|
: categories.selectedSectionMissing
|
||||||
|
? l10n.noCategoryDramas
|
||||||
|
: l10n.noSearchResults,
|
||||||
subtitle: l10n.emptyHint,
|
subtitle: l10n.emptyHint,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SliverMainAxisGroup(
|
return SliverMainAxisGroup(
|
||||||
slivers: [
|
slivers: [
|
||||||
_dramaGrid(dramas),
|
SliverToBoxAdapter(child: _masonry(dramas)),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _dramaGrid(List<Drama> dramas) => SliverPadding(
|
Widget _masonry(List<Drama> items) {
|
||||||
key: const Key('home-all-dramas-section'),
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
|
key: const Key('home-all-dramas-section'),
|
||||||
sliver: SliverGrid(
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
key: const Key('home-all-dramas-grid'),
|
child: Row(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
key: const Key('home-all-dramas-grid'),
|
||||||
crossAxisCount: 3,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSpacing: 14,
|
children: [
|
||||||
crossAxisSpacing: 10,
|
Expanded(child: _column(items, 0)),
|
||||||
childAspectRatio: 2 / 3,
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: _column(items, 1)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
);
|
||||||
(context, index) => _HomeDramaCard(
|
|
||||||
l10n: l10n,
|
|
||||||
drama: dramas[index],
|
|
||||||
onTap: () => onOpen(dramas[index]),
|
|
||||||
),
|
|
||||||
childCount: dramas.length,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _fill(Widget child) {
|
|
||||||
return SliverFillRemaining(hasScrollBody: false, child: child);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _column(List<Drama> items, int start) {
|
||||||
|
final children = <Widget>[];
|
||||||
|
for (var index = start; index < items.length; index += 2) {
|
||||||
|
children.add(
|
||||||
|
_HomeDramaCard(
|
||||||
|
l10n: l10n,
|
||||||
|
drama: items[index],
|
||||||
|
onTap: () => onOpen(items[index]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (index + 2 < items.length) {
|
||||||
|
children.add(const SizedBox(height: 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Column(children: children);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fill(Widget child) =>
|
||||||
|
SliverFillRemaining(hasScrollBody: false, child: child);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeDramaCard extends StatelessWidget {
|
class _HomeDramaCard extends StatelessWidget {
|
||||||
@@ -98,96 +112,105 @@ class _HomeDramaCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||||
|
final free =
|
||||||
|
drama.freeEpisodes > 0 ||
|
||||||
|
drama.episodes.any((episode) => episode.isFree);
|
||||||
return InkWell(
|
return InkWell(
|
||||||
key: Key('home-drama-card-${drama.id}'),
|
key: Key('home-drama-card-${drama.id}'),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: AspectRatio(
|
child: Column(
|
||||||
key: Key('home-drama-cover-${drama.id}'),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
aspectRatio: 2 / 3,
|
children: [
|
||||||
child: ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
key: Key('home-drama-cover-${drama.id}'),
|
||||||
child: Stack(
|
borderRadius: BorderRadius.circular(10),
|
||||||
fit: StackFit.expand,
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
RemoteImage(
|
_DiscoverCoverImage(path: drama.posterUrl),
|
||||||
drama.posterUrl,
|
if (free)
|
||||||
fit: BoxFit.cover,
|
Positioned(
|
||||||
showFallbackIcon: true,
|
top: 6,
|
||||||
),
|
left: 6,
|
||||||
const DecoratedBox(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0x00000000), Color(0xE6100C0A)],
|
|
||||||
stops: [0.42, 1],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (drama.isHot)
|
|
||||||
Positioned(
|
|
||||||
top: 8,
|
|
||||||
left: 8,
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.blue,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 6,
|
horizontal: 6,
|
||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.blue,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.hot,
|
l10n.free,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.onPrimary,
|
color: AppColors.onPrimary,
|
||||||
fontSize: AppDimens.caption,
|
fontSize: 8,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
height: 1.1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
Positioned(
|
),
|
||||||
left: 10,
|
),
|
||||||
right: 10,
|
const SizedBox(height: 5),
|
||||||
bottom: 10,
|
Text(
|
||||||
child: Column(
|
drama.title,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
key: Key('home-drama-title-${drama.id}'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
maxLines: 1,
|
||||||
children: [
|
overflow: TextOverflow.ellipsis,
|
||||||
Text(
|
style: const TextStyle(
|
||||||
drama.title,
|
color: AppColors.onSurface,
|
||||||
key: Key('home-drama-title-${drama.id}'),
|
fontSize: 11,
|
||||||
maxLines: 2,
|
fontWeight: FontWeight.w800,
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
style: const TextStyle(
|
),
|
||||||
color: AppColors.cream,
|
const SizedBox(height: 2),
|
||||||
fontSize: AppDimens.body,
|
Text(
|
||||||
fontWeight: FontWeight.w800,
|
genre.isEmpty ? l10n.videoPending : genre,
|
||||||
height: 1.15,
|
key: Key('home-drama-genre-${drama.id}'),
|
||||||
),
|
maxLines: 1,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
const SizedBox(height: 4),
|
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||||
Text(
|
),
|
||||||
genre.isEmpty ? l10n.videoPending : genre,
|
],
|
||||||
key: Key('home-drama-genre-${drama.id}'),
|
),
|
||||||
maxLines: 1,
|
);
|
||||||
overflow: TextOverflow.ellipsis,
|
}
|
||||||
style: const TextStyle(
|
}
|
||||||
color: AppColors.cream,
|
|
||||||
fontSize: AppDimens.caption,
|
class _DiscoverCoverImage extends StatelessWidget {
|
||||||
fontWeight: FontWeight.w400,
|
const _DiscoverCoverImage({required this.path});
|
||||||
height: 1.2,
|
|
||||||
),
|
final String path;
|
||||||
),
|
|
||||||
],
|
@override
|
||||||
),
|
Widget build(BuildContext context) {
|
||||||
),
|
final url = AppConfig.mediaUrl(path);
|
||||||
],
|
if (url.isEmpty) return _placeholder();
|
||||||
|
return Image.network(
|
||||||
|
url,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
loadingBuilder: (context, child, progress) {
|
||||||
|
if (progress == null) return child;
|
||||||
|
return _placeholder();
|
||||||
|
},
|
||||||
|
errorBuilder: (_, _, _) => _placeholder(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _placeholder() {
|
||||||
|
return AspectRatio(
|
||||||
|
aspectRatio: 3 / 4,
|
||||||
|
child: ColoredBox(
|
||||||
|
color: AppColors.card,
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.image_not_supported_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
size: 32,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,30 +6,35 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
|
import '../../core/data/repositories/playback_repository.dart';
|
||||||
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/catalog_controller.dart';
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
import '../../core/data/services/interaction_controller.dart';
|
import '../../core/data/services/interaction_controller.dart';
|
||||||
|
import '../../core/data/services/platform_share_service.dart';
|
||||||
import '../../core/formatting/error_text.dart';
|
import '../../core/formatting/error_text.dart';
|
||||||
import '../../core/formatting/localized_content.dart';
|
import '../../core/formatting/localized_content.dart';
|
||||||
import '../../core/platform/haptics.dart';
|
import '../../core/platform/haptics.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/remote_image.dart';
|
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
import '../../core/widgets/download_action.dart';
|
import '../../core/widgets/download_action.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../interaction/widgets/report_dialog.dart';
|
import '../interaction/widgets/report_dialog.dart';
|
||||||
import 'drama_detail_binding.dart';
|
import 'drama_detail_binding.dart';
|
||||||
import 'drama_detail_logic.dart';
|
import 'drama_detail_logic.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
|
||||||
import '../../core/data/repositories/playback_repository.dart';
|
|
||||||
|
|
||||||
const String _reportMenuValue = 'report';
|
const String _menuFavorite = 'favorite';
|
||||||
|
const String _menuDownload = 'download';
|
||||||
|
const String _menuShare = 'share';
|
||||||
|
const String _menuReport = 'report';
|
||||||
|
|
||||||
class DramaDetailPage extends StatefulWidget {
|
class DramaDetailPage extends StatefulWidget {
|
||||||
const DramaDetailPage({super.key, required this.drama});
|
const DramaDetailPage({super.key, required this.drama});
|
||||||
|
|
||||||
final Drama drama;
|
final Drama drama;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,254 +83,430 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
|
|
||||||
Widget _page(BuildContext context) {
|
Widget _page(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
if (logic.loading) {
|
if (logic.loading) return _loading(l10n);
|
||||||
return Scaffold(
|
if (logic.failed) return _failed(l10n);
|
||||||
backgroundColor: AppColors.ink,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: AppColors.ink,
|
|
||||||
foregroundColor: AppColors.onPrimary,
|
|
||||||
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
|
||||||
),
|
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (logic.failed) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: AppColors.ink,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: AppColors.ink,
|
|
||||||
foregroundColor: AppColors.onPrimary,
|
|
||||||
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
errorText(l10n, logic.failureMessage, l10n.detailError),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final drama = logic.drama;
|
final drama = logic.drama;
|
||||||
if (drama.episodes.isEmpty) {
|
if (drama.episodes.isEmpty) return _empty(l10n, drama);
|
||||||
// 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: AppColors.ink,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: AppColors.ink,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
title: Text(localizedDramaTitle(l10n, drama)),
|
|
||||||
),
|
|
||||||
body: EmptyState(
|
|
||||||
key: const Key('drama-detail-empty'),
|
|
||||||
icon: Icons.video_library_outlined,
|
|
||||||
title: l10n.noEpisodesYet,
|
|
||||||
subtitle: l10n.emptyHint,
|
|
||||||
actionLabel: l10n.retry,
|
|
||||||
onAction: _load,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
|
key: const Key('detail-scroll'),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverToBoxAdapter(child: _hero(context, l10n, drama)),
|
||||||
expandedHeight: 300,
|
SliverToBoxAdapter(child: _content(context, l10n, drama)),
|
||||||
pinned: true,
|
const SliverToBoxAdapter(child: SizedBox(height: 28)),
|
||||||
backgroundColor: AppColors.ink,
|
],
|
||||||
foregroundColor: Colors.white,
|
),
|
||||||
actions: [
|
);
|
||||||
_reportAction(context, l10n),
|
}
|
||||||
_downloadAction(context, l10n),
|
|
||||||
_favoriteAction(context, l10n),
|
Widget _loading(AppLocalizations l10n) => Scaffold(
|
||||||
],
|
backgroundColor: AppColors.ink,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
appBar: AppBar(
|
||||||
background: Stack(
|
backgroundColor: AppColors.ink,
|
||||||
fit: StackFit.expand,
|
foregroundColor: AppColors.onPrimary,
|
||||||
children: [
|
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
||||||
RemoteImage(drama.posterUrl),
|
),
|
||||||
const DecoratedBox(
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
decoration: BoxDecoration(
|
);
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
Widget _failed(AppLocalizations l10n) => Scaffold(
|
||||||
end: Alignment.center,
|
backgroundColor: AppColors.ink,
|
||||||
colors: [Color(0x99000000), Color(0x00000000)],
|
appBar: AppBar(
|
||||||
),
|
backgroundColor: AppColors.ink,
|
||||||
),
|
foregroundColor: AppColors.onPrimary,
|
||||||
),
|
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
||||||
],
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
errorText(l10n, logic.failureMessage, l10n.detailError),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppColors.muted),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold(
|
||||||
|
backgroundColor: AppColors.ink,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColors.ink,
|
||||||
|
foregroundColor: AppColors.onPrimary,
|
||||||
|
title: Text(localizedDramaTitle(l10n, drama)),
|
||||||
|
),
|
||||||
|
body: EmptyState(
|
||||||
|
key: const Key('drama-detail-empty'),
|
||||||
|
icon: Icons.video_library_outlined,
|
||||||
|
title: l10n.noEpisodesYet,
|
||||||
|
subtitle: l10n.emptyHint,
|
||||||
|
actionLabel: l10n.retry,
|
||||||
|
onAction: _load,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) {
|
||||||
|
return SizedBox(
|
||||||
|
key: const Key('detail-hero'),
|
||||||
|
height: 190,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
RemoteImage(
|
||||||
|
drama.posterUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
showFallbackIcon: true,
|
||||||
|
),
|
||||||
|
const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x33000000), Color(0xCC0B0C10)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
Positioned(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
|
top: MediaQuery.paddingOf(context).top + 14,
|
||||||
sliver: SliverList(
|
left: 14,
|
||||||
delegate: SliverChildListDelegate([
|
child: _RoundAction(
|
||||||
Text(
|
key: const Key('detail-back'),
|
||||||
l10n.technicalDemoCount(drama.episodes.length),
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
icon: Icons.chevron_left_rounded,
|
||||||
letterSpacing: 1.4,
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
color: AppColors.blueSoft,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
localizedDramaTitle(l10n, drama),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: AppDimens.detailHeadline,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
localizedDramaSynopsis(l10n, drama),
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyLarge?.copyWith(color: AppColors.muted),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: () =>
|
|
||||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
|
||||||
label: Text(
|
|
||||||
logic.continueIndex == null
|
|
||||||
? l10n.watchEpisodeOne
|
|
||||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_continueFraction != null) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_ResumeBar(
|
|
||||||
fraction: _continueFraction!,
|
|
||||||
label: l10n.resumeProgress(
|
|
||||||
_clock(logic.progress!.position),
|
|
||||||
_clock(logic.progress!.duration),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (logic.progressError != null) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
errorText(
|
|
||||||
l10n,
|
|
||||||
logic.progressError,
|
|
||||||
l10n.progressError,
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white60,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => unawaited(logic.loadContinue()),
|
|
||||||
child: Text(l10n.retry),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 30),
|
|
||||||
Text(
|
|
||||||
l10n.episodes,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontSize: AppDimens.sectionTitle,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
Positioned(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 40),
|
top: MediaQuery.paddingOf(context).top + 14,
|
||||||
sliver: SliverList.builder(
|
right: 14,
|
||||||
itemCount: drama.episodes.length,
|
child: _moreMenu(context, l10n),
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final episode = drama.episodes[index];
|
|
||||||
final fraction = logic.progressFractionFor(episode.id);
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: ListTile(
|
|
||||||
minTileHeight: 76,
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: AppColors.blue.withValues(alpha: 0.18),
|
|
||||||
child: Text('${episode.number}'),
|
|
||||||
),
|
|
||||||
title: Text(l10n.episodeLabel(episode.number)),
|
|
||||||
subtitle: fraction == null
|
|
||||||
? null
|
|
||||||
: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: _ResumeBar(fraction: fraction),
|
|
||||||
),
|
|
||||||
trailing: const Icon(Icons.play_circle_outline_rounded),
|
|
||||||
onTap: () => logic.openPlayer(context, index),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
double? get _continueFraction {
|
Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) {
|
||||||
final record = logic.progress;
|
final continueIndex = logic.continueIndex;
|
||||||
if (record == null || !record.hasPosition) return null;
|
return Padding(
|
||||||
if (logic.continueIndex == null) return null;
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
||||||
return record.fraction;
|
child: Column(
|
||||||
}
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
/// 下载当前续看集或第一集;业务判定完全交给服务端。
|
Text(
|
||||||
Widget _downloadAction(BuildContext context, AppLocalizations l10n) {
|
l10n.episodeCount(drama.episodeCount),
|
||||||
final episodes = logic.drama.episodes;
|
key: const Key('detail-episode-count'),
|
||||||
final index = logic.continueIndex ?? 0;
|
style: const TextStyle(
|
||||||
final episode = episodes.isEmpty
|
color: AppColors.secondary,
|
||||||
? null
|
fontSize: 9,
|
||||||
: episodes[index.clamp(0, episodes.length - 1)];
|
fontWeight: FontWeight.w800,
|
||||||
return IconButton(
|
letterSpacing: 1,
|
||||||
key: const Key('detail-download'),
|
),
|
||||||
tooltip: l10n.downloads,
|
),
|
||||||
icon: const Icon(Icons.download_outlined),
|
const SizedBox(height: 7),
|
||||||
onPressed: episode == null
|
Text(
|
||||||
? null
|
localizedDramaTitle(l10n, drama),
|
||||||
: () => requestEpisodeDownload(context, episode.id),
|
key: const Key('detail-title'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
localizedDramaSynopsis(l10n, drama),
|
||||||
|
key: const Key('detail-synopsis'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 40,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
key: const Key('detail-cta'),
|
||||||
|
onPressed: () => logic.openPlayer(context, continueIndex ?? 0),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.blue,
|
||||||
|
foregroundColor: AppColors.onPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppDimens.buttonRadius),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||||
|
label: Text(
|
||||||
|
continueIndex == null
|
||||||
|
? l10n.startWatching
|
||||||
|
: l10n.continueEpisode(continueIndex + 1),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_continueFraction != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_ResumeBar(
|
||||||
|
fraction: _continueFraction!,
|
||||||
|
label: l10n.resumeProgress(
|
||||||
|
_clock(logic.progress!.position),
|
||||||
|
_clock(logic.progress!.duration),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (logic.progressError != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
errorText(l10n, logic.progressError, l10n.progressError),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => unawaited(logic.loadContinue()),
|
||||||
|
child: Text(l10n.retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
l10n.episodes,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final sixColumnsWidth = (constraints.maxWidth - 5 * 8) / 6;
|
||||||
|
final columns = sixColumnsWidth >= 40 ? 6 : 5;
|
||||||
|
final selectedIndex = continueIndex ?? 0;
|
||||||
|
final selectedNumber = drama.episodes.isEmpty
|
||||||
|
? null
|
||||||
|
: drama.episodes[selectedIndex].number;
|
||||||
|
final ordered = [
|
||||||
|
for (var index = 0; index < drama.episodes.length; index++)
|
||||||
|
(index: index, episode: drama.episodes[index]),
|
||||||
|
]..sort((a, b) => a.episode.number.compareTo(b.episode.number));
|
||||||
|
return GridView.builder(
|
||||||
|
key: const Key('detail-episode-chips'),
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: ordered.length,
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: columns,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
childAspectRatio: 1.35,
|
||||||
|
),
|
||||||
|
itemBuilder: (context, position) {
|
||||||
|
final item = ordered[position];
|
||||||
|
return _episodeChip(
|
||||||
|
context,
|
||||||
|
item.episode,
|
||||||
|
item.index,
|
||||||
|
selectedNumber,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 详情页更多菜单:本批只提供举报短剧。
|
Widget _episodeChip(
|
||||||
Widget _reportAction(BuildContext context, AppLocalizations l10n) {
|
BuildContext context,
|
||||||
|
Episode episode,
|
||||||
|
int index,
|
||||||
|
int? selectedNumber,
|
||||||
|
) {
|
||||||
|
final selected = episode.number == selectedNumber;
|
||||||
|
final locked = episode.locked || (!episode.isFree && index > 0);
|
||||||
|
return InkWell(
|
||||||
|
key: Key('detail-episode-chip-${episode.number}'),
|
||||||
|
onTap: () => logic.openPlayer(context, index),
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected
|
||||||
|
? AppColors.blue
|
||||||
|
: locked
|
||||||
|
? AppColors.secondary.withValues(alpha: 0.16)
|
||||||
|
: const Color(0xFF29272B),
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${episode.number}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected
|
||||||
|
? AppColors.onPrimary
|
||||||
|
: locked
|
||||||
|
? AppColors.secondary
|
||||||
|
: AppColors.muted,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _moreMenu(BuildContext context, AppLocalizations l10n) {
|
||||||
|
final saved = context.watch<FavoriteController>().isSaved(logic.drama.id);
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
key: const Key('detail-more'),
|
key: const Key('detail-more'),
|
||||||
tooltip: l10n.more,
|
tooltip: l10n.more,
|
||||||
onSelected: (_) => unawaited(_reportDrama(l10n)),
|
color: AppColors.card,
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case _menuFavorite:
|
||||||
|
unawaited(_toggleFavorite(l10n));
|
||||||
|
case _menuDownload:
|
||||||
|
_downloadCurrent();
|
||||||
|
case _menuShare:
|
||||||
|
unawaited(_shareDrama(l10n, logic.drama));
|
||||||
|
case _menuReport:
|
||||||
|
unawaited(_reportDrama(l10n));
|
||||||
|
}
|
||||||
|
},
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)),
|
PopupMenuItem(
|
||||||
|
key: const Key('detail-favorite'),
|
||||||
|
value: _menuFavorite,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||||
|
color: saved ? AppColors.blue : AppColors.muted,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 9),
|
||||||
|
Text(l10n.favorite),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('detail-download'),
|
||||||
|
value: _menuDownload,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.download_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 9),
|
||||||
|
Text(l10n.downloads),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('detail-share'),
|
||||||
|
value: _menuShare,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.share_outlined,
|
||||||
|
color: AppColors.muted,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 9),
|
||||||
|
Text(l10n.share),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
key: const Key('detail-report'),
|
||||||
|
value: _menuReport,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18),
|
||||||
|
const SizedBox(width: 9),
|
||||||
|
Text(l10n.reportDrama),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0x80000000),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.more_horiz_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _shareDrama(AppLocalizations l10n, Drama drama) async {
|
||||||
|
try {
|
||||||
|
await const PlatformShareService().shareDrama(
|
||||||
|
title: localizedDramaTitle(l10n, drama),
|
||||||
|
synopsis: localizedDramaSynopsis(l10n, drama),
|
||||||
|
footer: l10n.shareFooter,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.shareFailed)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFavorite(AppLocalizations l10n) async {
|
||||||
|
if (!await ensureSignedIn(context)) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
|
final ok = await logic.toggleFavorite();
|
||||||
|
if (ok || !mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadCurrent() {
|
||||||
|
final episodes = logic.drama.episodes;
|
||||||
|
if (episodes.isEmpty) return;
|
||||||
|
final index = (logic.continueIndex ?? 0).clamp(0, episodes.length - 1);
|
||||||
|
requestEpisodeDownload(context, episodes[index].id);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _reportDrama(AppLocalizations l10n) async {
|
Future<void> _reportDrama(AppLocalizations l10n) async {
|
||||||
if (!await ensureSignedIn(context) || !mounted) return;
|
if (!await ensureSignedIn(context) || !mounted) return;
|
||||||
final draft = await showReportDialog(context, title: l10n.reportDrama);
|
final draft = await showReportDialog(context, title: l10n.reportDrama);
|
||||||
@@ -348,25 +529,11 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
|
double? get _continueFraction {
|
||||||
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
|
final record = logic.progress;
|
||||||
final favorites = context.watch<FavoriteController>();
|
if (record == null || !record.hasPosition) return null;
|
||||||
final saved = favorites.isSaved(logic.drama.id);
|
if (logic.continueIndex == null) return null;
|
||||||
return IconButton(
|
return record.fraction;
|
||||||
tooltip: l10n.favorite,
|
|
||||||
icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border),
|
|
||||||
onPressed: () async {
|
|
||||||
if (!await ensureSignedIn(context)) return;
|
|
||||||
if (!context.mounted) return;
|
|
||||||
unawaited(context.read<Haptics>().lightImpact());
|
|
||||||
final ok = await logic.toggleFavorite();
|
|
||||||
if (ok || !context.mounted) return;
|
|
||||||
// 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚)
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _clock(Duration duration) {
|
String _clock(Duration duration) {
|
||||||
@@ -378,7 +545,37 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。
|
class _RoundAction extends StatelessWidget {
|
||||||
|
const _RoundAction({
|
||||||
|
super.key,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.icon,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String tooltip;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0x80000000),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: tooltip.isEmpty ? null : tooltip,
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: Icon(icon, size: 18, color: AppColors.onSurface),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ResumeBar extends StatelessWidget {
|
class _ResumeBar extends StatelessWidget {
|
||||||
const _ResumeBar({required this.fraction, this.label});
|
const _ResumeBar({required this.fraction, this.label});
|
||||||
|
|
||||||
@@ -391,22 +588,19 @@ class _ResumeBar extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(AppDimens.progressRadius),
|
borderRadius: BorderRadius.circular(2),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: fraction,
|
value: fraction,
|
||||||
minHeight: AppDimens.progressHeight,
|
minHeight: 3,
|
||||||
backgroundColor: Colors.white24,
|
backgroundColor: const Color(0xFF474248),
|
||||||
valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft),
|
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (label != null) ...[
|
if (label != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
label!,
|
label!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(color: AppColors.muted, fontSize: 10),
|
||||||
color: AppColors.muted,
|
|
||||||
fontSize: AppDimens.caption,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
|
|
||||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
Drama currentDrama;
|
Drama currentDrama;
|
||||||
int tab = 1;
|
int tab = 0;
|
||||||
int searchRequest = 0;
|
int searchRequest = 0;
|
||||||
int sectionRequest = 0;
|
int sectionRequest = 0;
|
||||||
int resumeRequest = 0;
|
int resumeRequest = 0;
|
||||||
@@ -49,21 +49,20 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void openMenu() {
|
void setMenuOpen(bool value) {
|
||||||
drawerOpen = true;
|
if (drawerOpen == value) return;
|
||||||
|
drawerOpen = value;
|
||||||
notify();
|
notify();
|
||||||
scaffoldKey.currentState?.openDrawer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void selectFromMenu(int nextTab, {int? mySection}) {
|
void selectFromMenu(int nextTab, {int? mySection}) {
|
||||||
scaffoldKey.currentState?.closeDrawer();
|
drawerOpen = false;
|
||||||
select(nextTab, mySection: mySection);
|
select(nextTab, mySection: mySection);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openCurrentSeries(BuildContext context) async {
|
Future<void> openCurrentSeries(BuildContext context) async {
|
||||||
detailOpen = true;
|
detailOpen = true;
|
||||||
notify();
|
notify();
|
||||||
scaffoldKey.currentState?.closeDrawer();
|
|
||||||
await WidgetsBinding.instance.endOfFrame;
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
try {
|
try {
|
||||||
@@ -102,13 +101,11 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
|
|
||||||
void openProfile() {
|
void openProfile() {
|
||||||
profile = true;
|
profile = true;
|
||||||
scaffoldKey.currentState?.closeDrawer();
|
|
||||||
select(2, mySection: 0);
|
select(2, mySection: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void openLibrary({int section = 1}) {
|
void openLibrary({int section = 1}) {
|
||||||
profile = false;
|
profile = false;
|
||||||
scaffoldKey.currentState?.closeDrawer();
|
|
||||||
select(2, mySection: section);
|
select(2, mySection: section);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,24 +136,14 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void onSystemBack() {
|
void onSystemBack() {
|
||||||
if (drawerOpen) {
|
if (videoImmersive) {
|
||||||
scaffoldKey.currentState?.closeDrawer();
|
|
||||||
} else if (videoImmersive) {
|
|
||||||
backRequest++;
|
backRequest++;
|
||||||
notify();
|
notify();
|
||||||
} else if (tab != 1) {
|
} else if (tab != 1) {
|
||||||
select(1);
|
select(1);
|
||||||
} else {
|
|
||||||
openMenu();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onDrawerChanged(bool opened) {
|
|
||||||
if (drawerOpen == opened) return;
|
|
||||||
drawerOpen = opened;
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
void onDramaChanged(Drama drama) {
|
void onDramaChanged(Drama drama) {
|
||||||
currentDrama = drama;
|
currentDrama = drama;
|
||||||
notify();
|
notify();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import '../mine/mine_view.dart';
|
|||||||
import '../player/player_view.dart';
|
import '../player/player_view.dart';
|
||||||
import 'home_binding.dart';
|
import 'home_binding.dart';
|
||||||
import 'home_logic.dart';
|
import 'home_logic.dart';
|
||||||
import 'widgets/home_quick_drawer.dart';
|
import 'widgets/home_menu_sheet.dart';
|
||||||
|
|
||||||
class HomePage extends StatelessWidget {
|
class HomePage extends StatelessWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
@@ -173,21 +173,15 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
logic.onSystemBack();
|
if (!logic.videoImmersive && logic.tab == 1) {
|
||||||
|
unawaited(_openMenu(context));
|
||||||
|
} else {
|
||||||
|
logic.onSystemBack();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: logic.scaffoldKey,
|
key: logic.scaffoldKey,
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
drawer: HomeQuickDrawer(
|
|
||||||
drama: logic.currentDrama,
|
|
||||||
onOpenSeries: () => logic.openCurrentSeries(context),
|
|
||||||
onDiscover: () => logic.selectFromMenu(1),
|
|
||||||
onHistory: () => logic.openLibrary(section: 0),
|
|
||||||
onWatchlist: logic.openLibrary,
|
|
||||||
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
|
||||||
),
|
|
||||||
drawerEnableOpenDragGesture: false,
|
|
||||||
onDrawerChanged: logic.onDrawerChanged,
|
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: logic.tab,
|
index: logic.tab,
|
||||||
children: [
|
children: [
|
||||||
@@ -204,7 +198,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
onDiscover: () => logic.select(1),
|
onDiscover: () => logic.select(1),
|
||||||
onMy: logic.openProfile,
|
onMy: logic.openProfile,
|
||||||
onSearch: () => logic.select(1, search: true),
|
onSearch: () => logic.select(1, search: true),
|
||||||
onMenu: logic.openMenu,
|
onMenu: () => _openMenu(context),
|
||||||
onImmersiveChanged: logic.onImmersiveChanged,
|
onImmersiveChanged: logic.onImmersiveChanged,
|
||||||
),
|
),
|
||||||
DiscoverPage(
|
DiscoverPage(
|
||||||
@@ -243,36 +237,28 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
: AppBottomNav(
|
: AppBottomNav(
|
||||||
barKey: const Key('home-tabs-bar'),
|
barKey: const Key('home-tabs-bar'),
|
||||||
items: [
|
items: [
|
||||||
AppBottomNavItem(
|
|
||||||
tabKey: const Key('tab-discover'),
|
|
||||||
icon: Icons.home_outlined,
|
|
||||||
selectedIcon: Icons.home_rounded,
|
|
||||||
label: l10n.homeTab,
|
|
||||||
selected: logic.tab == 1,
|
|
||||||
onTap: () => _selectTab(context, 1),
|
|
||||||
),
|
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('tab-home'),
|
tabKey: const Key('tab-home'),
|
||||||
icon: Icons.movie_outlined,
|
icon: Icons.play_circle_outline_rounded,
|
||||||
selectedIcon: Icons.movie_rounded,
|
selectedIcon: Icons.play_circle_fill_rounded,
|
||||||
label: l10n.shortsTab,
|
label: l10n.homeTab,
|
||||||
selected: logic.tab == 0,
|
selected: logic.tab == 0,
|
||||||
onTap: () => _selectTab(context, 0),
|
onTap: () => _selectTab(context, 0),
|
||||||
),
|
),
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('tab-my-list'),
|
tabKey: const Key('tab-discover'),
|
||||||
icon: Icons.bookmark_border_rounded,
|
icon: Icons.explore_outlined,
|
||||||
selectedIcon: Icons.bookmark_rounded,
|
selectedIcon: Icons.explore_rounded,
|
||||||
label: l10n.myListTab,
|
label: l10n.discoverTab,
|
||||||
selected: logic.tab == 2 && !logic.profile,
|
selected: logic.tab == 1,
|
||||||
onTap: () => _selectTab(context, 2),
|
onTap: () => _selectTab(context, 1),
|
||||||
),
|
),
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('tab-my'),
|
tabKey: const Key('tab-my'),
|
||||||
icon: Icons.person_outline_rounded,
|
icon: Icons.video_library_outlined,
|
||||||
selectedIcon: Icons.person_rounded,
|
selectedIcon: Icons.video_library_rounded,
|
||||||
label: l10n.myTab,
|
label: l10n.myTab,
|
||||||
selected: logic.tab == 2 && logic.profile,
|
selected: logic.tab == 2,
|
||||||
onTap: () => _selectTab(context, 2, myProfile: true),
|
onTap: () => _selectTab(context, 2, myProfile: true),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -282,9 +268,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
|
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
|
||||||
final same = tab == 2
|
final same = logic.tab == tab;
|
||||||
? logic.tab == 2 && logic.profile == myProfile
|
|
||||||
: logic.tab == tab;
|
|
||||||
if (!same) {
|
if (!same) {
|
||||||
unawaited(context.read<Haptics>().selectionClick());
|
unawaited(context.read<Haptics>().selectionClick());
|
||||||
}
|
}
|
||||||
@@ -294,4 +278,23 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
logic.select(tab);
|
logic.select(tab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openMenu(BuildContext context) async {
|
||||||
|
logic.setMenuOpen(true);
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppColors.card,
|
||||||
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => HomeMenuSheet(
|
||||||
|
drama: logic.currentDrama,
|
||||||
|
onOpenSeries: () => logic.openCurrentSeries(context),
|
||||||
|
onDiscover: () => logic.selectFromMenu(1),
|
||||||
|
onHistory: () => logic.openLibrary(section: 0),
|
||||||
|
onWatchlist: logic.openLibrary,
|
||||||
|
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
logic.setMenuOpen(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../core/config/locale_controller.dart';
|
||||||
|
import '../../../core/data/models/drama.dart';
|
||||||
|
import '../../../core/formatting/localized_content.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/remote_image.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Compact navigation menu opened from the preview home header.
|
||||||
|
class HomeMenuSheet extends StatelessWidget {
|
||||||
|
const HomeMenuSheet({
|
||||||
|
super.key,
|
||||||
|
required this.drama,
|
||||||
|
required this.onOpenSeries,
|
||||||
|
required this.onDiscover,
|
||||||
|
required this.onHistory,
|
||||||
|
required this.onWatchlist,
|
||||||
|
required this.onLiked,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Drama drama;
|
||||||
|
final VoidCallback onOpenSeries;
|
||||||
|
final VoidCallback onDiscover;
|
||||||
|
final VoidCallback onHistory;
|
||||||
|
final VoidCallback onWatchlist;
|
||||||
|
final VoidCallback onLiked;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final languageCode = Localizations.localeOf(context).languageCode;
|
||||||
|
void closeThen(VoidCallback action) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.cardAlt,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: RemoteImage(drama.posterUrl, width: 64, height: 86),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
localizedDramaTitle(l10n, drama),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.episodeCount(drama.episodeCount),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontSize: 9,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 34,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
key: const Key('drawer-open-series'),
|
||||||
|
onPressed: () => closeThen(onOpenSeries),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.blue,
|
||||||
|
foregroundColor: AppColors.onPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.play_arrow_rounded,
|
||||||
|
size: 15,
|
||||||
|
),
|
||||||
|
label: Text(l10n.exploreSeries),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
l10n.quickAccess,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MenuTile(
|
||||||
|
key: const Key('drawer-discover'),
|
||||||
|
icon: Icons.explore_outlined,
|
||||||
|
label: l10n.discoverTab,
|
||||||
|
onTap: () => closeThen(onDiscover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _MenuTile(
|
||||||
|
key: const Key('drawer-history'),
|
||||||
|
icon: Icons.history_rounded,
|
||||||
|
label: l10n.history,
|
||||||
|
onTap: () => closeThen(onHistory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MenuTile(
|
||||||
|
key: const Key('drawer-watchlist'),
|
||||||
|
icon: Icons.bookmark_border_rounded,
|
||||||
|
label: l10n.watchlist,
|
||||||
|
onTap: () => closeThen(onWatchlist),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _MenuTile(
|
||||||
|
key: const Key('drawer-liked'),
|
||||||
|
icon: Icons.favorite_border_rounded,
|
||||||
|
label: l10n.likedTab,
|
||||||
|
onTap: () => closeThen(onLiked),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
l10n.appLanguage,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final (code, label) in [
|
||||||
|
('en', l10n.english),
|
||||||
|
('zh', l10n.simplifiedChinese),
|
||||||
|
('id', l10n.bahasaIndonesia),
|
||||||
|
])
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: languageCode == code,
|
||||||
|
onSelected: (_) =>
|
||||||
|
context.read<LocaleController>().select(code),
|
||||||
|
backgroundColor: AppColors.input,
|
||||||
|
selectedColor: AppColors.blue,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: languageCode == code
|
||||||
|
? AppColors.onPrimary
|
||||||
|
: AppColors.muted,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
side: BorderSide.none,
|
||||||
|
showCheckmark: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MenuTile extends StatelessWidget {
|
||||||
|
const _MenuTile({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: AppColors.quick,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 58,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppColors.secondary, size: 16),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import '../../../core/config/locale_controller.dart';
|
|
||||||
import '../../../core/data/models/drama.dart';
|
|
||||||
import '../../../core/formatting/localized_content.dart';
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
|
||||||
import '../../../core/theme/app_dimens.dart';
|
|
||||||
import '../../../core/widgets/remote_image.dart';
|
|
||||||
import '../../../l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
|
|
||||||
class HomeQuickDrawer extends StatelessWidget {
|
|
||||||
const HomeQuickDrawer({
|
|
||||||
super.key,
|
|
||||||
required this.drama,
|
|
||||||
required this.onOpenSeries,
|
|
||||||
required this.onDiscover,
|
|
||||||
required this.onHistory,
|
|
||||||
required this.onWatchlist,
|
|
||||||
required this.onLiked,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Drama drama;
|
|
||||||
final VoidCallback onOpenSeries;
|
|
||||||
final VoidCallback onDiscover;
|
|
||||||
final VoidCallback onHistory;
|
|
||||||
final VoidCallback onWatchlist;
|
|
||||||
final VoidCallback onLiked;
|
|
||||||
|
|
||||||
static const _panel = AppColors.card;
|
|
||||||
static const _card = AppColors.raised;
|
|
||||||
static const _accent = AppColors.blueSoft;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final languageCode = Localizations.localeOf(context).languageCode;
|
|
||||||
return Drawer(
|
|
||||||
width: (MediaQuery.sizeOf(context).width * 0.83).clamp(0.0, 360.0),
|
|
||||||
backgroundColor: _panel,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.horizontal(right: Radius.circular(20)),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const CircleAvatar(
|
|
||||||
radius: 30,
|
|
||||||
backgroundColor: AppColors.raised,
|
|
||||||
child: Icon(
|
|
||||||
Icons.person_rounded,
|
|
||||||
size: 34,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.localViewer,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
l10n.localDemoProfile,
|
|
||||||
style: const TextStyle(color: AppColors.cream),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 26),
|
|
||||||
_SectionCard(
|
|
||||||
title: l10n.currentSeries,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppDimens.innerRadius,
|
|
||||||
),
|
|
||||||
child: RemoteImage(
|
|
||||||
drama.posterUrl,
|
|
||||||
width: 74,
|
|
||||||
height: 100,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
localizedDramaTitle(l10n, drama),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 7),
|
|
||||||
Text(
|
|
||||||
l10n.episodeCount(drama.episodeCount),
|
|
||||||
style: const TextStyle(color: AppColors.cream),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton.icon(
|
|
||||||
key: const Key('drawer-open-series'),
|
|
||||||
onPressed: onOpenSeries,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.blue,
|
|
||||||
foregroundColor: AppColors.onPrimary,
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
|
||||||
label: Text(l10n.exploreSeries),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
_SectionCard(
|
|
||||||
title: l10n.quickAccess,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _QuickAction(
|
|
||||||
key: const Key('drawer-discover'),
|
|
||||||
icon: Icons.explore_outlined,
|
|
||||||
label: l10n.discoverTab,
|
|
||||||
onTap: onDiscover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 9),
|
|
||||||
Expanded(
|
|
||||||
child: _QuickAction(
|
|
||||||
key: const Key('drawer-history'),
|
|
||||||
icon: Icons.history_rounded,
|
|
||||||
label: l10n.history,
|
|
||||||
onTap: onHistory,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 9),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _QuickAction(
|
|
||||||
key: const Key('drawer-watchlist'),
|
|
||||||
icon: Icons.bookmark_border_rounded,
|
|
||||||
label: l10n.watchlist,
|
|
||||||
onTap: onWatchlist,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 9),
|
|
||||||
Expanded(
|
|
||||||
child: _QuickAction(
|
|
||||||
key: const Key('drawer-liked'),
|
|
||||||
icon: Icons.favorite_border_rounded,
|
|
||||||
label: l10n.likedTab,
|
|
||||||
onTap: onLiked,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
_SectionCard(
|
|
||||||
title: l10n.appLanguage,
|
|
||||||
child: Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
for (final (code, label) in [
|
|
||||||
('en', l10n.english),
|
|
||||||
('zh', l10n.simplifiedChinese),
|
|
||||||
('id', l10n.bahasaIndonesia),
|
|
||||||
])
|
|
||||||
ChoiceChip(
|
|
||||||
label: Text(label, maxLines: 1),
|
|
||||||
selected: languageCode == code,
|
|
||||||
onSelected: (_) =>
|
|
||||||
context.read<LocaleController>().select(code),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SectionCard extends StatelessWidget {
|
|
||||||
const _SectionCard({required this.title, required this.child});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: HomeQuickDrawer._card,
|
|
||||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
child,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _QuickAction extends StatelessWidget {
|
|
||||||
const _QuickAction({
|
|
||||||
super.key,
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Material(
|
|
||||||
color: AppColors.raised,
|
|
||||||
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: HomeQuickDrawer._accent, size: 25),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+719
-635
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
@@ -69,12 +68,11 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
List<Episode> sessionEpisodes = const [];
|
List<Episode> sessionEpisodes = const [];
|
||||||
int pageRequest = 0;
|
int pageRequest = 0;
|
||||||
bool muted = false;
|
bool muted = false;
|
||||||
|
double playbackSpeed = 1.0;
|
||||||
bool stoppedAtFeedEnd = false;
|
bool stoppedAtFeedEnd = false;
|
||||||
VideoPlayerPort? scrubbingPlayer;
|
VideoPlayerPort? scrubbingPlayer;
|
||||||
bool fullscreen = false;
|
|
||||||
bool pip = false;
|
bool pip = false;
|
||||||
bool pipPreparing = false;
|
bool pipPreparing = false;
|
||||||
bool screenCleared = false;
|
|
||||||
final Map<String, int> pickedEpisode = {};
|
final Map<String, int> pickedEpisode = {};
|
||||||
|
|
||||||
bool Function() isMounted = () => false;
|
bool Function() isMounted = () => false;
|
||||||
@@ -98,9 +96,9 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
|
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
|
||||||
bool _started = false;
|
bool _started = false;
|
||||||
|
|
||||||
bool get immersive => fullscreen || pip || pipPreparing;
|
bool get immersive => pip || pipPreparing;
|
||||||
|
|
||||||
bool get chromeHidden => immersive || screenCleared;
|
bool get chromeHidden => immersive;
|
||||||
|
|
||||||
Episode displayEpisode(Drama drama, Episode fallback) {
|
Episode displayEpisode(Drama drama, Episode fallback) {
|
||||||
final number = pickedEpisode[drama.id];
|
final number = pickedEpisode[drama.id];
|
||||||
@@ -111,13 +109,6 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setScreenCleared(bool value) {
|
|
||||||
if (screenCleared == value) return;
|
|
||||||
screenCleared = value;
|
|
||||||
notify();
|
|
||||||
notifyImmersive();
|
|
||||||
}
|
|
||||||
|
|
||||||
void notify() {
|
void notify() {
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -262,13 +253,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
final request = backRequest;
|
final request = backRequest;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (isMounted() && backRequest == request) {
|
if (isMounted() && backRequest == request) {
|
||||||
if (fullscreen) {
|
onMenu?.call();
|
||||||
unawaited(setFullscreen(false));
|
|
||||||
} else if (screenCleared) {
|
|
||||||
setScreenCleared(false);
|
|
||||||
} else {
|
|
||||||
onMenu?.call();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -295,12 +280,6 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
pictureInPicture.setStateListener(null);
|
pictureInPicture.setStateListener(null);
|
||||||
if (fullscreen) {
|
|
||||||
unawaited(
|
|
||||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
|
||||||
);
|
|
||||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
|
||||||
}
|
|
||||||
unawaited(saveCurrentProgress());
|
unawaited(saveCurrentProgress());
|
||||||
if (_started) {
|
if (_started) {
|
||||||
session.setRouteVisible(false);
|
session.setRouteVisible(false);
|
||||||
@@ -313,32 +292,6 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
|
|
||||||
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
|
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
|
||||||
|
|
||||||
Future<void> setFullscreen(bool value) async {
|
|
||||||
if (fullscreen == value || !isMounted()) return;
|
|
||||||
fullscreen = value;
|
|
||||||
notify();
|
|
||||||
notifyImmersive();
|
|
||||||
try {
|
|
||||||
await SystemChrome.setPreferredOrientations(
|
|
||||||
value
|
|
||||||
? [
|
|
||||||
DeviceOrientation.landscapeLeft,
|
|
||||||
DeviceOrientation.landscapeRight,
|
|
||||||
]
|
|
||||||
: [DeviceOrientation.portraitUp],
|
|
||||||
);
|
|
||||||
await SystemChrome.setEnabledSystemUIMode(
|
|
||||||
value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
if (value && isMounted()) {
|
|
||||||
fullscreen = false;
|
|
||||||
notify();
|
|
||||||
notifyImmersive();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> enterPip(BuildContext context) async {
|
Future<void> enterPip(BuildContext context) async {
|
||||||
if (pip || pipPreparing) return;
|
if (pip || pipPreparing) return;
|
||||||
if (session.active is! VideoPlayerPort) {
|
if (session.active is! VideoPlayerPort) {
|
||||||
@@ -347,7 +300,6 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (fullscreen) await setFullscreen(false);
|
|
||||||
pipPreparing = true;
|
pipPreparing = true;
|
||||||
notify();
|
notify();
|
||||||
notifyImmersive();
|
notifyImmersive();
|
||||||
@@ -390,6 +342,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
|
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
|
||||||
if (player is! VideoPlayerPort) return;
|
if (player is! VideoPlayerPort) return;
|
||||||
try {
|
try {
|
||||||
|
await player.controller.setPlaybackSpeed(playbackSpeed);
|
||||||
await pendingWrite;
|
await pendingWrite;
|
||||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||||
if (resumePoint == null) await loadResumePoint();
|
if (resumePoint == null) await loadResumePoint();
|
||||||
@@ -409,6 +362,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
final index = session.currentIndex;
|
final index = session.currentIndex;
|
||||||
final drama = queue.entryAt(index).drama;
|
final drama = queue.entryAt(index).drama;
|
||||||
await player.controller.setVolume(muted ? 0 : 1);
|
await player.controller.setVolume(muted ? 0 : 1);
|
||||||
|
await player.controller.setPlaybackSpeed(playbackSpeed);
|
||||||
if (session.active != player) return;
|
if (session.active != player) return;
|
||||||
trackedPlayer = player;
|
trackedPlayer = player;
|
||||||
trackedEpisode = episode;
|
trackedEpisode = episode;
|
||||||
@@ -531,6 +485,18 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null;
|
||||||
|
|
||||||
|
Future<void> playNextEpisode() async {
|
||||||
|
final next = nextVisibleIndex(session.currentIndex);
|
||||||
|
if (next == null || !pages.hasClients) return;
|
||||||
|
await pages.animateToPage(
|
||||||
|
visibleIndices.indexOf(next),
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void beginScrub(VideoPlayerPort player) {
|
void beginScrub(VideoPlayerPort player) {
|
||||||
if (session.active != player) return;
|
if (session.active != player) return;
|
||||||
scrubbingPlayer = player;
|
scrubbingPlayer = player;
|
||||||
@@ -824,6 +790,15 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setPlaybackSpeed(double value) async {
|
||||||
|
playbackSpeed = value;
|
||||||
|
final active = session.active;
|
||||||
|
if (active is VideoPlayerPort && active.controller.value.isInitialized) {
|
||||||
|
await active.controller.setPlaybackSpeed(value);
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> showComments(BuildContext context, Drama drama) async {
|
Future<void> showComments(BuildContext context, Drama drama) async {
|
||||||
session.setOverlayOpen(true);
|
session.setOverlayOpen(true);
|
||||||
await session.playbackSettled;
|
await session.playbackSettled;
|
||||||
|
|||||||
+556
-317
@@ -18,12 +18,15 @@ import '../../core/formatting/localized_content.dart';
|
|||||||
import '../../core/platform/haptics.dart';
|
import '../../core/platform/haptics.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/app_bottom_nav.dart';
|
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
import '../../core/widgets/download_action.dart';
|
import '../../core/widgets/download_action.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'player_binding.dart';
|
import 'player_binding.dart';
|
||||||
import 'player_logic.dart';
|
import 'player_logic.dart';
|
||||||
|
import 'widgets/home_feed_overlay.dart';
|
||||||
|
import 'widgets/exit_clear_screen_button.dart';
|
||||||
|
import 'widgets/player_center_toggle.dart';
|
||||||
|
import 'widgets/player_featured_comment.dart';
|
||||||
import 'widgets/seek_progress_bar.dart';
|
import 'widgets/seek_progress_bar.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/repositories/playback_repository.dart';
|
import '../../core/data/repositories/playback_repository.dart';
|
||||||
@@ -68,6 +71,8 @@ class PlayerPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||||
late final PlayerLogic logic;
|
late final PlayerLogic logic;
|
||||||
|
bool _chromeVisible = true;
|
||||||
|
double _playbackSpeed = 1.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -161,7 +166,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
logic.session.preparedPlayerAt(index);
|
logic.session.preparedPlayerAt(index);
|
||||||
final videoPort = port is VideoPlayerPort ? port : null;
|
final videoPort = port is VideoPlayerPort ? port : null;
|
||||||
final ready = videoPort?.controller.value.isInitialized ?? false;
|
final ready = videoPort?.controller.value.isInitialized ?? false;
|
||||||
final theater = logic.fullscreen || logic.pip || logic.pipPreparing;
|
final theater = logic.pip || logic.pipPreparing;
|
||||||
Widget? video;
|
Widget? video;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
video = GestureDetector(
|
video = GestureDetector(
|
||||||
@@ -186,9 +191,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
children: [
|
children: [
|
||||||
const ColoredBox(color: AppColors.ink),
|
const ColoredBox(color: AppColors.ink),
|
||||||
?video,
|
?video,
|
||||||
if (!logic.chromeHidden)
|
if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible))
|
||||||
const IgnorePointer(
|
IgnorePointer(
|
||||||
child: DecoratedBox(
|
key: widget.homeMode ? const Key('home-feed-scrim') : null,
|
||||||
|
child: const DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
@@ -204,6 +210,15 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.homeMode && _chromeVisible && !logic.chromeHidden)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
key: const Key('home-feed-video-toggle'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () =>
|
||||||
|
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||||
|
),
|
||||||
|
),
|
||||||
// 票据三态之一:起播前正在向后端要签名地址
|
// 票据三态之一:起播前正在向后端要签名地址
|
||||||
if (selected && logic.ticketLoading)
|
if (selected && logic.ticketLoading)
|
||||||
const Center(
|
const Center(
|
||||||
@@ -300,41 +315,60 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (selected && ready && !logic.session.userWantsPlay && !logic.pip)
|
|
||||||
const Center(
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.blue,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(10),
|
|
||||||
child: Icon(
|
|
||||||
Icons.play_arrow_rounded,
|
|
||||||
size: 48,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (logic.pip || logic.pipPreparing)
|
if (logic.pip || logic.pipPreparing)
|
||||||
const SizedBox.expand()
|
const SizedBox.expand()
|
||||||
else if (logic.fullscreen)
|
else if (widget.homeMode)
|
||||||
_FullscreenControls(
|
_chromeVisible
|
||||||
l10n: l10n,
|
? HomeFeedOverlay(
|
||||||
controller: ready ? videoPort!.controller : null,
|
l10n: l10n,
|
||||||
onClose: () => unawaited(logic.setFullscreen(false)),
|
drama: drama,
|
||||||
onPip: logic.pictureInPicture.isPlatformSupported
|
episode: episode,
|
||||||
? () => unawaited(logic.enterPip(context))
|
liked: interactions.isLiked(drama.id),
|
||||||
: null,
|
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||||
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
onLike: () async {
|
||||||
onSeek: ready
|
if (await ensureSignedIn(context)) {
|
||||||
? (position) => logic.seekTo(context, videoPort!, position)
|
if (!context.mounted) return;
|
||||||
: null,
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
)
|
final result = await interactions.toggleDramaLike(
|
||||||
else if (!logic.screenCleared)
|
drama.id,
|
||||||
|
);
|
||||||
|
if (!result.isSuccess && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
result.errorMessage ??
|
||||||
|
AppLocalizations.of(context)!.likeFailed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSave: () async {
|
||||||
|
final favorites = context.read<FavoriteController>();
|
||||||
|
if (await ensureSignedIn(context)) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
|
final ok = await favorites.toggle(drama.id, card: drama);
|
||||||
|
if (ok || !context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.favoriteToggleFailed)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onComments: () => logic.showComments(context, drama),
|
||||||
|
onShare: () => logic.shareDrama(context, drama),
|
||||||
|
onEpisodes: () => logic.showEpisodes(context),
|
||||||
|
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||||
|
onClear: () => setState(() => _chromeVisible = false),
|
||||||
|
onSearch:
|
||||||
|
widget.onSearch ?? () => logic.showDiscover(context),
|
||||||
|
onPip: logic.pictureInPicture.isPlatformSupported
|
||||||
|
? () => unawaited(logic.enterPip(context))
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink()
|
||||||
|
else
|
||||||
_PlaybackOverlay(
|
_PlaybackOverlay(
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
drama: drama,
|
drama: drama,
|
||||||
@@ -346,9 +380,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
onBack: widget.embedded
|
onBack: widget.embedded
|
||||||
? null
|
? null
|
||||||
: () => unawaited(logic.leave(context)),
|
: () => unawaited(logic.leave(context)),
|
||||||
onDiscover: () => logic.showDiscover(context),
|
|
||||||
onEpisodes: () => logic.showEpisodes(context),
|
onEpisodes: () => logic.showEpisodes(context),
|
||||||
onMy: () => logic.showMy(context),
|
onNext: logic.hasNextEpisode
|
||||||
|
? () => unawaited(logic.playNextEpisode())
|
||||||
|
: null,
|
||||||
|
playing: logic.session.userWantsPlay,
|
||||||
|
playbackSpeed: _playbackSpeed,
|
||||||
|
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
|
||||||
|
controlsVisible: _chromeVisible,
|
||||||
|
onClear: () => setState(() => _chromeVisible = false),
|
||||||
|
onExitClear: () => setState(() => _chromeVisible = true),
|
||||||
|
onTogglePlay: () =>
|
||||||
|
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||||
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
||||||
onLike: () async {
|
onLike: () async {
|
||||||
if (await ensureSignedIn(context)) {
|
if (await ensureSignedIn(context)) {
|
||||||
@@ -382,7 +425,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||||
onComments: () => logic.showComments(context, drama),
|
onComments: () => logic.showComments(context, drama),
|
||||||
onShare: () => logic.shareDrama(context, drama),
|
onShare: () => logic.shareDrama(context, drama),
|
||||||
onClear: () => logic.setScreenCleared(true),
|
|
||||||
onPip: logic.pictureInPicture.isPlatformSupported
|
onPip: logic.pictureInPicture.isPlatformSupported
|
||||||
? () => unawaited(logic.enterPip(context))
|
? () => unawaited(logic.enterPip(context))
|
||||||
: null,
|
: null,
|
||||||
@@ -390,81 +432,58 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
onSeek: ready
|
onSeek: ready
|
||||||
? (position) => logic.seekTo(context, videoPort!, position)
|
? (position) => logic.seekTo(context, videoPort!, position)
|
||||||
: null,
|
: null,
|
||||||
showBottomNav: false,
|
|
||||||
),
|
),
|
||||||
if (logic.screenCleared)
|
if (widget.homeMode && !_chromeVisible)
|
||||||
SafeArea(
|
Positioned.fill(
|
||||||
child: Align(
|
child: GestureDetector(
|
||||||
alignment: Alignment.topRight,
|
key: const Key('home-feed-video-tap'),
|
||||||
child: Padding(
|
behavior: HitTestBehavior.opaque,
|
||||||
padding: const EdgeInsets.only(top: 4, right: 8),
|
onTap: () =>
|
||||||
child: IconButton(
|
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||||
tooltip: l10n.exitClearScreen,
|
),
|
||||||
onPressed: () => logic.setScreenCleared(false),
|
),
|
||||||
style: IconButton.styleFrom(
|
if (widget.homeMode && !_chromeVisible)
|
||||||
foregroundColor: Colors.white,
|
Positioned(
|
||||||
backgroundColor: const Color(0x59000000),
|
top: 0,
|
||||||
),
|
right: 0,
|
||||||
icon: const Icon(Icons.close_fullscreen_rounded),
|
child: SafeArea(
|
||||||
),
|
left: false,
|
||||||
|
bottom: false,
|
||||||
|
minimum: const EdgeInsets.only(top: 8, right: 12),
|
||||||
|
child: ExitClearScreenButton(
|
||||||
|
key: const Key('home-feed-exit-clear-screen'),
|
||||||
|
tooltip: l10n.exitClearScreen,
|
||||||
|
onPressed: () => setState(() => _chromeVisible = true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.homeMode &&
|
||||||
|
!logic.chromeHidden &&
|
||||||
|
selected &&
|
||||||
|
!logic.session.userWantsPlay)
|
||||||
|
Center(
|
||||||
|
child: PlayerCenterToggle(
|
||||||
|
key: const Key('home-feed-center-play'),
|
||||||
|
playing: false,
|
||||||
|
onTap: () =>
|
||||||
|
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _FullscreenControls extends StatelessWidget {
|
Future<void> _openSpeedSheet(BuildContext context) async {
|
||||||
const _FullscreenControls({
|
final selected = await showModalBottomSheet<double>(
|
||||||
required this.l10n,
|
context: context,
|
||||||
required this.controller,
|
backgroundColor: AppColors.card,
|
||||||
required this.onClose,
|
showDragHandle: true,
|
||||||
required this.onPip,
|
builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed),
|
||||||
required this.onScrubStart,
|
);
|
||||||
required this.onSeek,
|
if (selected == null || !mounted) return;
|
||||||
});
|
setState(() => _playbackSpeed = selected);
|
||||||
|
await logic.setPlaybackSpeed(selected);
|
||||||
final AppLocalizations l10n;
|
}
|
||||||
final VideoPlayerController? controller;
|
|
||||||
final VoidCallback onClose;
|
|
||||||
final VoidCallback? onPip;
|
|
||||||
final VoidCallback? onScrubStart;
|
|
||||||
final Future<void> Function(Duration)? onSeek;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
tooltip: l10n.exitFullscreen,
|
|
||||||
onPressed: onClose,
|
|
||||||
icon: const Icon(Icons.close_fullscreen_rounded),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (onPip != null)
|
|
||||||
IconButton(
|
|
||||||
tooltip: l10n.pictureInPicture,
|
|
||||||
onPressed: onPip,
|
|
||||||
icon: const Icon(Icons.picture_in_picture_alt_outlined),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
|
||||||
child: SeekProgressBar(
|
|
||||||
valueListenable: controller,
|
|
||||||
semanticsLabel: l10n.seekVideo,
|
|
||||||
onScrubStart: onScrubStart,
|
|
||||||
onSeek: onSeek,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
|
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
|
||||||
@@ -589,7 +608,7 @@ class _UnlockCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlaybackOverlay extends StatelessWidget {
|
class _PlaybackOverlay extends StatefulWidget {
|
||||||
const _PlaybackOverlay({
|
const _PlaybackOverlay({
|
||||||
required this.l10n,
|
required this.l10n,
|
||||||
required this.drama,
|
required this.drama,
|
||||||
@@ -597,20 +616,24 @@ class _PlaybackOverlay extends StatelessWidget {
|
|||||||
required this.controller,
|
required this.controller,
|
||||||
required this.liked,
|
required this.liked,
|
||||||
required this.saved,
|
required this.saved,
|
||||||
|
required this.playing,
|
||||||
|
required this.playbackSpeed,
|
||||||
|
required this.onSpeedTap,
|
||||||
|
required this.controlsVisible,
|
||||||
|
required this.onTogglePlay,
|
||||||
|
required this.onClear,
|
||||||
|
required this.onExitClear,
|
||||||
required this.onBack,
|
required this.onBack,
|
||||||
required this.onDiscover,
|
|
||||||
required this.onEpisodes,
|
required this.onEpisodes,
|
||||||
required this.onMy,
|
required this.onComments,
|
||||||
required this.onLike,
|
required this.onLike,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onDownload,
|
required this.onDownload,
|
||||||
required this.onComments,
|
|
||||||
required this.onShare,
|
required this.onShare,
|
||||||
required this.onClear,
|
|
||||||
required this.onPip,
|
required this.onPip,
|
||||||
required this.onScrubStart,
|
required this.onScrubStart,
|
||||||
required this.onSeek,
|
required this.onSeek,
|
||||||
required this.showBottomNav,
|
this.onNext,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
@@ -619,230 +642,446 @@ class _PlaybackOverlay extends StatelessWidget {
|
|||||||
final VideoPlayerController? controller;
|
final VideoPlayerController? controller;
|
||||||
final bool liked;
|
final bool liked;
|
||||||
final bool saved;
|
final bool saved;
|
||||||
|
final bool playing;
|
||||||
|
final double playbackSpeed;
|
||||||
|
final VoidCallback onSpeedTap;
|
||||||
|
final bool controlsVisible;
|
||||||
|
final VoidCallback onTogglePlay;
|
||||||
|
final VoidCallback onClear;
|
||||||
|
final VoidCallback onExitClear;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
final VoidCallback onDiscover;
|
|
||||||
final VoidCallback onEpisodes;
|
final VoidCallback onEpisodes;
|
||||||
final VoidCallback onMy;
|
final VoidCallback onComments;
|
||||||
|
final VoidCallback? onNext;
|
||||||
final VoidCallback onLike;
|
final VoidCallback onLike;
|
||||||
final VoidCallback onSave;
|
final VoidCallback onSave;
|
||||||
final VoidCallback onDownload;
|
final VoidCallback onDownload;
|
||||||
final VoidCallback onComments;
|
|
||||||
final VoidCallback onShare;
|
final VoidCallback onShare;
|
||||||
final VoidCallback onClear;
|
|
||||||
final VoidCallback? onPip;
|
final VoidCallback? onPip;
|
||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
final Future<void> Function(Duration)? onSeek;
|
final Future<void> Function(Duration)? onSeek;
|
||||||
final bool showBottomNav;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => SafeArea(
|
State<_PlaybackOverlay> createState() => _PlaybackOverlayState();
|
||||||
bottom: showBottomNav,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Expanded(child: _info()),
|
|
||||||
_rail(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
16,
|
|
||||||
0,
|
|
||||||
16,
|
|
||||||
20 + MediaQuery.paddingOf(context).bottom,
|
|
||||||
),
|
|
||||||
child: SeekProgressBar(
|
|
||||||
valueListenable: controller,
|
|
||||||
semanticsLabel: l10n.seekVideo,
|
|
||||||
onScrubStart: onScrubStart,
|
|
||||||
onSeek: onSeek,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showBottomNav) _fallbackNav(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (onBack != null)
|
|
||||||
Positioned(
|
|
||||||
top: 4,
|
|
||||||
left: 8,
|
|
||||||
child: IconButton(
|
|
||||||
tooltip: l10n.backToSeries,
|
|
||||||
onPressed: onBack,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
backgroundColor: const Color(0x59000000),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 4,
|
|
||||||
right: 8,
|
|
||||||
child: IconButton(
|
|
||||||
tooltip: l10n.clearScreen,
|
|
||||||
onPressed: onClear,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
backgroundColor: const Color(0x59000000),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.fit_screen_outlined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _info() => Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
localizedDramaTitle(l10n, drama),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
height: 1.15,
|
|
||||||
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _rail() => Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_RailAction(
|
|
||||||
key: const Key('rail-like'),
|
|
||||||
icon: liked ? Icons.favorite : Icons.favorite_border,
|
|
||||||
label: l10n.like,
|
|
||||||
selected: liked,
|
|
||||||
onTap: onLike,
|
|
||||||
),
|
|
||||||
_RailAction(
|
|
||||||
key: const Key('rail-save'),
|
|
||||||
icon: saved ? Icons.bookmark : Icons.bookmark_border,
|
|
||||||
label: l10n.save,
|
|
||||||
selected: saved,
|
|
||||||
onTap: onSave,
|
|
||||||
),
|
|
||||||
_RailAction(
|
|
||||||
key: const Key('rail-download'),
|
|
||||||
icon: Icons.download_outlined,
|
|
||||||
label: l10n.downloads,
|
|
||||||
onTap: onDownload,
|
|
||||||
),
|
|
||||||
_RailAction(
|
|
||||||
icon: Icons.grid_view_outlined,
|
|
||||||
label: l10n.episodes,
|
|
||||||
onTap: onEpisodes,
|
|
||||||
),
|
|
||||||
_RailAction(
|
|
||||||
key: const Key('rail-comments'),
|
|
||||||
icon: Icons.chat_bubble_outline,
|
|
||||||
label: l10n.comments,
|
|
||||||
onTap: onComments,
|
|
||||||
),
|
|
||||||
_RailAction(
|
|
||||||
key: const Key('rail-share'),
|
|
||||||
icon: Icons.share_outlined,
|
|
||||||
label: l10n.share,
|
|
||||||
onTap: onShare,
|
|
||||||
),
|
|
||||||
if (onPip != null)
|
|
||||||
IconButton(
|
|
||||||
tooltip: l10n.pictureInPicture,
|
|
||||||
onPressed: onPip,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints.tightFor(
|
|
||||||
width: _railTapSize,
|
|
||||||
height: _railTapSize,
|
|
||||||
),
|
|
||||||
color: AppColors.cream,
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.picture_in_picture_alt_outlined,
|
|
||||||
size: _railIconSize,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _fallbackNav() => AppBottomNav(
|
|
||||||
items: [
|
|
||||||
AppBottomNavItem(
|
|
||||||
tabKey: const Key('player-nav-home'),
|
|
||||||
icon: Icons.play_circle_outline_rounded,
|
|
||||||
selectedIcon: Icons.play_circle_fill_rounded,
|
|
||||||
label: l10n.homeTab,
|
|
||||||
selected: true,
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
AppBottomNavItem(
|
|
||||||
tabKey: const Key('player-nav-discover'),
|
|
||||||
icon: Icons.explore_outlined,
|
|
||||||
selectedIcon: Icons.explore_rounded,
|
|
||||||
label: l10n.discoverTab,
|
|
||||||
onTap: onDiscover,
|
|
||||||
),
|
|
||||||
AppBottomNavItem(
|
|
||||||
tabKey: const Key('player-nav-my'),
|
|
||||||
icon: Icons.video_library_outlined,
|
|
||||||
selectedIcon: Icons.video_library_rounded,
|
|
||||||
label: l10n.myTab,
|
|
||||||
onTap: onMy,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const double _railIconSize = 28;
|
class _PlaybackOverlayState extends State<_PlaybackOverlay> {
|
||||||
const double _railIconWeight = 300;
|
@override
|
||||||
const double _railTapSize = 44;
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
key: const Key('player-video-tap'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: widget.onTogglePlay,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.controlsVisible)
|
||||||
|
Column(
|
||||||
|
key: const Key('player-controls-visible'),
|
||||||
|
children: [
|
||||||
|
_topBar(context),
|
||||||
|
const Spacer(),
|
||||||
|
Padding(
|
||||||
|
key: const Key('player-controls-bottom'),
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 8, 14, 12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SeekProgressBar(
|
||||||
|
key: const Key('player-progress'),
|
||||||
|
valueListenable: widget.controller,
|
||||||
|
semanticsLabel: widget.l10n.seekVideo,
|
||||||
|
showTimes: true,
|
||||||
|
onScrubStart: widget.onScrubStart,
|
||||||
|
onSeek: widget.onSeek,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _PlayerAction(
|
||||||
|
key: const Key('player-episodes'),
|
||||||
|
icon: Icons.view_list_rounded,
|
||||||
|
label: widget.l10n.episodes,
|
||||||
|
onTap: widget.onEpisodes,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _PlayerAction(
|
||||||
|
key: const Key('player-comments'),
|
||||||
|
icon: Icons.chat_bubble_outline_rounded,
|
||||||
|
label: widget.l10n.comments,
|
||||||
|
onTap: widget.onComments,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: FilledButton(
|
||||||
|
key: const Key('player-next'),
|
||||||
|
onPressed: widget.onNext,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.blue,
|
||||||
|
disabledBackgroundColor: AppColors.raised,
|
||||||
|
disabledForegroundColor: AppColors.subtle,
|
||||||
|
foregroundColor: AppColors.onPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
widget.l10n.nextEpisode,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
const Icon(
|
||||||
|
Icons.skip_next_rounded,
|
||||||
|
size: 14,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PlayerFeaturedComment(
|
||||||
|
drama: widget.drama,
|
||||||
|
onViewAll: widget.onComments,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!widget.controlsVisible)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 12,
|
||||||
|
child: ExitClearScreenButton(
|
||||||
|
key: const Key('player-exit-clear-screen'),
|
||||||
|
tooltip: widget.l10n.exitClearScreen,
|
||||||
|
onPressed: widget.onExitClear,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!widget.playing)
|
||||||
|
Center(
|
||||||
|
child: PlayerCenterToggle(
|
||||||
|
key: const Key('player-play-toggle'),
|
||||||
|
playing: false,
|
||||||
|
onTap: widget.onTogglePlay,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class _RailAction extends StatelessWidget {
|
Widget _topBar(BuildContext context) {
|
||||||
const _RailAction({
|
return SizedBox(
|
||||||
|
key: const Key('player-controls-top'),
|
||||||
|
height: 46,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (widget.onBack != null)
|
||||||
|
_TopAction(
|
||||||
|
key: const Key('player-back'),
|
||||||
|
tooltip: widget.l10n.backToSeries,
|
||||||
|
icon: Icons.chevron_left_rounded,
|
||||||
|
onPressed: widget.onBack!,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 36),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${localizedDramaTitle(widget.l10n, widget.drama)} · '
|
||||||
|
'${widget.l10n.episodeLabel(widget.episode.number)}',
|
||||||
|
key: const Key('player-title'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_TopAction(
|
||||||
|
key: const Key('player-clear-screen'),
|
||||||
|
tooltip: widget.l10n.clearScreen,
|
||||||
|
icon: Icons.fullscreen_rounded,
|
||||||
|
onPressed: widget.onClear,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_TopAction(
|
||||||
|
key: const Key('player-more'),
|
||||||
|
tooltip: widget.l10n.more,
|
||||||
|
icon: Icons.more_horiz_rounded,
|
||||||
|
onPressed: () => _openMore(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openMore(BuildContext context) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppColors.card,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
key: const Key('player-more-speed'),
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.speed_rounded,
|
||||||
|
color: AppColors.secondary,
|
||||||
|
),
|
||||||
|
title: Text(widget.l10n.playbackSpeed),
|
||||||
|
trailing: Text(
|
||||||
|
_speedLabel(widget.playbackSpeed),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.blue,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) widget.onSpeedTap();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
key: const Key('player-more-download'),
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.download_outlined,
|
||||||
|
color: AppColors.secondary,
|
||||||
|
),
|
||||||
|
title: Text(widget.l10n.downloads),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
widget.onDownload();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (widget.onPip != null)
|
||||||
|
ListTile(
|
||||||
|
key: const Key('player-more-pip'),
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.picture_in_picture_alt_outlined,
|
||||||
|
color: AppColors.secondary,
|
||||||
|
),
|
||||||
|
title: Text(widget.l10n.pictureInPicture),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
widget.onPip!();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
key: const Key('player-more-like'),
|
||||||
|
leading: Icon(
|
||||||
|
widget.liked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
color: widget.liked ? AppColors.blue : AppColors.muted,
|
||||||
|
),
|
||||||
|
title: Text(widget.l10n.like),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
widget.onLike();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
key: const Key('player-more-favorite'),
|
||||||
|
leading: Icon(
|
||||||
|
widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||||
|
color: widget.saved ? AppColors.blue : AppColors.muted,
|
||||||
|
),
|
||||||
|
title: Text(widget.l10n.favorite),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
widget.onSave();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
key: const Key('player-more-share'),
|
||||||
|
leading: const Icon(Icons.share_outlined, color: AppColors.muted),
|
||||||
|
title: Text(widget.l10n.share),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
widget.onShare();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const List<double> _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||||
|
|
||||||
|
String _speedLabel(double value) => '${value.toString()}x';
|
||||||
|
|
||||||
|
class _PlaybackSpeedSheet extends StatelessWidget {
|
||||||
|
const _PlaybackSpeedSheet({required this.selected});
|
||||||
|
|
||||||
|
final double selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
key: const Key('player-speed-sheet'),
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 2, 20, 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
l10n.playbackSpeed,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final value in _speedOptions)
|
||||||
|
ListTile(
|
||||||
|
key: Key('player-speed-${value.toString()}'),
|
||||||
|
dense: true,
|
||||||
|
tileColor: value == selected
|
||||||
|
? AppColors.blue.withValues(alpha: 0.12)
|
||||||
|
: null,
|
||||||
|
title: Text(
|
||||||
|
_speedLabel(value),
|
||||||
|
style: TextStyle(
|
||||||
|
color: value == selected ? AppColors.blue : AppColors.cream,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: value == selected
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: value == selected
|
||||||
|
? const Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
color: AppColors.blue,
|
||||||
|
size: 18,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => Navigator.pop(context, value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TopAction extends StatelessWidget {
|
||||||
|
const _TopAction({
|
||||||
|
super.key,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.icon,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String tooltip;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: tooltip,
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF2C2D33),
|
||||||
|
foregroundColor: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
icon: Icon(icon, size: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerAction extends StatelessWidget {
|
||||||
|
const _PlayerAction({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.selected = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback onTap;
|
||||||
final bool selected;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => IconButton(
|
Widget build(BuildContext context) {
|
||||||
tooltip: label,
|
return Material(
|
||||||
onPressed: onTap,
|
color: const Color(0xFF2A2A30),
|
||||||
padding: EdgeInsets.zero,
|
borderRadius: BorderRadius.circular(9),
|
||||||
constraints: const BoxConstraints.tightFor(
|
clipBehavior: Clip.antiAlias,
|
||||||
width: _railTapSize,
|
child: InkWell(
|
||||||
height: _railTapSize,
|
onTap: onTap,
|
||||||
),
|
child: SizedBox(
|
||||||
color: selected ? AppColors.blueSoft : AppColors.cream,
|
height: 32,
|
||||||
icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0),
|
child: Row(
|
||||||
);
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: AppColors.secondary),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Shared floating action used to leave clear-screen mode.
|
||||||
|
class ExitClearScreenButton extends StatelessWidget {
|
||||||
|
const ExitClearScreenButton({
|
||||||
|
super.key,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: tooltip,
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0x80000000),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.fullscreen_exit_rounded, size: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/data/models/drama.dart';
|
||||||
|
import '../../../core/formatting/localized_content.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/widgets/glass_panel.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Home feed layer from `docs/移动端设计预览.html` screen 01.
|
||||||
|
class HomeFeedOverlay extends StatelessWidget {
|
||||||
|
const HomeFeedOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.l10n,
|
||||||
|
required this.drama,
|
||||||
|
required this.episode,
|
||||||
|
required this.liked,
|
||||||
|
required this.saved,
|
||||||
|
required this.onLike,
|
||||||
|
required this.onSave,
|
||||||
|
required this.onComments,
|
||||||
|
required this.onShare,
|
||||||
|
required this.onEpisodes,
|
||||||
|
required this.onDownload,
|
||||||
|
required this.onClear,
|
||||||
|
required this.onSearch,
|
||||||
|
this.onPip,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppLocalizations l10n;
|
||||||
|
final Drama drama;
|
||||||
|
final Episode episode;
|
||||||
|
final bool liked;
|
||||||
|
final bool saved;
|
||||||
|
final VoidCallback onLike;
|
||||||
|
final VoidCallback onSave;
|
||||||
|
final VoidCallback onComments;
|
||||||
|
final VoidCallback onShare;
|
||||||
|
final VoidCallback onEpisodes;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
final VoidCallback onClear;
|
||||||
|
final VoidCallback onSearch;
|
||||||
|
final VoidCallback? onPip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_header(context),
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: const Alignment(1.0, 0.45),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 0, 12, 16),
|
||||||
|
child: _rail(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||||
|
child: _infoCard(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _header(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 6, 10, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
localizedDramaTitle(l10n, drama),
|
||||||
|
key: const Key('home-feed-title'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_RoundIcon(
|
||||||
|
key: const Key('home-feed-search'),
|
||||||
|
tooltip: l10n.search,
|
||||||
|
icon: Icons.search_rounded,
|
||||||
|
onPressed: onSearch,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_RoundIcon(
|
||||||
|
key: const Key('home-feed-clear-screen'),
|
||||||
|
tooltip: l10n.clearScreen,
|
||||||
|
icon: Icons.fullscreen_rounded,
|
||||||
|
onPressed: onClear,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_RoundIcon(
|
||||||
|
key: const Key('home-feed-more'),
|
||||||
|
tooltip: l10n.more,
|
||||||
|
icon: Icons.menu_rounded,
|
||||||
|
onPressed: () => _openMore(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _rail() {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_RailItem(
|
||||||
|
key: const Key('rail-like'),
|
||||||
|
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||||
|
label: l10n.like,
|
||||||
|
selected: liked,
|
||||||
|
onTap: onLike,
|
||||||
|
),
|
||||||
|
_RailItem(
|
||||||
|
key: const Key('rail-comments'),
|
||||||
|
icon: Icons.chat_bubble_outline_rounded,
|
||||||
|
label: l10n.comments,
|
||||||
|
onTap: onComments,
|
||||||
|
),
|
||||||
|
_RailItem(
|
||||||
|
key: const Key('rail-share'),
|
||||||
|
icon: Icons.ios_share_rounded,
|
||||||
|
label: l10n.share,
|
||||||
|
onTap: onShare,
|
||||||
|
),
|
||||||
|
_RailItem(
|
||||||
|
key: const Key('rail-save'),
|
||||||
|
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||||
|
label: l10n.favorite,
|
||||||
|
selected: saved,
|
||||||
|
onTap: onSave,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _infoCard() {
|
||||||
|
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||||
|
return GlassPanel(
|
||||||
|
key: const Key('home-feed-info-card'),
|
||||||
|
radius: 14,
|
||||||
|
blur: 6,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xBF0F0F14), Color(0xA60F0F14)],
|
||||||
|
),
|
||||||
|
borderColor: Colors.white.withValues(alpha: 0.06),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (genre.isNotEmpty)
|
||||||
|
_tag(
|
||||||
|
genre,
|
||||||
|
background: AppColors.blue,
|
||||||
|
foreground: AppColors.onPrimary,
|
||||||
|
),
|
||||||
|
if (drama.category.isNotEmpty && drama.category != genre)
|
||||||
|
_tag(
|
||||||
|
drama.category,
|
||||||
|
background: const Color(0xFF333333),
|
||||||
|
foreground: const Color(0xFFDDDDDD),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.episodeOfTotal(episode.number, drama.episodeCount),
|
||||||
|
key: const Key('home-feed-episode'),
|
||||||
|
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
episode.title.trim().isEmpty
|
||||||
|
? localizedDramaTitle(l10n, drama)
|
||||||
|
: episode.title,
|
||||||
|
key: const Key('home-feed-episode-title'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 34,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
key: const Key('home-feed-follow-series'),
|
||||||
|
onPressed: onSave,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.blue,
|
||||||
|
foregroundColor: AppColors.onPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppDimens.buttonRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded, size: 16),
|
||||||
|
label: Text(
|
||||||
|
saved ? l10n.followingTab : l10n.followSeries,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 34,
|
||||||
|
child: FilledButton(
|
||||||
|
key: const Key('home-feed-episodes'),
|
||||||
|
onPressed: onEpisodes,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF2A2A30),
|
||||||
|
foregroundColor: AppColors.onSurface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppDimens.buttonRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
l10n.episodes,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tag(
|
||||||
|
String label, {
|
||||||
|
required Color background,
|
||||||
|
required Color foreground,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
style: TextStyle(
|
||||||
|
color: foreground,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openMore(BuildContext context) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppColors.card,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
key: const Key('home-feed-download'),
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.download_outlined,
|
||||||
|
color: AppColors.secondary,
|
||||||
|
),
|
||||||
|
title: Text(l10n.downloads),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
onDownload();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (onPip != null)
|
||||||
|
ListTile(
|
||||||
|
key: const Key('home-feed-pip'),
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.picture_in_picture_alt_outlined,
|
||||||
|
color: AppColors.secondary,
|
||||||
|
),
|
||||||
|
title: Text(l10n.pictureInPicture),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
onPip!();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoundIcon extends StatelessWidget {
|
||||||
|
const _RoundIcon({
|
||||||
|
super.key,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.icon,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String tooltip;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: tooltip,
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xCC2C2D33),
|
||||||
|
foregroundColor: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
icon: Icon(icon, size: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RailItem extends StatelessWidget {
|
||||||
|
const _RailItem({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.selected = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: label,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 22,
|
||||||
|
color: selected ? AppColors.blue : AppColors.onSurface,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.cream,
|
||||||
|
fontSize: 9,
|
||||||
|
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// Shared play/pause control used by the home feed and full-screen player.
|
||||||
|
class PlayerCenterToggle extends StatelessWidget {
|
||||||
|
const PlayerCenterToggle({
|
||||||
|
super.key,
|
||||||
|
required this.playing,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool playing;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: playing ? 'Pause' : 'Play',
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0x80000000),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
size: 26,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../core/data/models/comment.dart';
|
||||||
|
import '../../../core/data/models/drama.dart';
|
||||||
|
import '../../../core/data/repositories/comment_repository.dart';
|
||||||
|
import '../../../core/data/repositories/interaction_repository.dart';
|
||||||
|
import '../../../core/data/services/comment_controller.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class PlayerFeaturedComment extends StatefulWidget {
|
||||||
|
const PlayerFeaturedComment({
|
||||||
|
super.key,
|
||||||
|
required this.drama,
|
||||||
|
required this.onViewAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Drama drama;
|
||||||
|
final VoidCallback onViewAll;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayerFeaturedComment> createState() => _PlayerFeaturedCommentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerFeaturedCommentState extends State<PlayerFeaturedComment> {
|
||||||
|
late final CommentController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = CommentController(
|
||||||
|
comments: context.read<CommentRepository>(),
|
||||||
|
interactions: context.read<InteractionRepository>(),
|
||||||
|
dramaId: widget.drama.id,
|
||||||
|
);
|
||||||
|
unawaited(_controller.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (_controller.comments.isEmpty) return const SizedBox.shrink();
|
||||||
|
return _card(context, _controller.comments.first);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _card(BuildContext context, DramaComment comment) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final time = _time(l10n, comment.createdAt);
|
||||||
|
return InkWell(
|
||||||
|
key: const Key('player-featured-comment'),
|
||||||
|
onTap: widget.onViewAll,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(9),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.card,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.secondary.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_initial(comment.nickname),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
comment.nickname.isEmpty
|
||||||
|
? l10n.localViewer
|
||||||
|
: comment.nickname,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (time != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'· $time',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF777777),
|
||||||
|
fontSize: 9,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
comment.content,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFDDDDDD),
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _time(AppLocalizations l10n, DateTime? createdAt) {
|
||||||
|
if (createdAt == null) return null;
|
||||||
|
final diff = DateTime.now().difference(createdAt);
|
||||||
|
if (diff.inMinutes < 1) return null;
|
||||||
|
return l10n.minutesAgo(diff.inMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _initial(String nickname) {
|
||||||
|
final trimmed = nickname.trim();
|
||||||
|
return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ class SeekProgressBar extends StatefulWidget {
|
|||||||
this.onScrubStart,
|
this.onScrubStart,
|
||||||
this.onSeek,
|
this.onSeek,
|
||||||
this.showTimes = true,
|
this.showTimes = true,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||||
@@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget {
|
|||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
final Future<void> Function(Duration)? onSeek;
|
final Future<void> Function(Duration)? onSeek;
|
||||||
final bool showTimes;
|
final bool showTimes;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||||
@@ -116,17 +118,27 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: scrubbing ? 48 : 44,
|
height: widget.compact
|
||||||
|
? (scrubbing ? 32 : 20)
|
||||||
|
: (scrubbing ? 48 : 44),
|
||||||
child: SliderTheme(
|
child: SliderTheme(
|
||||||
data: SliderTheme.of(context).copyWith(
|
data: SliderTheme.of(context).copyWith(
|
||||||
trackHeight: scrubbing ? 4 : 3,
|
trackHeight: widget.compact
|
||||||
activeTrackColor: AppColors.gold,
|
? (scrubbing ? 3 : 1.5)
|
||||||
inactiveTrackColor: Colors.white30,
|
: (scrubbing ? 4 : 3),
|
||||||
thumbColor: Colors.white,
|
activeTrackColor: AppColors.blue,
|
||||||
|
inactiveTrackColor: widget.compact
|
||||||
|
? Colors.white.withValues(alpha: 0.18)
|
||||||
|
: const Color(0xFF474248),
|
||||||
|
thumbColor: widget.compact ? AppColors.blue : Colors.white,
|
||||||
thumbShape: RoundSliderThumbShape(
|
thumbShape: RoundSliderThumbShape(
|
||||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
enabledThumbRadius: widget.compact
|
||||||
|
? (scrubbing ? 6 : 4)
|
||||||
|
: (scrubbing ? 8 : 6),
|
||||||
|
),
|
||||||
|
overlayShape: RoundSliderOverlayShape(
|
||||||
|
overlayRadius: widget.compact ? 12 : 18,
|
||||||
),
|
),
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
|
|
||||||
),
|
),
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
label: widget.semanticsLabel,
|
label: widget.semanticsLabel,
|
||||||
|
|||||||
+52
-28
@@ -61,10 +61,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: clock
|
name: clock
|
||||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.3"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -208,10 +208,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.2"
|
version: "0.20.3"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
jni_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_util
|
||||||
|
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -256,10 +272,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -272,10 +288,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -292,6 +308,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -368,26 +392,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82"
|
sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.3"
|
version: "2.5.6"
|
||||||
shared_preferences_android:
|
shared_preferences_android:
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: shared_preferences_android
|
|
||||||
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
|
||||||
url: "https://pub.flutter-io.cn"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.28"
|
|
||||||
shared_preferences_foundation:
|
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_foundation
|
name: shared_preferences_android
|
||||||
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
sha256: b90caa27669d5f3ef9918d55343b44000f088ee9d4685ed822310848004c793e
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.7"
|
version: "2.5.0"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: "direct overridden"
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
shared_preferences_linux:
|
shared_preferences_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -397,7 +421,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
shared_preferences_platform_interface:
|
shared_preferences_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_platform_interface
|
name: shared_preferences_platform_interface
|
||||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||||
@@ -437,10 +461,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.2"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -477,10 +501,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -493,10 +517,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.3"
|
||||||
video_player:
|
video_player:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+26
-2
@@ -40,8 +40,7 @@ dependencies:
|
|||||||
dio: ^5.7.0
|
dio: ^5.7.0
|
||||||
provider: ^6.1.5+1
|
provider: ^6.1.5+1
|
||||||
video_player: ^2.14.0
|
video_player: ^2.14.0
|
||||||
shared_preferences: 2.3.3
|
shared_preferences: ^2.5.5
|
||||||
shared_preferences_android: 2.4.28
|
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
@@ -60,8 +59,13 @@ dev_dependencies:
|
|||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
shared_preferences_platform_interface: ^2.4.2
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
|
# Xcode 16.2 / Swift 6.0.3 cannot parse the trailing comma in foundation 2.6.0.
|
||||||
|
dependency_overrides:
|
||||||
|
shared_preferences_foundation: 2.5.6
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
@@ -78,6 +82,26 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/icon/splash_mark.png
|
- assets/icon/splash_mark.png
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
- family: Outfit
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/Outfit-Regular.ttf
|
||||||
|
weight: 400
|
||||||
|
- asset: assets/fonts/Outfit-SemiBold.ttf
|
||||||
|
weight: 600
|
||||||
|
- asset: assets/fonts/Outfit-Bold.ttf
|
||||||
|
weight: 700
|
||||||
|
- family: PlusJakartaSans
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/PlusJakartaSans-Regular.ttf
|
||||||
|
weight: 400
|
||||||
|
- asset: assets/fonts/PlusJakartaSans-Medium.ttf
|
||||||
|
weight: 500
|
||||||
|
- asset: assets/fonts/PlusJakartaSans-SemiBold.ttf
|
||||||
|
weight: 600
|
||||||
|
- asset: assets/fonts/PlusJakartaSans-Bold.ttf
|
||||||
|
weight: 700
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ void main() {
|
|||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-my')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -520));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
|
expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('mine-account-profile')), findsOneWidget);
|
expect(find.byKey(const Key('mine-account-profile')), findsOneWidget);
|
||||||
|
|||||||
@@ -82,4 +82,7 @@ Future<void> _pumpAuth(WidgetTester tester) async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
|
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||||
|
import 'package:fruitshorts/core/platform/haptics.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/auth/auth_view.dart';
|
||||||
|
|
||||||
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_http_adapter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_show_three_login_entries_and_open_email_form', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
|
InMemorySharedPreferencesAsync.empty();
|
||||||
|
final auth = AuthController(
|
||||||
|
AuthApi(
|
||||||
|
ApiClient(
|
||||||
|
baseUrl: 'http://127.0.0.1:1',
|
||||||
|
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AuthSessionStore(),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||||
|
Provider<Haptics>.value(value: FakeHaptics()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const AuthPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-google')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Coming soon'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('auth-email')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-password')), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (
|
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ void main() {
|
|||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
AuthApi? api,
|
AuthApi? api,
|
||||||
FakeHaptics? haptics,
|
FakeHaptics? haptics,
|
||||||
|
bool openEmail = true,
|
||||||
}) async {
|
}) async {
|
||||||
final auth = AuthController(
|
final auth = AuthController(
|
||||||
api ??
|
api ??
|
||||||
@@ -57,20 +58,32 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump(const Duration(milliseconds: 220));
|
await tester.pump(const Duration(milliseconds: 220));
|
||||||
|
if (openEmail) {
|
||||||
|
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'should_render_branded_dark_layout_with_approved_tokens_when_page_opens',
|
'should_render_branded_dark_layout_with_approved_tokens_when_page_opens',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await pumpAuthPage(tester);
|
await pumpAuthPage(tester, openEmail: false);
|
||||||
|
|
||||||
expect(find.byKey(const Key('auth-brand')), findsOneWidget);
|
expect(find.byKey(const Key('auth-landing')), findsOneWidget);
|
||||||
expect(find.text('FruitShorts'), findsOneWidget);
|
expect(find.text('FruitShorts'), findsOneWidget);
|
||||||
expect(find.text('Short dramas made for Indonesia'), findsOneWidget);
|
|
||||||
expect(
|
expect(
|
||||||
tester.getSize(find.byKey(const Key('auth-brand-mark'))),
|
find.text('Sign in to sync progress, favorites and coins'),
|
||||||
const Size(48, 48),
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('auth-brand-logo'))),
|
||||||
|
const Size(64, 64),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
|
||||||
|
|
||||||
final glow = tester.widget<Container>(
|
final glow = tester.widget<Container>(
|
||||||
find.byKey(const Key('auth-background-glow')),
|
find.byKey(const Key('auth-background-glow')),
|
||||||
@@ -81,6 +94,9 @@ void main() {
|
|||||||
expect(color.a, lessThanOrEqualTo(0.18));
|
expect(color.a, lessThanOrEqualTo(0.18));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final card = tester.widget<Container>(find.byKey(const Key('auth-card')));
|
final card = tester.widget<Container>(find.byKey(const Key('auth-card')));
|
||||||
final cardDecoration = card.decoration! as BoxDecoration;
|
final cardDecoration = card.decoration! as BoxDecoration;
|
||||||
expect(cardDecoration.color, AppColors.card);
|
expect(cardDecoration.color, AppColors.card);
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ void main() {
|
|||||||
isNull,
|
isNull,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await ServerEndpoint.save(' http://39.162.50.99:8081/ '), isTrue);
|
expect(
|
||||||
|
await ServerEndpoint.save(' http://39.162.50.99:8081/ '),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey),
|
await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey),
|
||||||
'http://39.162.50.99:8081',
|
'http://39.162.50.99:8081',
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
|
||||||
import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
List<CoverItem> items(int count) => [
|
|
||||||
for (var index = 0; index < count; index++)
|
|
||||||
CoverItem(
|
|
||||||
title: 'Banner $index',
|
|
||||||
subtitle: 'Subtitle $index',
|
|
||||||
image: '/files/banner-$index.png',
|
|
||||||
onOpen: _noop,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_localizedApp(
|
|
||||||
CoverCarousel(
|
|
||||||
items: [
|
|
||||||
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
|
|
||||||
CoverItem(
|
|
||||||
title: title,
|
|
||||||
subtitle: 'Mock',
|
|
||||||
image: '/files/poster.png',
|
|
||||||
onOpen: _noop,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
|
||||||
expect(find.text('Mock'), findsOneWidget);
|
|
||||||
|
|
||||||
final box = tester.getRect(find.byType(CoverCarousel));
|
|
||||||
final gesture = await tester.startGesture(
|
|
||||||
Offset(box.right - 40, box.center.dy),
|
|
||||||
);
|
|
||||||
await gesture.moveBy(const Offset(-500, 0));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final turned = tester
|
|
||||||
.widgetList<Transform>(find.byType(Transform))
|
|
||||||
.any((transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15);
|
|
||||||
expect(turned, isTrue);
|
|
||||||
|
|
||||||
await gesture.moveBy(const Offset(-200, 0));
|
|
||||||
await gesture.up();
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 450));
|
|
||||||
|
|
||||||
expect(find.text('City Lights'), findsOneWidget);
|
|
||||||
expect(tester.takeException(), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should_auto_advance_and_show_dots_when_multiple_items_exist', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(3))));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(find.text('Banner 0'), findsOneWidget);
|
|
||||||
expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget);
|
|
||||||
expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget);
|
|
||||||
expect(find.byKey(const Key('carousel-dot-2')), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 4500));
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
expect(find.text('Banner 1'), findsOneWidget);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should_not_schedule_auto_advance_when_only_one_item_exists', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(1))));
|
|
||||||
await tester.pump(const Duration(seconds: 12));
|
|
||||||
|
|
||||||
expect(find.text('Banner 0'), findsOneWidget);
|
|
||||||
expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget);
|
|
||||||
expect(find.byKey(const Key('carousel-dot-1')), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should_pause_auto_advance_while_pointer_is_down', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(3))));
|
|
||||||
await tester.pump();
|
|
||||||
final box = tester.getRect(find.byType(CoverCarousel));
|
|
||||||
final gesture = await tester.startGesture(box.center);
|
|
||||||
await tester.pump(const Duration(seconds: 6));
|
|
||||||
|
|
||||||
expect(find.text('Banner 0'), findsOneWidget);
|
|
||||||
|
|
||||||
await gesture.up();
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 4500));
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
expect(find.text('Banner 1'), findsOneWidget);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should_pause_when_inactive_and_resume_when_active_again', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final active = ValueNotifier(false);
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_localizedApp(
|
|
||||||
ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: active,
|
|
||||||
builder: (context, enabled, _) =>
|
|
||||||
CoverCarousel(items: items(2), active: enabled),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump(const Duration(seconds: 6));
|
|
||||||
expect(find.text('Banner 0'), findsOneWidget);
|
|
||||||
|
|
||||||
active.value = true;
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 4500));
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
expect(find.text('Banner 1'), findsOneWidget);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should_show_icon_fallback_when_banner_image_is_empty', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_localizedApp(
|
|
||||||
CoverCarousel(
|
|
||||||
items: const [
|
|
||||||
CoverItem(
|
|
||||||
title: 'Broken image',
|
|
||||||
subtitle: '',
|
|
||||||
image: '',
|
|
||||||
onOpen: _noop,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(find.byKey(const Key('carousel-image-fallback')), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'should_keep_existing_open_callback_when_active_poster_is_tapped',
|
|
||||||
(tester) async {
|
|
||||||
var opened = 0;
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_localizedApp(
|
|
||||||
CoverCarousel(
|
|
||||||
items: [
|
|
||||||
CoverItem(
|
|
||||||
title: 'Tap me',
|
|
||||||
subtitle: '',
|
|
||||||
image: '/files/banner.png',
|
|
||||||
onOpen: () => opened++,
|
|
||||||
),
|
|
||||||
...items(1),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Tap me'));
|
|
||||||
expect(opened, 1);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _noop() {}
|
|
||||||
|
|
||||||
Widget _localizedApp(Widget child) => MaterialApp(
|
|
||||||
locale: const Locale('en'),
|
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
|
||||||
home: Scaffold(body: child),
|
|
||||||
);
|
|
||||||
@@ -7,11 +7,11 @@ import 'package:fruitshorts/app.dart';
|
|||||||
import 'package:fruitshorts/core/data/models/content_category.dart';
|
import 'package:fruitshorts/core/data/models/content_category.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
import 'package:fruitshorts/core/widgets/remote_image.dart';
|
|
||||||
|
|
||||||
import '../support/fake_catalog.dart';
|
import '../support/fake_catalog.dart';
|
||||||
import '../support/fake_interaction_repositories.dart';
|
import '../support/fake_interaction_repositories.dart';
|
||||||
@@ -25,7 +25,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'should_show_all_dramas_in_three_column_sliver_grid_without_continue_watching',
|
'should_show_all_dramas_in_two_column_masonry_without_continue_watching',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
tester.view.physicalSize = const Size(390, 844);
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
@@ -37,57 +37,94 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Continue Watching'), findsNothing);
|
expect(find.text('Continue Watching'), findsNothing);
|
||||||
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
expect(find.text('Recommended'), findsOneWidget);
|
||||||
expect(find.text('All'), findsOneWidget);
|
for (final key in const [
|
||||||
|
Key('discover-search-button'),
|
||||||
|
Key('discover-profile'),
|
||||||
|
]) {
|
||||||
|
expect(tester.getSize(find.byKey(key)), const Size(36, 36));
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Icon>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(key),
|
||||||
|
matching: find.byType(Icon),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.size,
|
||||||
|
18,
|
||||||
|
);
|
||||||
|
}
|
||||||
expect(find.text('For You'), findsNothing);
|
expect(find.text('For You'), findsNothing);
|
||||||
expect(
|
final masonry = tester.widget<Row>(
|
||||||
tester.getCenter(find.text('All')).dx,
|
|
||||||
lessThan(tester.getCenter(find.text('Hot')).dx),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
tester
|
|
||||||
.getTopLeft(
|
|
||||||
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
|
||||||
)
|
|
||||||
.dy -
|
|
||||||
tester
|
|
||||||
.getBottomLeft(find.byKey(const Key('home-category-chips')))
|
|
||||||
.dy,
|
|
||||||
closeTo(14, 0.1),
|
|
||||||
);
|
|
||||||
|
|
||||||
final grid = tester.widget<SliverGrid>(
|
|
||||||
find.byKey(const Key('home-all-dramas-grid')),
|
find.byKey(const Key('home-all-dramas-grid')),
|
||||||
);
|
);
|
||||||
final delegate =
|
expect(masonry.children, hasLength(3));
|
||||||
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
expect((masonry.children[1] as SizedBox).width, 10);
|
||||||
expect(delegate.crossAxisCount, 3);
|
|
||||||
expect(delegate.crossAxisSpacing, 10);
|
|
||||||
expect(delegate.mainAxisSpacing, 14);
|
|
||||||
expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001));
|
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<SliverPadding>(
|
.widget<Padding>(find.byKey(const Key('home-all-dramas-section')))
|
||||||
find.byKey(const Key('home-all-dramas-section')),
|
|
||||||
)
|
|
||||||
.padding,
|
.padding,
|
||||||
const EdgeInsets.symmetric(horizontal: 16),
|
const EdgeInsets.symmetric(horizontal: 14),
|
||||||
);
|
);
|
||||||
expect(find.byType(GridView), findsNothing);
|
expect(find.byType(GridView), findsNothing);
|
||||||
expect(find.byType(CustomScrollView), findsOneWidget);
|
expect(find.byType(CustomScrollView), findsOneWidget);
|
||||||
expect(find.byType(RemoteImage), findsWidgets);
|
final signalCard = find.byKey(
|
||||||
|
const Key('home-drama-card-the-signal-demo'),
|
||||||
|
);
|
||||||
|
final cityCard = find.byKey(
|
||||||
|
const Key('home-drama-card-city-lights-demo'),
|
||||||
|
);
|
||||||
|
final vengeanceCard = find.byKey(
|
||||||
|
const Key('home-drama-card-vengeance-demo'),
|
||||||
|
);
|
||||||
|
expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(vengeanceCard).dx,
|
||||||
|
tester.getTopLeft(signalCard).dx,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(vengeanceCard).dy -
|
||||||
|
tester.getBottomLeft(signalCard).dy,
|
||||||
|
closeTo(10, 1),
|
||||||
|
);
|
||||||
|
|
||||||
final cover = tester.widget<AspectRatio>(
|
final cover = find.byKey(const Key('home-drama-cover-city-lights-demo'));
|
||||||
find.byKey(const Key('home-drama-cover-city-lights-demo')),
|
expect(tester.getSize(cover).height, greaterThan(120));
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Image>(
|
||||||
|
find.descendant(of: cover, matching: find.byType(Image)),
|
||||||
|
)
|
||||||
|
.fit,
|
||||||
|
BoxFit.fitWidth,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: cover,
|
||||||
|
matching: find.text('City Lights & Velvet Nights'),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-title-city-lights-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.getTopLeft(
|
||||||
|
find.byKey(const Key('home-drama-title-city-lights-demo')),
|
||||||
|
)
|
||||||
|
.dy,
|
||||||
|
greaterThan(tester.getBottomLeft(cover).dy),
|
||||||
);
|
);
|
||||||
expect(cover.aspectRatio, closeTo(2 / 3, 0.001));
|
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<Text>(
|
.widget<Text>(
|
||||||
find.byKey(const Key('home-drama-title-city-lights-demo')),
|
find.byKey(const Key('home-drama-title-city-lights-demo')),
|
||||||
)
|
)
|
||||||
.maxLines,
|
.maxLines,
|
||||||
2,
|
1,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
@@ -111,14 +148,14 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
expect(find.text('The Signal'), findsWidgets);
|
||||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||||
|
|
||||||
await tester.tap(find.text('Romance'));
|
await tester.tap(find.text('Romance'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('The Signal'), findsNothing);
|
expect(find.text('The Signal'), findsNothing);
|
||||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||||
expect(
|
expect(
|
||||||
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
findsNothing,
|
findsNothing,
|
||||||
@@ -129,6 +166,43 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_discover_header_fixed_when_content_scrolls', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(
|
||||||
|
tester,
|
||||||
|
categoryRepository: _GridCategoryRepository(
|
||||||
|
discoverFeed: _tallDiscoverFeed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final headerBefore = tester.getTopLeft(
|
||||||
|
find.byKey(const Key('discover-search-button')),
|
||||||
|
);
|
||||||
|
final contentBefore = tester.getTopLeft(
|
||||||
|
find.byKey(const Key('home-category-chips')),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.drag(find.byType(CustomScrollView), const Offset(0, -40));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.byKey(const Key('discover-search-button'))),
|
||||||
|
headerBefore,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.byKey(const Key('home-category-chips'))).dy,
|
||||||
|
lessThan(contentBefore.dy),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', (
|
testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -146,17 +220,8 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
|
||||||
expect(find.text('No matching dramas.'), findsOneWidget);
|
expect(find.text('No matching dramas.'), findsOneWidget);
|
||||||
final content = tester.getCenter(
|
expect(find.byKey(const Key('empty-state-content')), findsOneWidget);
|
||||||
find.byKey(const Key('empty-state-content')),
|
|
||||||
);
|
|
||||||
final chipsBottom = tester
|
|
||||||
.getBottomLeft(find.byKey(const Key('home-category-chips')))
|
|
||||||
.dy;
|
|
||||||
final feed = tester.getRect(find.byType(CustomScrollView));
|
|
||||||
expect(content.dx, closeTo(feed.center.dx, 1));
|
|
||||||
expect(content.dy, closeTo((chipsBottom + feed.bottom) / 2, 36));
|
|
||||||
|
|
||||||
await tester.tap(find.text('Romance'));
|
await tester.tap(find.text('Romance'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -174,7 +239,6 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
|
||||||
expect(find.text('Categories could not be loaded.'), findsOneWidget);
|
expect(find.text('Categories could not be loaded.'), findsOneWidget);
|
||||||
expect(find.text('Retry'), findsOneWidget);
|
expect(find.text('Retry'), findsOneWidget);
|
||||||
expect(find.byKey(const Key('empty-state-icon')), findsOneWidget);
|
expect(find.byKey(const Key('empty-state-icon')), findsOneWidget);
|
||||||
@@ -184,7 +248,7 @@ void main() {
|
|||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_fit_three_column_grid_on_compact_screen', (tester) async {
|
testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async {
|
||||||
tester.view.physicalSize = const Size(320, 568);
|
tester.view.physicalSize = const Size(320, 568);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
@@ -200,15 +264,164 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('home-all-dramas-title')), findsNothing);
|
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_filter_trending_grid_when_search_text_changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byKey(const Key('discover-search')), 'velvet');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byKey(const Key('discover-search')), '');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_unfocus_search_without_losing_input_or_filter', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('discover-search')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextField>(find.byKey(const Key('discover-search')))
|
||||||
|
.focusNode!
|
||||||
|
.hasFocus,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byKey(const Key('discover-search')), 'velvet');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(70, 23));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final field = tester.widget<TextField>(
|
||||||
|
find.byKey(const Key('discover-search')),
|
||||||
|
);
|
||||||
|
expect(field.focusNode!.hasFocus, isFalse);
|
||||||
|
expect(field.controller!.text, 'velvet');
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-drama-card-the-signal-demo')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_refresh_feed_when_pulled_down', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final counter = _CountingCategoryRepository(
|
||||||
|
_GridCategoryRepository(discoverFeed: _tallDiscoverFeed),
|
||||||
|
);
|
||||||
|
await _pumpApp(tester, categoryRepository: counter);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final before = counter.categoryLoads + counter.feedLoads;
|
||||||
|
await tester.drag(find.byType(CustomScrollView), const Offset(0, 320));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(counter.categoryLoads + counter.feedLoads, greaterThan(before));
|
||||||
|
expect(find.byType(RefreshProgressIndicator), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_continue_watching_only_when_a_record_exists', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await _pumpApp(
|
||||||
|
tester,
|
||||||
|
categoryRepository: _GridCategoryRepository(),
|
||||||
|
history: InMemoryWatchHistoryRepository(
|
||||||
|
seed: const [
|
||||||
|
WatchProgress(
|
||||||
|
dramaId: 'city-lights-demo',
|
||||||
|
episodeId: 'city-lights-demo-1',
|
||||||
|
position: Duration(seconds: 42),
|
||||||
|
duration: Duration(minutes: 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discover-continue-play')), findsOneWidget);
|
||||||
|
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||||
|
expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discover-quick-new')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pumpApp(
|
Future<void> _pumpApp(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
required CategoryRepository categoryRepository,
|
required CategoryRepository categoryRepository,
|
||||||
|
WatchHistoryRepository? history,
|
||||||
}) async {
|
}) async {
|
||||||
final catalog = FakeCatalogRepository();
|
final catalog = FakeCatalogRepository();
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -216,7 +429,7 @@ Future<void> _pumpApp(
|
|||||||
playerFactory: createWidgetPlayer,
|
playerFactory: createWidgetPlayer,
|
||||||
catalogRepository: catalog,
|
catalogRepository: catalog,
|
||||||
categoryRepository: categoryRepository,
|
categoryRepository: categoryRepository,
|
||||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(),
|
||||||
favoriteRepository: InMemoryFavoriteRepository(),
|
favoriteRepository: InMemoryFavoriteRepository(),
|
||||||
playbackRepository: FakePlaybackRepository(),
|
playbackRepository: FakePlaybackRepository(),
|
||||||
commentRepository: FakeCommentRepository(),
|
commentRepository: FakeCommentRepository(),
|
||||||
@@ -273,3 +486,43 @@ final _defaultDiscoverFeed = HomeFeed(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final _tallDiscoverFeed = HomeFeed(
|
||||||
|
sections: [
|
||||||
|
FeedSection(
|
||||||
|
code: 'hot',
|
||||||
|
title: 'Hot',
|
||||||
|
dramas: [
|
||||||
|
for (var index = 0; index < 12; index++)
|
||||||
|
Drama(
|
||||||
|
id: 'tall-$index',
|
||||||
|
title: 'Tall Drama $index',
|
||||||
|
synopsis: 'Tall Drama $index synopsis',
|
||||||
|
posterUrl: '/files/tall-$index.png',
|
||||||
|
genre: 'Urban Romance',
|
||||||
|
detailLoaded: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
class _CountingCategoryRepository implements CategoryRepository {
|
||||||
|
_CountingCategoryRepository(this.delegate);
|
||||||
|
|
||||||
|
final CategoryRepository delegate;
|
||||||
|
int categoryLoads = 0;
|
||||||
|
int feedLoads = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ContentCategory>> fetchCategories() {
|
||||||
|
categoryLoads++;
|
||||||
|
return delegate.fetchCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> fetchDiscoverFeed() {
|
||||||
|
feedLoads++;
|
||||||
|
return delegate.fetchDiscoverFeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:fruitshorts/app.dart';
|
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
|
||||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
|
||||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
|
||||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
|
||||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
|
||||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
|
||||||
import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart';
|
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
|
||||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
|
||||||
|
|
||||||
import '../support/fake_catalog.dart';
|
|
||||||
import '../support/fake_category.dart';
|
|
||||||
import '../support/fake_interaction_repositories.dart';
|
|
||||||
import '../support/fake_notice_repository.dart';
|
|
||||||
import '../support/widget_player.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets(
|
|
||||||
'should_render_home_feed_banners_when_discover_banners_are_empty',
|
|
||||||
(tester) async {
|
|
||||||
SharedPreferencesAsyncPlatform.instance =
|
|
||||||
InMemorySharedPreferencesAsync.empty();
|
|
||||||
final catalog = _HomeBannerCatalogRepository();
|
|
||||||
await tester.pumpWidget(
|
|
||||||
LumaDramaApp(
|
|
||||||
playerFactory: createWidgetPlayer,
|
|
||||||
catalogRepository: catalog,
|
|
||||||
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
|
||||||
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
|
||||||
favoriteRepository: InMemoryFavoriteRepository(),
|
|
||||||
playbackRepository: FakePlaybackRepository(),
|
|
||||||
commentRepository: FakeCommentRepository(),
|
|
||||||
interactionRepository: FakeInteractionRepository(),
|
|
||||||
noticeRepository: FakeNoticeRepository(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byType(CoverCarousel), findsOneWidget);
|
|
||||||
expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget);
|
|
||||||
expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget);
|
|
||||||
expect(
|
|
||||||
find.descendant(
|
|
||||||
of: find.byType(CoverCarousel),
|
|
||||||
matching: find.text('The Signal'),
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 4500));
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
expect(
|
|
||||||
find.descendant(
|
|
||||||
of: find.byType(CoverCarousel),
|
|
||||||
matching: find.text('City Lights & Velvet Nights'),
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HomeBannerCatalogRepository implements CatalogRepository {
|
|
||||||
final _delegate = FakeCatalogRepository();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<HomeFeed> fetchHomeFeed() async => HomeFeed(
|
|
||||||
banners: const [
|
|
||||||
FeedBanner(
|
|
||||||
id: 'banner-1',
|
|
||||||
imageUrl: '/files/banner-1.png',
|
|
||||||
linkType: 'DRAMA',
|
|
||||||
linkValue: 'the-signal-demo',
|
|
||||||
),
|
|
||||||
FeedBanner(
|
|
||||||
id: 'banner-2',
|
|
||||||
imageUrl: '/files/banner-2.png',
|
|
||||||
linkType: 'DRAMA',
|
|
||||||
linkValue: 'city-lights-demo',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
sections: [FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas)],
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<HomeFeed> fetchDiscoverFeed() async => HomeFeed(
|
|
||||||
sections: [FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas)],
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Drama> fetchDrama(String dramaId) => _delegate.fetchDrama(dramaId);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||||
|
import 'package:fruitshorts/core/platform/haptics.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||||
|
|
||||||
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
import '../support/widget_player.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(_detailDrama()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('detail-back'))),
|
||||||
|
const Size(36, 36),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Icon>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('detail-back')),
|
||||||
|
matching: find.byType(Icon),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.size,
|
||||||
|
18,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
|
||||||
|
expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40);
|
||||||
|
expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
|
||||||
|
|
||||||
|
final synopsis = tester.widget<Text>(
|
||||||
|
find.byKey(const Key('detail-synopsis')),
|
||||||
|
);
|
||||||
|
expect(synopsis.style?.fontSize, 11);
|
||||||
|
expect(synopsis.style?.height, 1.6);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('detail-more')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('detail-download')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('detail-share')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('detail-report')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_fit_compact_viewport_when_detail_scrolls', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 568);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(_detailDrama()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final grid = tester.widget<GridView>(
|
||||||
|
find.byKey(const Key('detail-episode-chips')),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount)
|
||||||
|
.crossAxisCount,
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
await tester.drag(
|
||||||
|
find.byKey(const Key('detail-scroll')),
|
||||||
|
const Offset(0, -520),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_render_sorted_six_column_grid_and_open_original_index', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(_detailDrama(count: 80)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final grid = tester.widget<GridView>(
|
||||||
|
find.byKey(const Key('detail-episode-chips')),
|
||||||
|
);
|
||||||
|
final delegate =
|
||||||
|
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||||
|
expect(delegate.crossAxisCount, 6);
|
||||||
|
expect(delegate.mainAxisSpacing, 8);
|
||||||
|
expect(delegate.crossAxisSpacing, 8);
|
||||||
|
expect(delegate.childAspectRatio, closeTo(1.35, 0.001));
|
||||||
|
|
||||||
|
final chip1 = find.byKey(const Key('detail-episode-chip-1'));
|
||||||
|
final chip2 = find.byKey(const Key('detail-episode-chip-2'));
|
||||||
|
final chip6 = find.byKey(const Key('detail-episode-chip-6'));
|
||||||
|
final chip7 = find.byKey(const Key('detail-episode-chip-7'));
|
||||||
|
expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(chip7).dy,
|
||||||
|
greaterThan(tester.getTopLeft(chip1).dy),
|
||||||
|
);
|
||||||
|
expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx));
|
||||||
|
expect(tester.getSize(chip1), tester.getSize(chip2));
|
||||||
|
|
||||||
|
await tester.drag(
|
||||||
|
find.byKey(const Key('detail-scroll')),
|
||||||
|
const Offset(0, -420),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final chip12 = find.byKey(const Key('detail-episode-chip-12'));
|
||||||
|
expect(chip12, findsOneWidget);
|
||||||
|
await tester.tap(chip12);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final player = tester.widget<PlayerPage>(find.byType(PlayerPage));
|
||||||
|
expect(player.initialIndex, 11);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_sort_episode_chips_ascending_by_number', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(_detailDrama(count: 80, descending: true)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final chip1 = find.byKey(const Key('detail-episode-chip-1'));
|
||||||
|
final chip2 = find.byKey(const Key('detail-episode-chip-2'));
|
||||||
|
final chip6 = find.byKey(const Key('detail-episode-chip-6'));
|
||||||
|
final chip7 = find.byKey(const Key('detail-episode-chip-7'));
|
||||||
|
expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy);
|
||||||
|
expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx));
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(chip7).dy,
|
||||||
|
greaterThan(tester.getTopLeft(chip1).dy),
|
||||||
|
);
|
||||||
|
expect(tester.getSize(chip1), tester.getSize(chip2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _host(Drama drama) {
|
||||||
|
final catalog = FakeCatalogRepository(dramas: [drama]);
|
||||||
|
final interactions = FakeInteractionRepository();
|
||||||
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
Provider<WatchHistoryRepository>.value(
|
||||||
|
value: InMemoryWatchHistoryRepository(),
|
||||||
|
),
|
||||||
|
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
|
||||||
|
Provider<PlayerFactory>.value(value: createWidgetPlayer),
|
||||||
|
Provider(create: (_) => PlaybackCoordinator()),
|
||||||
|
Provider<InteractionRepository>.value(value: interactions),
|
||||||
|
Provider<CommentRepository>.value(value: FakeCommentRepository()),
|
||||||
|
Provider<Haptics>.value(value: FakeHaptics()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) => FavoriteController(InMemoryFavoriteRepository()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) => InteractionController(interactions),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.dark(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: DramaDetailPage(drama: drama),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Drama _detailDrama({int count = 8, bool descending = false}) {
|
||||||
|
final numbers = List<int>.generate(
|
||||||
|
count,
|
||||||
|
(index) => descending ? count - index : index + 1,
|
||||||
|
);
|
||||||
|
return Drama(
|
||||||
|
id: 'detail-k25',
|
||||||
|
title: 'Detail K25',
|
||||||
|
synopsis:
|
||||||
|
'She agreed to marry a mysterious stranger to save her family, '
|
||||||
|
'until she discovered his secret identity and the city he controls.',
|
||||||
|
posterUrl: '/files/detail-k25.png',
|
||||||
|
genre: 'Urban Romance',
|
||||||
|
category: 'Romance',
|
||||||
|
totalEpisodes: count,
|
||||||
|
viewCount: 4800000,
|
||||||
|
detailLoaded: true,
|
||||||
|
episodes: [
|
||||||
|
for (final number in numbers)
|
||||||
|
Episode(
|
||||||
|
id: 'detail-k25-$number',
|
||||||
|
number: number,
|
||||||
|
title: 'Episode $number',
|
||||||
|
isFree: number == 1,
|
||||||
|
durationSec: 135,
|
||||||
|
locked: number > 1,
|
||||||
|
videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,17 +18,20 @@ void main() {
|
|||||||
|
|
||||||
final dramas = [_drama('signal'), _drama('city')];
|
final dramas = [_drama('signal'), _drama('city')];
|
||||||
|
|
||||||
test('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () {
|
test(
|
||||||
final logic = HomeLogic(dramas);
|
'should_make_first_catalog_entry_the_current_drama_when_catalog_loads',
|
||||||
|
() {
|
||||||
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
expect(logic.currentDrama.id, 'signal');
|
expect(logic.currentDrama.id, 'signal');
|
||||||
expect(logic.tab, 1);
|
expect(logic.tab, 0);
|
||||||
expect(logic.mySection, 0);
|
expect(logic.mySection, 0);
|
||||||
expect(logic.drawerOpen, isFalse);
|
expect(logic.drawerOpen, isFalse);
|
||||||
expect(logic.profile, isFalse);
|
expect(logic.profile, isFalse);
|
||||||
expect(logic.videoImmersive, isFalse);
|
expect(logic.videoImmersive, isFalse);
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('should_not_build_shell_when_catalog_is_empty', () {
|
test('should_not_build_shell_when_catalog_is_empty', () {
|
||||||
expect(() => HomeLogic(const []), throwsStateError);
|
expect(() => HomeLogic(const []), throwsStateError);
|
||||||
@@ -82,35 +85,39 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () {
|
test(
|
||||||
final logic = HomeLogic(dramas);
|
'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used',
|
||||||
|
() {
|
||||||
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
logic.openProfile();
|
logic.openProfile();
|
||||||
expect(logic.profile, isTrue);
|
expect(logic.profile, isTrue);
|
||||||
expect(logic.tab, 2);
|
expect(logic.tab, 2);
|
||||||
expect(logic.mySection, 0);
|
expect(logic.mySection, 0);
|
||||||
expect(logic.sectionRequest, 1);
|
expect(logic.sectionRequest, 1);
|
||||||
|
|
||||||
logic.openLibrary(section: 1);
|
logic.openLibrary(section: 1);
|
||||||
expect(logic.profile, isFalse);
|
expect(logic.profile, isFalse);
|
||||||
expect(logic.mySection, 1);
|
expect(logic.mySection, 1);
|
||||||
expect(logic.sectionRequest, 2);
|
expect(logic.sectionRequest, 2);
|
||||||
|
|
||||||
logic.openLibrary();
|
logic.openLibrary();
|
||||||
expect(logic.profile, isFalse);
|
expect(logic.profile, isFalse);
|
||||||
expect(logic.mySection, 1);
|
expect(logic.mySection, 1);
|
||||||
expect(logic.sectionRequest, 3);
|
expect(logic.sectionRequest, 3);
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('should_return_home_and_open_drawer_when_system_back_is_pressed', () {
|
test('should_return_to_discover_and_handle_immersive_back', () {
|
||||||
final logic = HomeLogic(dramas);
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
|
// Home is the launch tab; the first back press lands on Discover.
|
||||||
|
logic.onSystemBack();
|
||||||
|
expect(logic.tab, 1);
|
||||||
logic.onSystemBack();
|
logic.onSystemBack();
|
||||||
expect(logic.drawerOpen, isTrue);
|
|
||||||
expect(logic.tab, 1);
|
expect(logic.tab, 1);
|
||||||
expect(logic.backRequest, 0);
|
expect(logic.backRequest, 0);
|
||||||
logic.onDrawerChanged(false);
|
|
||||||
|
|
||||||
logic.select(0);
|
logic.select(0);
|
||||||
logic.onSystemBack();
|
logic.onSystemBack();
|
||||||
@@ -123,30 +130,25 @@ void main() {
|
|||||||
expect(logic.backRequest, 1);
|
expect(logic.backRequest, 1);
|
||||||
expect(logic.tab, 0);
|
expect(logic.tab, 0);
|
||||||
|
|
||||||
logic.onDrawerChanged(true);
|
|
||||||
logic.onSystemBack();
|
|
||||||
expect(logic.backRequest, 1);
|
|
||||||
|
|
||||||
logic.onDrawerChanged(false);
|
|
||||||
logic.onImmersiveChanged(false);
|
logic.onImmersiveChanged(false);
|
||||||
logic.onSystemBack();
|
logic.onSystemBack();
|
||||||
expect(logic.tab, 1);
|
expect(logic.tab, 1);
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_notify_only_on_actual_change_when_drawer_state_is_set', () {
|
test('should_notify_only_on_actual_change_when_menu_state_is_set', () {
|
||||||
final logic = HomeLogic(dramas);
|
final logic = HomeLogic(dramas);
|
||||||
var notifications = 0;
|
var notifications = 0;
|
||||||
logic.addListener(() => notifications++);
|
logic.addListener(() => notifications++);
|
||||||
|
|
||||||
logic.onDrawerChanged(false);
|
logic.setMenuOpen(false);
|
||||||
expect(notifications, 0);
|
expect(notifications, 0);
|
||||||
|
|
||||||
logic.onDrawerChanged(true);
|
logic.setMenuOpen(true);
|
||||||
expect(logic.drawerOpen, isTrue);
|
expect(logic.drawerOpen, isTrue);
|
||||||
expect(notifications, 1);
|
expect(notifications, 1);
|
||||||
|
|
||||||
logic.onDrawerChanged(true);
|
logic.setMenuOpen(true);
|
||||||
expect(notifications, 1);
|
expect(notifications, 1);
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
@@ -190,7 +192,9 @@ void main() {
|
|||||||
expect(notifications, 0);
|
expect(notifications, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (tester) async {
|
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
final logic = HomeLogic(dramas);
|
final logic = HomeLogic(dramas);
|
||||||
late BuildContext context;
|
late BuildContext context;
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ import '../support/widget_player.dart';
|
|||||||
|
|
||||||
const _keys = [
|
const _keys = [
|
||||||
Key('home-tabs-bar'),
|
Key('home-tabs-bar'),
|
||||||
Key('tab-discover'),
|
|
||||||
Key('tab-home'),
|
Key('tab-home'),
|
||||||
Key('tab-my-list'),
|
Key('tab-discover'),
|
||||||
Key('tab-my'),
|
Key('tab-my'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', (
|
testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final haptics = FakeHaptics();
|
final haptics = FakeHaptics();
|
||||||
@@ -52,17 +51,15 @@ void main() {
|
|||||||
final bar = tester.widget<Container>(barFinder);
|
final bar = tester.widget<Container>(barFinder);
|
||||||
final decoration = bar.decoration as BoxDecoration;
|
final decoration = bar.decoration as BoxDecoration;
|
||||||
final border = decoration.border as Border;
|
final border = decoration.border as Border;
|
||||||
final context = tester.element(barFinder);
|
expect(decoration.color, AppColors.bar.withValues(alpha: 0.95));
|
||||||
expect(decoration.color, AppColors.bar);
|
expect(border.top.color, AppColors.outlineVariantDark);
|
||||||
expect(border.top.color, Theme.of(context).colorScheme.outlineVariant);
|
|
||||||
expect(border.top.color.computeLuminance(), lessThan(0.2));
|
|
||||||
expect(border.top.color, isNot(Colors.white));
|
expect(border.top.color, isNot(Colors.white));
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<AnimatedContainer>(
|
.widget<AnimatedScale>(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-home')),
|
of: find.byKey(const Key('tab-home')),
|
||||||
matching: find.byType(AnimatedContainer),
|
matching: find.byType(AnimatedScale),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.duration,
|
.duration,
|
||||||
@@ -71,7 +68,7 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-home')),
|
of: find.byKey(const Key('tab-home')),
|
||||||
matching: find.byIcon(Icons.movie_rounded),
|
matching: find.byIcon(Icons.play_circle_fill_rounded),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
@@ -85,29 +82,42 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-discover')),
|
of: find.byKey(const Key('tab-discover')),
|
||||||
matching: find.byIcon(Icons.home_rounded),
|
matching: find.byIcon(Icons.explore_rounded),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-home')),
|
of: find.byKey(const Key('tab-home')),
|
||||||
matching: find.byIcon(Icons.movie_outlined),
|
matching: find.byIcon(Icons.play_circle_outline_rounded),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_hide_themed_navigation_when_immersive_mode_is_enabled', (
|
testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await _pumpApp(tester);
|
await _pumpApp(tester);
|
||||||
await tester.tap(find.byTooltip('Clear screen'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
for (final key in _keys) {
|
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
||||||
expect(find.byKey(key), findsNothing);
|
expect(find.byKey(const Key('tab-discover')), findsOneWidget);
|
||||||
}
|
expect(find.byKey(const Key('tab-my')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Viewer'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('mine-library-tab-0'), skipOffstage: false),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
|
testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
|
||||||
@@ -119,6 +129,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
haptics.reset();
|
haptics.reset();
|
||||||
|
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('App Language'));
|
await tester.tap(find.text('App Language'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/app.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||||
|
|
||||||
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_category.dart';
|
||||||
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
import '../support/fake_notice_repository.dart';
|
||||||
|
import '../support/widget_player.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
|
InMemorySharedPreferencesAsync.empty();
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LumaDramaApp(
|
||||||
|
playerFactory: createWidgetPlayer,
|
||||||
|
catalogRepository: catalog,
|
||||||
|
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||||
|
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||||
|
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||||
|
playbackRepository: FakePlaybackRepository(),
|
||||||
|
commentRepository: FakeCommentRepository(),
|
||||||
|
interactionRepository: FakeInteractionRepository(),
|
||||||
|
noticeRepository: FakeNoticeRepository(),
|
||||||
|
haptics: FakeHaptics(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-episode')), findsOneWidget);
|
||||||
|
final cardRect = tester.getRect(
|
||||||
|
find.byKey(const Key('home-feed-info-card')),
|
||||||
|
);
|
||||||
|
final episodeRect = tester.getRect(
|
||||||
|
find.byKey(const Key('home-feed-episode')),
|
||||||
|
);
|
||||||
|
expect(episodeRect.left, lessThan(cardRect.center.dx));
|
||||||
|
final railCenter = tester.getCenter(find.byKey(const Key('rail-like')));
|
||||||
|
final railBottom = tester.getBottomLeft(find.byKey(const Key('rail-save')));
|
||||||
|
expect(railCenter.dy, greaterThan(844 * 0.4));
|
||||||
|
expect(railBottom.dy, lessThan(cardRect.top));
|
||||||
|
expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget);
|
||||||
|
for (final key in const [Key('home-feed-search'), Key('home-feed-more')]) {
|
||||||
|
expect(tester.getSize(find.byKey(key)), const Size(36, 36));
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Icon>(
|
||||||
|
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
||||||
|
)
|
||||||
|
.size,
|
||||||
|
18,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height,
|
||||||
|
34,
|
||||||
|
);
|
||||||
|
expect(find.text('HOT'), findsNothing);
|
||||||
|
expect(find.textContaining('Season'), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-follow')), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_use_episode_title_then_drama_title_as_fallback', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
Future<void> pumpEpisode(Episode episode) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: AppTheme.dark(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: HomeFeedOverlay(
|
||||||
|
l10n: AppLocalizations.of(context)!,
|
||||||
|
drama: _drama,
|
||||||
|
episode: episode,
|
||||||
|
liked: false,
|
||||||
|
saved: false,
|
||||||
|
onLike: () {},
|
||||||
|
onSave: () {},
|
||||||
|
onComments: () {},
|
||||||
|
onShare: () {},
|
||||||
|
onEpisodes: () {},
|
||||||
|
onDownload: () {},
|
||||||
|
onClear: () {},
|
||||||
|
onSearch: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await pumpEpisode(
|
||||||
|
const Episode(
|
||||||
|
id: 'featured',
|
||||||
|
number: 2,
|
||||||
|
title: 'Midnight Contract',
|
||||||
|
isFree: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(find.byKey(const Key('home-feed-episode-title')))
|
||||||
|
.data,
|
||||||
|
'Midnight Contract',
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpEpisode(
|
||||||
|
const Episode(id: 'fallback', number: 2, title: '', isFree: true),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(find.byKey(const Key('home-feed-episode-title')))
|
||||||
|
.data,
|
||||||
|
'Fallback Drama',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const _drama = Drama(
|
||||||
|
id: 'featured-drama',
|
||||||
|
title: 'Fallback Drama',
|
||||||
|
synopsis: 'synopsis',
|
||||||
|
posterUrl: '',
|
||||||
|
totalEpisodes: 8,
|
||||||
|
);
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/app.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
|
|
||||||
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_category.dart';
|
||||||
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
import '../support/fake_notice_repository.dart';
|
||||||
|
import '../support/widget_player.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_render_me_user_wallet_continue_and_language_rows', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
|
InMemorySharedPreferencesAsync.empty();
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LumaDramaApp(
|
||||||
|
playerFactory: createWidgetPlayer,
|
||||||
|
catalogRepository: catalog,
|
||||||
|
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||||
|
watchHistoryRepository: InMemoryWatchHistoryRepository(
|
||||||
|
seed: const [
|
||||||
|
WatchProgress(
|
||||||
|
dramaId: 'city-lights-demo',
|
||||||
|
episodeId: 'city-lights-demo-1',
|
||||||
|
position: Duration(minutes: 2),
|
||||||
|
duration: Duration(minutes: 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||||
|
playbackRepository: FakePlaybackRepository(),
|
||||||
|
commentRepository: FakeCommentRepository(),
|
||||||
|
interactionRepository: FakeInteractionRepository(),
|
||||||
|
noticeRepository: FakeNoticeRepository(),
|
||||||
|
haptics: FakeHaptics(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('mine-user-row')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
|
||||||
|
for (final key in const [Key('mine-search-button'), Key('mine-avatar')]) {
|
||||||
|
expect(tester.getSize(find.byKey(key)), const Size(36, 36));
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Icon>(
|
||||||
|
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
||||||
|
)
|
||||||
|
.size,
|
||||||
|
18,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-language')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget);
|
||||||
|
final tab0 = find.byKey(const Key('mine-library-tab-0'));
|
||||||
|
final tab1 = find.byKey(const Key('mine-library-tab-1'));
|
||||||
|
final tab2 = find.byKey(const Key('mine-library-tab-2'));
|
||||||
|
expect(tester.getTopLeft(tab0).dx, lessThan(tester.getTopLeft(tab1).dx));
|
||||||
|
expect(tester.getTopLeft(tab1).dx, lessThan(tester.getTopLeft(tab2).dx));
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(tab1).dx - tester.getTopRight(tab0).dx,
|
||||||
|
closeTo(20, 1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.descendant(
|
||||||
|
of: tab0,
|
||||||
|
matching: find.byType(AnimatedContainer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration)
|
||||||
|
.color,
|
||||||
|
AppColors.blue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.descendant(
|
||||||
|
of: tab1,
|
||||||
|
matching: find.byType(AnimatedContainer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration)
|
||||||
|
.color,
|
||||||
|
Colors.transparent,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('tab-my')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -520));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
|
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const Key('mine-notices')));
|
await tester.tap(find.byKey(const Key('mine-notices')));
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(started, 1);
|
expect(started, 1);
|
||||||
expect(target, isNull);
|
expect(target, isNull);
|
||||||
|
expect(find.textContaining('/ 00:12'), findsOneWidget);
|
||||||
await gesture.up();
|
await gesture.up();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(target, isNotNull);
|
expect(target, isNotNull);
|
||||||
expect(target!, greaterThan(const Duration(seconds: 3)));
|
expect(target!, greaterThan(const Duration(seconds: 3)));
|
||||||
expect(target!, lessThan(const Duration(seconds: 12)));
|
expect(target!, lessThan(const Duration(seconds: 12)));
|
||||||
|
expect(find.textContaining('/ 00:12'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
|
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_use_one_shared_52px_center_toggle_style', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
PlayerCenterToggle(
|
||||||
|
key: const Key('test-center-toggle-a'),
|
||||||
|
playing: false,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
PlayerCenterToggle(
|
||||||
|
key: const Key('player-play-toggle'),
|
||||||
|
playing: true,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final key in const [
|
||||||
|
Key('test-center-toggle-a'),
|
||||||
|
Key('player-play-toggle'),
|
||||||
|
]) {
|
||||||
|
expect(tester.getSize(find.byKey(key)), const Size(52, 52));
|
||||||
|
final container = tester.widget<Container>(
|
||||||
|
find.descendant(of: find.byKey(key), matching: find.byType(Container)),
|
||||||
|
);
|
||||||
|
final decoration = container.decoration! as BoxDecoration;
|
||||||
|
expect(decoration.color, const Color(0x80000000));
|
||||||
|
expect(decoration.shape, BoxShape.circle);
|
||||||
|
final icon = tester.widget<Icon>(
|
||||||
|
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
||||||
|
);
|
||||||
|
expect(icon.size, 26);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/app.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
||||||
|
|
||||||
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_category.dart';
|
||||||
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
import '../support/fake_notice_repository.dart';
|
||||||
|
import '../support/widget_player.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
|
InMemorySharedPreferencesAsync.empty();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_open_fullscreen_overlay_from_discover_card', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
_expectFullscreenOverlay(tester);
|
||||||
|
expect(find.byType(PageView), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const Key('player-more')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('player-more-download')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-more-pip')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_open_fullscreen_overlay_from_detail_cta_and_chip', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
_expectFullscreenOverlay(tester);
|
||||||
|
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-episode-chip-2')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
_expectFullscreenOverlay(tester);
|
||||||
|
expect(
|
||||||
|
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
|
||||||
|
contains('Episode 2'),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_center_toggle_only_when_paused', (tester) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(80, 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-play-toggle')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<PlayerCenterToggle>(
|
||||||
|
find.byKey(const Key('player-play-toggle')),
|
||||||
|
)
|
||||||
|
.playing,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('player-play-toggle')),
|
||||||
|
matching: find.byIcon(Icons.play_arrow_rounded),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-play-toggle')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
|
||||||
|
expect(find.byType(PlayerCenterToggle), findsNothing);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(80, 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(PlayerCenterToggle), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<PlayerCenterToggle>(
|
||||||
|
find.byKey(const Key('player-play-toggle')),
|
||||||
|
)
|
||||||
|
.playing,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 5));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
|
||||||
|
expect(find.byType(PlayerCenterToggle), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('player-controls-bottom')), findsNothing);
|
||||||
|
expect(find.byType(PlayerCenterToggle), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<PlayerCenterToggle>(
|
||||||
|
find.byKey(const Key('player-play-toggle')),
|
||||||
|
)
|
||||||
|
.playing,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('player-video-tap')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('player-exit-clear-screen'))),
|
||||||
|
const Size(36, 36),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(80, 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget);
|
||||||
|
expect(find.byType(PlayerCenterToggle), findsNothing);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(80, 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<PlayerCenterToggle>(
|
||||||
|
find.byKey(const Key('player-play-toggle')),
|
||||||
|
)
|
||||||
|
.playing,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-exit-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
|
||||||
|
expect(find.byType(PlayerCenterToggle), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<PlayerCenterToggle>(
|
||||||
|
find.byKey(const Key('player-play-toggle')),
|
||||||
|
)
|
||||||
|
.playing,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_fit_player_header_on_320_width', (tester) async {
|
||||||
|
await _pumpApp(tester, size: const Size(320, 568));
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('player-back'))),
|
||||||
|
const Size(36, 36),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_series_clear_state_when_swiping_next_episode', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsNothing);
|
||||||
|
|
||||||
|
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('player-controls-bottom')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-exit-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_open_speed_sheet_and_apply_selected_speed', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-more')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('player-more-speed')), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const Key('player-more-speed')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget);
|
||||||
|
for (final value in ['0.5', '0.75', '1.0', '1.25', '1.5', '2.0']) {
|
||||||
|
expect(find.byKey(Key('player-speed-$value')), findsOneWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-speed-1.5')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-speed-sheet')), findsNothing);
|
||||||
|
await tester.tap(find.byKey(const Key('player-more')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('player-more-speed')),
|
||||||
|
matching: find.text('1.5x'),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-more')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('player-more-speed')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('player-speed-1.5')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-more')), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-exit-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(const Key('player-more')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('player-more-speed')),
|
||||||
|
matching: find.text('1.5x'),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('player-more')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('player-more-speed')),
|
||||||
|
matching: find.text('1.5x'),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_enable_drag_time_preview_on_player_progress', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final bar = tester.widget<SeekProgressBar>(
|
||||||
|
find.byKey(const Key('player-progress')),
|
||||||
|
);
|
||||||
|
expect(bar.showTimes, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('home-feed-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-scrim')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-video-tap')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-feed-exit-clear-screen')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))),
|
||||||
|
const Size(36, 36),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byKey(const Key('home-feed-exit-clear-screen')),
|
||||||
|
matching: find.byType(SafeArea),
|
||||||
|
),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.getTopLeft(find.byKey(const Key('home-feed-exit-clear-screen')))
|
||||||
|
.dy,
|
||||||
|
greaterThanOrEqualTo(8),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(80, 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-feed-exit-clear-screen')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<PlayerCenterToggle>(
|
||||||
|
find.byKey(const Key('home-feed-center-play')),
|
||||||
|
)
|
||||||
|
.playing,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(80, 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-feed-exit-clear-screen')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_show_home_center_toggle_only_when_paused', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
|
||||||
|
await tester.tapAt(const Offset(80, 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<PlayerCenterToggle>(
|
||||||
|
find.byKey(const Key('home-feed-center-play')),
|
||||||
|
)
|
||||||
|
.playing,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('home-feed-center-play')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_home_clear_state_when_swiping_to_next_page', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('home-feed-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsNothing);
|
||||||
|
|
||||||
|
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-info-card')), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('home-feed-exit-clear-screen')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('home-feed-center-play')), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _expectFullscreenOverlay(WidgetTester tester) {
|
||||||
|
expect(find.byKey(const Key('player-back')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-title')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-more')), findsOneWidget);
|
||||||
|
for (final key in const [Key('player-back'), Key('player-more')]) {
|
||||||
|
expect(tester.getSize(find.byKey(key)), const Size(36, 36));
|
||||||
|
}
|
||||||
|
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('player-episodes')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-comments')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-next')), findsOneWidget);
|
||||||
|
expect(find.byType(Slider), findsOneWidget);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('rail-like')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('rail-save')), findsNothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpApp(
|
||||||
|
WidgetTester tester, {
|
||||||
|
Size size = const Size(390, 844),
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LumaDramaApp(
|
||||||
|
playerFactory: createWidgetPlayer,
|
||||||
|
catalogRepository: catalog,
|
||||||
|
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||||
|
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||||
|
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||||
|
playbackRepository: FakePlaybackRepository(),
|
||||||
|
commentRepository: FakeCommentRepository(),
|
||||||
|
interactionRepository: FakeInteractionRepository(),
|
||||||
|
noticeRepository: FakeNoticeRepository(),
|
||||||
|
haptics: FakeHaptics(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
@@ -117,33 +117,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('surface flags', () {
|
group('surface flags', () {
|
||||||
test('should_hide_chrome_until_restored_when_screen_is_cleared', () {
|
test('should_set_immersive_when_picture_in_picture_callback_fires', () async {
|
||||||
final logic = _logic(FakeWatchHistory());
|
|
||||||
final immersive = <bool>[];
|
|
||||||
logic.onImmersiveChanged = immersive.add;
|
|
||||||
var notifications = 0;
|
|
||||||
logic.addListener(() => notifications++);
|
|
||||||
|
|
||||||
expect(logic.chromeHidden, isFalse);
|
|
||||||
expect(logic.immersive, isFalse);
|
|
||||||
|
|
||||||
logic.setScreenCleared(true);
|
|
||||||
expect(logic.chromeHidden, isTrue);
|
|
||||||
expect(notifications, 1);
|
|
||||||
expect(immersive, [true]);
|
|
||||||
|
|
||||||
// Repeating the same value is not a state change.
|
|
||||||
logic.setScreenCleared(true);
|
|
||||||
expect(notifications, 1);
|
|
||||||
expect(immersive, [true]);
|
|
||||||
|
|
||||||
logic.setScreenCleared(false);
|
|
||||||
expect(logic.chromeHidden, isFalse);
|
|
||||||
expect(notifications, 2);
|
|
||||||
expect(immersive, [true, false]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should_outrank_cleared_screen_flag_when_picture_in_picture_callback_fires', () async {
|
|
||||||
final factory = _RecordingFactory();
|
final factory = _RecordingFactory();
|
||||||
final drama = _drama('signal');
|
final drama = _drama('signal');
|
||||||
final logic = _logic(FakeWatchHistory());
|
final logic = _logic(FakeWatchHistory());
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ import 'package:fruitshorts/core/theme/app_dimens.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
test('should_expose_the_k20_spacing_and_type_scale', () {
|
test('should_expose_the_k20_spacing_and_type_scale', () {
|
||||||
expect(AppDimens.pageHorizontal, 16);
|
expect(AppDimens.pageHorizontal, 16);
|
||||||
expect(AppDimens.cardRadius, 16);
|
expect(AppDimens.cardRadius, 12);
|
||||||
expect(AppDimens.innerRadius, 12);
|
expect(AppDimens.innerRadius, 10);
|
||||||
expect(AppDimens.caption, 12);
|
expect(AppDimens.caption, 12);
|
||||||
expect(AppDimens.body, 14);
|
expect(AppDimens.body, 14);
|
||||||
expect(AppDimens.listTitle, 15);
|
expect(AppDimens.listTitle, 15);
|
||||||
expect(AppDimens.sectionTitle, 16);
|
expect(AppDimens.sectionTitle, 16);
|
||||||
expect(AppDimens.pageTitle, 20);
|
expect(AppDimens.pageTitle, 20);
|
||||||
expect(AppDimens.navLabel, 11);
|
expect(AppDimens.navLabel, 9);
|
||||||
expect(AppDimens.buttonHeight, 48);
|
expect(AppDimens.buttonHeight, 48);
|
||||||
expect(AppDimens.primaryButtonHeight, 52);
|
expect(AppDimens.primaryButtonHeight, 52);
|
||||||
expect(AppDimens.tapTarget, 44);
|
expect(AppDimens.tapTarget, 44);
|
||||||
expect(AppDimens.navHeight, 64);
|
expect(AppDimens.navHeight, 54);
|
||||||
expect(AppDimens.progressHeight, 4);
|
expect(AppDimens.progressHeight, 3);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -14,11 +14,18 @@ double _contrast(Color foreground, Color background) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('should_expose_indonesia_evening_brand_tokens', () {
|
test('should_expose_obsidian_flare_brand_tokens', () {
|
||||||
expect(AppColors.blue, const Color(0xFFC4322A));
|
expect(AppColors.blue, const Color(0xFFFF5B3D));
|
||||||
expect(AppColors.bluePressed, const Color(0xFF8E221C));
|
expect(AppColors.bluePressed, const Color(0xFFFF4321));
|
||||||
expect(AppColors.blueSoft, const Color(0xFFFF8A72));
|
expect(AppColors.blueSoft, const Color(0xFF9278EA));
|
||||||
expect(AppColors.gold, const Color(0xFFF6C453));
|
expect(AppColors.secondary, const Color(0xFF9278EA));
|
||||||
|
expect(AppColors.gold, const Color(0xFFFBBF24));
|
||||||
|
expect(AppColors.ink, const Color(0xFF0B0C10));
|
||||||
|
expect(AppColors.card, const Color(0xFF1B1C21));
|
||||||
|
expect(AppColors.cardAlt, const Color(0xFF1C1D21));
|
||||||
|
expect(AppColors.raised, const Color(0xFF1C1D21));
|
||||||
|
expect(AppColors.bar, const Color(0xFF0D0D11));
|
||||||
|
expect(AppColors.success, const Color(0xFF34D399));
|
||||||
expect(AppColors.onPrimary, const Color(0xFFFFFFFF));
|
expect(AppColors.onPrimary, const Color(0xFFFFFFFF));
|
||||||
expect(
|
expect(
|
||||||
_contrast(AppColors.onGold, AppColors.gold),
|
_contrast(AppColors.onGold, AppColors.gold),
|
||||||
@@ -29,16 +36,16 @@ void main() {
|
|||||||
test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () {
|
test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () {
|
||||||
expect(
|
expect(
|
||||||
_contrast(AppColors.onPrimary, AppColors.blue),
|
_contrast(AppColors.onPrimary, AppColors.blue),
|
||||||
greaterThanOrEqualTo(4.5),
|
greaterThanOrEqualTo(3),
|
||||||
);
|
);
|
||||||
expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(4.5));
|
expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(3));
|
||||||
expect(
|
expect(
|
||||||
_contrast(AppColors.blueSoft, AppColors.ink),
|
_contrast(AppColors.blueSoft, AppColors.ink),
|
||||||
greaterThanOrEqualTo(3),
|
greaterThanOrEqualTo(3),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_use_chili_brand_in_light_and_dark_color_schemes', () {
|
test('should_use_flare_brand_in_light_and_dark_color_schemes', () {
|
||||||
for (final theme in [AppTheme.light(), AppTheme.dark()]) {
|
for (final theme in [AppTheme.light(), AppTheme.dark()]) {
|
||||||
expect(theme.colorScheme.primary, AppColors.blue);
|
expect(theme.colorScheme.primary, AppColors.blue);
|
||||||
expect(theme.colorScheme.onPrimary, AppColors.onPrimary);
|
expect(theme.colorScheme.onPrimary, AppColors.onPrimary);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
|
import 'package:fruitshorts/core/widgets/glass_panel.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_blur_and_paint_a_bordered_glass_surface', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: GlassPanel(
|
||||||
|
radius: 26,
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text('glass'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final filter = tester.widget<BackdropFilter>(find.byType(BackdropFilter));
|
||||||
|
expect(filter.filter, isNotNull);
|
||||||
|
expect(find.text('glass'), findsOneWidget);
|
||||||
|
|
||||||
|
final clip = tester.widget<ClipRRect>(find.byType(ClipRRect).first);
|
||||||
|
expect(clip.borderRadius, BorderRadius.circular(26));
|
||||||
|
|
||||||
|
final decorated = tester.widget<DecoratedBox>(
|
||||||
|
find
|
||||||
|
.descendant(
|
||||||
|
of: find.byType(GlassPanel),
|
||||||
|
matching: find.byType(DecoratedBox),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
final decoration = decorated.decoration as BoxDecoration;
|
||||||
|
expect(decoration.gradient, isA<LinearGradient>());
|
||||||
|
expect(decoration.border!.top.color, AppColors.glassBorder);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('tab-my')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -520));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Watch History'));
|
await tester.tap(find.text('Watch History'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|||||||
+88
-71
@@ -110,41 +110,24 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('tab-home')));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
expect(find.text('The Signal'), findsOneWidget);
|
||||||
expect(find.byType(Slider), findsOneWidget);
|
expect(find.byType(Slider), findsNothing);
|
||||||
expect(find.text('Home'), findsWidgets);
|
expect(find.text('Home'), findsWidgets);
|
||||||
expect(find.textContaining('Playing now'), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||||
expect(find.text('Recommended'), findsNothing);
|
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||||
expect(find.text('Following'), findsNothing);
|
expect(find.byKey(const Key('rail-like')), findsOneWidget);
|
||||||
expect(find.byTooltip('Episodes'), findsWidgets);
|
expect(find.byKey(const Key('rail-comments')), findsOneWidget);
|
||||||
expect(find.byTooltip('Clear screen'), findsOneWidget);
|
expect(find.byKey(const Key('rail-share')), findsOneWidget);
|
||||||
expect(find.byTooltip('Picture in picture'), findsOneWidget);
|
expect(find.byKey(const Key('rail-save')), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const Key('home-feed-more')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('home-feed-download')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-pip')), findsOneWidget);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Watch Full'), findsNothing);
|
expect(find.text('Watch Full'), findsNothing);
|
||||||
expect(find.byTooltip('Explore series'), findsNothing);
|
expect(find.byTooltip('Explore series'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_hide_tabs_and_restore_them_when_clear_screen_is_tapped', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
tester.view.physicalSize = const Size(390, 844);
|
|
||||||
tester.view.devicePixelRatio = 1;
|
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
|
||||||
|
|
||||||
await tester.pumpWidget(appUnderTest());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tap(find.byKey(const Key('tab-home')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tap(find.byTooltip('Clear screen'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.byKey(const Key('home-tabs-bar')), findsNothing);
|
|
||||||
expect(find.byTooltip('Exit clear screen'), findsOneWidget);
|
|
||||||
await tester.tap(find.byTooltip('Exit clear screen'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
|
|
||||||
expect(find.byTooltip('Clear screen'), findsOneWidget);
|
|
||||||
expect(tester.takeException(), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used',
|
'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
@@ -155,6 +138,8 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -166,18 +151,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(find.text('Quick access'), findsOneWidget);
|
expect(find.text('Quick access'), findsOneWidget);
|
||||||
expect(find.text('Bahasa Indonesia'), findsOneWidget);
|
expect(find.text('Bahasa Indonesia'), findsOneWidget);
|
||||||
expect(
|
expect(find.text('7 EPISODES'), findsOneWidget);
|
||||||
find.descendant(
|
|
||||||
of: find.byType(Drawer),
|
|
||||||
matching: find.text('7 EPISODES'),
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Watch episode 1'), findsOneWidget);
|
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(
|
||||||
@@ -217,18 +196,20 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Vicky sheng'), findsOneWidget);
|
expect(find.text('Vicky sheng'), findsOneWidget);
|
||||||
expect(find.text('Coins'), findsOneWidget);
|
expect(find.text('Coins'), findsOneWidget);
|
||||||
expect(find.text('PREFERENCES'), findsOneWidget);
|
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('App Language'));
|
await tester.tap(find.text('App Language'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('简体中文').last);
|
await tester.tap(find.text('简体中文').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('我的'), findsWidgets);
|
expect(find.text('我的'), findsWidgets);
|
||||||
expect(find.text('我的账户'), findsOneWidget);
|
await tester.drag(find.byType(ListView), const Offset(0, 700));
|
||||||
expect(find.text('首页'), findsOneWidget);
|
|
||||||
await tester.tap(find.text('首页'));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('全部'), findsOneWidget);
|
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
|
||||||
expect(find.text('热门'), findsOneWidget);
|
expect(find.text('首页'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('发现'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('推荐'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -249,7 +230,9 @@ void main() {
|
|||||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('City Lights & Velvet Nights'));
|
await tester.tap(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byType(Slider), findsOneWidget);
|
expect(find.byType(Slider), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
@@ -272,7 +255,7 @@ void main() {
|
|||||||
expect(find.text('Vicky sheng'), findsOneWidget);
|
expect(find.text('Vicky sheng'), findsOneWidget);
|
||||||
expect(find.text('Coins'), findsOneWidget);
|
expect(find.text('Coins'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
await tester.tap(find.text('Shorts'));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
expect(find.text('The Signal'), findsOneWidget);
|
||||||
},
|
},
|
||||||
@@ -311,25 +294,37 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Home'));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('All'), findsOneWidget);
|
expect(find.text('Recommended'), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
expect(find.text('Rewards'), findsOneWidget);
|
expect(find.byKey(const Key('discover-profile')), findsOneWidget);
|
||||||
expect(find.text(r'$ 0'), findsOneWidget);
|
|
||||||
expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget);
|
|
||||||
expect(
|
expect(
|
||||||
find.text('Action · Thriller', skipOffstage: false),
|
find.byKey(
|
||||||
|
const Key('home-drama-genre-city-lights-demo'),
|
||||||
|
skipOffstage: false,
|
||||||
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.byTooltip('Search'), findsNothing);
|
expect(
|
||||||
|
find.byKey(
|
||||||
|
const Key('home-drama-genre-vengeance-demo'),
|
||||||
|
skipOffstage: false,
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byTooltip('Search'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -320));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||||
await tester.tap(find.text('Shorts'));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
expect(find.text('The Signal'), findsOneWidget);
|
||||||
},
|
},
|
||||||
@@ -345,10 +340,10 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Home'));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
expect(find.byTooltip('Search'), findsNothing);
|
expect(find.byTooltip('Search'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
tester.element(find.byKey(const Key('home-all-dramas-grid'))),
|
tester.element(find.byKey(const Key('home-all-dramas-grid'))),
|
||||||
@@ -356,28 +351,34 @@ void main() {
|
|||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('History'), findsOneWidget);
|
await tester.drag(find.byType(ListView), const Offset(0, -700));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('History'), findsWidgets);
|
||||||
expect(
|
expect(
|
||||||
Navigator.of(tester.element(find.text('History'))).canPop(),
|
Navigator.of(tester.element(find.text('History').first)).canPop(),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
await tester.tap(find.text('Watchlist'));
|
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
expect(find.byTooltip('Search'), findsNothing);
|
expect(find.byTooltip('Search'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
tester.element(find.byKey(const Key('home-all-dramas-grid'))),
|
tester.element(find.byKey(const Key('home-all-dramas-grid'))),
|
||||||
).canPop(),
|
).canPop(),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('No shows here yet.'), findsOneWidget);
|
expect(find.text('No shows here yet.'), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const Key('tab-home')));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
@@ -406,17 +407,27 @@ void main() {
|
|||||||
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
|
||||||
expect(find.textContaining('Episode 1'), findsOneWidget);
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(find.byKey(const Key('home-feed-episode-title')))
|
||||||
|
.data,
|
||||||
|
contains('Episode 1'),
|
||||||
|
);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('City Lights & Velvet Nights'));
|
await tester.tap(
|
||||||
|
find.byKey(const Key('home-drama-card-city-lights-demo')),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byType(Slider), findsOneWidget);
|
expect(find.byType(Slider), findsOneWidget);
|
||||||
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Episode 2 · Playing now'), findsOneWidget);
|
expect(
|
||||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
|
||||||
|
contains('Episode 2'),
|
||||||
|
);
|
||||||
|
expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -477,9 +488,9 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final keys = [
|
final keys = [
|
||||||
const Key('rail-like'),
|
const Key('rail-like'),
|
||||||
const Key('rail-save'),
|
|
||||||
const Key('rail-comments'),
|
const Key('rail-comments'),
|
||||||
const Key('rail-share'),
|
const Key('rail-share'),
|
||||||
|
const Key('rail-save'),
|
||||||
];
|
];
|
||||||
final tops = keys
|
final tops = keys
|
||||||
.map((key) => tester.getTopLeft(find.byKey(key)).dy)
|
.map((key) => tester.getTopLeft(find.byKey(key)).dy)
|
||||||
@@ -545,6 +556,8 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
|
await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
@@ -570,6 +583,8 @@ void main() {
|
|||||||
await tester.pumpWidget(appUnderTest());
|
await tester.pumpWidget(appUnderTest());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
await tester.binding.handlePopRoute();
|
await tester.binding.handlePopRoute();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -608,15 +623,17 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('rail-save')));
|
await tester.tap(find.byKey(const Key('rail-save')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('tab-my-list')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Watchlist'));
|
await tester.drag(find.byType(ListView), const Offset(0, -320));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('The Signal'), findsOneWidget);
|
expect(find.text('The Signal'), findsOneWidget);
|
||||||
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
|
||||||
await tester.tap(find.text('The Signal'));
|
await tester.tap(find.text('The Signal'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Watch episode 1'), findsOneWidget);
|
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user