feat(client): C 端 1-2 账号链路(邮箱登录/注册 + 令牌与 401 + 匿名/登录分叉)

为什么:客户端此前完全没有账号概念(mine 页的"登录"只是本地 demo 开关),
而受限动作(收藏/评论)要对到具体账号。本批把账号链路接上,为后续互动、进度同步、下载打底。

- AuthApi + AuthController + AuthSessionStore:注册/登录/登出/刷新,令牌与设备号存 shared_preferences
  (用 SharedPreferencesAsync,与 engagement 仓库同一套 API)
- ApiClient:统一带 Authorization 与 X-Device-Id;401 自动刷新一次并重试,刷新失败清登录态走 onUnauthorized
- 登录/注册页(pages/auth,沿用 binding/logic/view 与现有视觉):邮箱格式、密码 8-64 位含字母数字本地先校验,
  错误提示走 l10n;登录/注册一键切换
- 匿名/登录分叉:首页、发现、详情保持匿名可看;收藏/点赞等受限动作走 ensureSignedIn() 弹底部提示引导登录
- mine 页展示真实账号(昵称 + uid),拿不到才回落到演示文案
- 测试:新增 auth 控制器(注册落盘/401 刷新重试/刷新失败清会话/登出)与登录页(校验/切换)用例;
  widget_test 改为预置本地会话(账号不再是本地开关);修掉 playback 用例对 host 大小写的环境依赖断言

门禁:flutter analyze 0 问题;flutter test 148 全过。
This commit is contained in:
Vicky committed 2026-10-15 17:53:35 +08:00
1 parent 77d5748dbb
commit e1b98df509
24 files changed
+2765 -910

No files matched your search

+46 -6
View File
@@ -2,8 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'core/config/locale_controller.dart'; import 'core/config/locale_controller.dart';
import 'core/data/remote/api_client.dart';
import 'core/data/remote/auth_api.dart';
import 'core/data/remote/catalog_api.dart';
import 'core/data/repositories/catalog_repository.dart'; import 'core/data/repositories/catalog_repository.dart';
import 'core/data/repositories/engagement_repository.dart'; import 'core/data/repositories/engagement_repository.dart';
import 'core/data/services/auth_controller.dart';
import 'core/data/services/auth_session_store.dart';
import 'core/data/services/catalog_controller.dart'; import 'core/data/services/catalog_controller.dart';
import 'core/data/services/engagement_controller.dart'; import 'core/data/services/engagement_controller.dart';
import 'core/data/services/playback_session.dart'; import 'core/data/services/playback_session.dart';
@@ -11,19 +16,53 @@ import 'core/data/services/video_player_port.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'pages/home/home_view.dart'; import 'pages/home/home_view.dart';
class LumaDramaApp extends StatelessWidget { class LumaDramaApp extends StatefulWidget {
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new}); const LumaDramaApp({
super.key,
this.playerFactory = VideoPlayerPort.new,
this.catalogRepository,
});
final PlayerFactory playerFactory; final PlayerFactory playerFactory;
/// 测试注入用;生产走 HTTP 仓库。
final CatalogRepository? catalogRepository;
@override
State<LumaDramaApp> createState() => _LumaDramaAppState();
}
class _LumaDramaAppState extends State<LumaDramaApp> {
late final AuthSessionStore _sessionStore = AuthSessionStore();
/// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求
/// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。
late final ApiClient _apiClient = ApiClient(
accessTokenReader: () => _auth.accessToken,
tokenRefresher: () => _auth.refresh(),
onUnauthorized: () => _auth.clear(),
deviceIdReader: _sessionStore.deviceId,
);
late final AuthController _auth = AuthController(AuthApi(_apiClient), _sessionStore);
@override
void initState() {
super.initState();
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
_auth.load();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
Provider(create: (_) => PlaybackCoordinator()), Provider(create: (_) => PlaybackCoordinator()),
Provider<PlayerFactory>(create: (_) => playerFactory), Provider<PlayerFactory>(create: (_) => widget.playerFactory),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => CatalogController(DemoCatalogRepository())..load(), create: (_) => CatalogController(
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)),
)..load(),
), ),
ChangeNotifierProvider(create: (_) => LocaleController()..load()), ChangeNotifierProvider(create: (_) => LocaleController()..load()),
ChangeNotifierProvider( ChangeNotifierProvider(
@@ -31,6 +70,7 @@ class LumaDramaApp extends StatelessWidget {
EngagementController(SharedPreferencesEngagementRepository()) EngagementController(SharedPreferencesEngagementRepository())
..load(), ..load(),
), ),
ChangeNotifierProvider.value(value: _auth),
], ],
child: Consumer<LocaleController>( child: Consumer<LocaleController>(
builder: (context, locale, _) => MaterialApp( builder: (context, locale, _) => MaterialApp(
@@ -51,14 +91,14 @@ class LumaDramaApp extends StatelessWidget {
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6D4AFF), seedColor: const Color(0xFF2F7BFF),
), ),
scaffoldBackgroundColor: const Color(0xFFF8F7FC), scaffoldBackgroundColor: const Color(0xFFF8F7FC),
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF9B86FF), seedColor: const Color(0xFF2F7BFF),
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
scaffoldBackgroundColor: const Color(0xFF101019), scaffoldBackgroundColor: const Color(0xFF101019),
+51
View File
@@ -0,0 +1,51 @@
/// 登录态:服务端下发的令牌与用户摘要。
///
/// 只保留客户端真正要用的字段(uid / 昵称 / 头像),不做整份 AppUserRespVO 的映射 ——
/// 首页卡片、个人页需要的都在这里,避免为用不到的字段维护一份平行模型。
class AuthUser {
const AuthUser({required this.uid, required this.nickname, this.avatarUrl = ''});
final String uid;
final String nickname;
final String avatarUrl;
factory AuthUser.fromJson(Map<String, dynamic> json) => AuthUser(
uid: json['uid'] as String? ?? '',
nickname: json['nickname'] as String? ?? '',
avatarUrl: json['avatarUrl'] as String? ?? '',
);
Map<String, dynamic> toJson() => {
'uid': uid,
'nickname': nickname,
'avatarUrl': avatarUrl,
};
}
class AuthSession {
const AuthSession({
required this.accessToken,
required this.refreshToken,
this.expiresIn = 0,
required this.user,
});
final String accessToken;
final String refreshToken;
final int expiresIn;
final AuthUser user;
factory AuthSession.fromJson(Map<String, dynamic> json) => AuthSession(
accessToken: json['accessToken'] as String? ?? '',
refreshToken: json['refreshToken'] as String? ?? '',
expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 0,
user: AuthUser.fromJson((json['user'] as Map?)?.cast<String, dynamic>() ?? const {}),
);
Map<String, dynamic> toJson() => {
'accessToken': accessToken,
'refreshToken': refreshToken,
'expiresIn': expiresIn,
'user': user.toJson(),
};
}
+130
View File
@@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../config/app_config.dart';
/// 接口异常:code 为后端业务码,message 可直接展示。
class ApiException implements Exception {
const ApiException(this.code, this.message);
final int code;
final String message;
@override
String toString() => 'ApiException($code, $message)';
}
/// 后端统一响应壳 `{code, message, data}` 的解析入口。
class ApiClient {
ApiClient({
String? baseUrl,
http.Client? client,
this.accessTokenReader,
this.tokenRefresher,
this.onUnauthorized,
this.deviceIdReader,
})
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? http.Client();
final String baseUrl;
final http.Client _client;
/// 取当前 access token(未登录返回 null)
final String? Function()? accessTokenReader;
/// 刷新令牌:返回 true 表示刷新成功,可以重试原请求
final Future<bool> Function()? tokenRefresher;
/// 刷新也失败时回调(清本地登录态、回收 UI)
final void Function()? onUnauthorized;
/// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用
final Future<String> Function()? deviceIdReader;
Future<Object?> get(String path, {Map<String, String>? query}) {
return _send('GET', path, query: query);
}
Future<Object?> post(
String path, {
Object? body,
Map<String, String>? query,
bool skipAuthRetry = false,
}) {
return _send('POST', path, body: body, query: query, skipAuthRetry: skipAuthRetry);
}
Future<Object?> _send(
String method,
String path, {
Object? body,
Map<String, String>? query,
bool retried = false,
bool skipAuthRetry = false,
}) async {
final uri = Uri.parse(
'$baseUrl$path',
).replace(queryParameters: query?.isEmpty ?? true ? null : query);
final headers = await _headers(withBody: body != null);
final response = await (method == 'GET'
? _client.get(uri, headers: headers)
: _client.post(
uri,
headers: headers,
body: body == null ? null : jsonEncode(body),
))
.timeout(AppConfig.requestTimeout);
Map<String, dynamic> shell = const {};
try {
shell = (jsonDecode(utf8.decode(response.bodyBytes)) as Map)
.cast<String, dynamic>();
} catch (_) {
shell = const {};
}
final code = shell['code'] as int? ?? -1;
// 令牌过期:刷新一次再重试原请求;刷新失败就清登录态并抛给调用方
if (code == 401 && !retried && !skipAuthRetry && tokenRefresher != null) {
final refreshed = await tokenRefresher!.call();
if (refreshed) {
return _send(
method,
path,
body: body,
query: query,
retried: true,
skipAuthRetry: skipAuthRetry,
);
}
onUnauthorized?.call();
throw ApiException(401, shell['message'] as String? ?? '登录已过期');
}
if (code == -1) {
throw ApiException(response.statusCode, 'HTTP ${response.statusCode}');
}
if (code != 0) {
throw ApiException(code, shell['message'] as String? ?? '请求失败');
}
return shell['data'];
}
Future<Map<String, String>> _headers({required bool withBody}) async {
final headers = <String, String>{'Accept': 'application/json'};
if (withBody) headers['Content-Type'] = 'application/json';
final token = accessTokenReader?.call();
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final deviceId = await deviceIdReader?.call();
if (deviceId != null && deviceId.isNotEmpty) {
headers['X-Device-Id'] = deviceId;
}
return headers;
}
void close() => _client.close();
}
+73
View File
@@ -0,0 +1,73 @@
import '../models/auth_session.dart';
import 'api_client.dart';
/// 账号相关接口(/app/auth/**)。
///
/// 注册/登录都用「邮箱 + 密码」;邮箱注册不需要验证码(后端按 targetType 放开),
/// 手机号注册仍要验证码 —— 客户端这一批只开放邮箱入口。
class AuthApi {
const AuthApi(this._client);
final ApiClient _client;
Future<AuthSession> register({
required String email,
required String password,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/register',
body: {
'targetType': 'EMAIL',
'target': email,
'password': password,
'countryCode': 'ID',
'deviceId': deviceId,
'platform': platform,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> login({
required String email,
required String password,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/login',
body: {
'targetType': 'EMAIL',
'target': email,
'password': password,
'deviceId': deviceId,
'platform': platform,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> refresh(String refreshToken) async {
final data = await _client.post(
'/app/auth/refresh',
body: {'refreshToken': refreshToken},
// 刷新请求本身不带旧令牌重试,失败就是失败
skipAuthRetry: true,
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<void> logout(String refreshToken) async {
await _client.post(
'/app/auth/logout',
body: {'refreshToken': refreshToken},
skipAuthRetry: true,
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/foundation.dart';
import '../models/auth_session.dart';
import '../remote/api_client.dart';
import '../remote/auth_api.dart';
import 'auth_session_store.dart';
/// 登录态的唯一来源。
///
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
class AuthController extends ChangeNotifier {
AuthController(this._api, this._store);
final AuthApi _api;
final AuthSessionStore _store;
AuthSession? _session;
String _deviceId = '';
bool _loaded = false;
bool get isLoaded => _loaded;
bool get isSignedIn => _session != null;
AuthUser? get user => _session?.user;
String? get accessToken => _session?.accessToken;
String get deviceId => _deviceId;
/// App 启动时调用:恢复本地令牌与设备号。
Future<void> load() async {
_deviceId = await _store.deviceId();
_session = await _store.read();
_loaded = true;
notifyListeners();
}
Future<void> register({required String email, required String password}) async {
final session = await _api.register(
email: email,
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
);
await _apply(session);
}
Future<void> signIn({required String email, required String password}) async {
final session = await _api.login(
email: email,
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
);
await _apply(session);
}
Future<void> signOut() async {
final refreshToken = _session?.refreshToken ?? '';
_session = null;
await _store.clear();
notifyListeners();
if (refreshToken.isNotEmpty) {
// 服务端吊销失败不影响本地已登出,异常直接吞掉
try {
await _api.logout(refreshToken);
} catch (_) {}
}
}
/// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。
Future<bool> refresh() async {
final refreshToken = _session?.refreshToken ?? '';
if (refreshToken.isEmpty) {
await clear();
return false;
}
try {
await _apply(await _api.refresh(refreshToken));
return true;
} catch (_) {
await clear();
return false;
}
}
Future<void> clear() async {
if (_session == null) return;
_session = null;
await _store.clear();
notifyListeners();
}
Future<void> _apply(AuthSession session) async {
_session = session;
await _store.save(session);
notifyListeners();
}
String get _platform {
if (kIsWeb) return 'WEB';
return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID';
}
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/auth_session.dart';
/// 令牌与设备号的本地持久化。
///
/// 用 `SharedPreferencesAsync`(与项目里 engagement 仓库一致):旧的
/// `SharedPreferences.getInstance()` 在新版本里已标注弃用,两套混用还会让
/// widget 测试只 mock 到其中一套。
///
/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**;
/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。
class AuthSessionStore {
static const _kAccessToken = 'auth.accessToken';
static const _kRefreshToken = 'auth.refreshToken';
static const _kUser = 'auth.user';
static const _kDeviceId = 'device.id';
String? _cachedDeviceId;
Future<String> deviceId() async {
if (_cachedDeviceId != null) return _cachedDeviceId!;
final prefs = SharedPreferencesAsync();
var value = await prefs.getString(_kDeviceId) ?? '';
if (value.isEmpty) {
final random = Random();
value =
'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-'
'${random.nextInt(1 << 32).toRadixString(36)}';
await prefs.setString(_kDeviceId, value);
}
_cachedDeviceId = value;
return value;
}
Future<AuthSession?> read() async {
final prefs = SharedPreferencesAsync();
final accessToken = await prefs.getString(_kAccessToken) ?? '';
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
final rawUser = await prefs.getString(_kUser);
Map<String, dynamic> user = const {};
if (rawUser != null && rawUser.isNotEmpty) {
try {
user = (jsonDecode(rawUser) as Map).cast<String, dynamic>();
} catch (_) {
user = const {};
}
}
return AuthSession(
accessToken: accessToken,
refreshToken: refreshToken,
user: AuthUser.fromJson(user),
);
}
Future<void> save(AuthSession session) async {
final prefs = SharedPreferencesAsync();
await prefs.setString(_kAccessToken, session.accessToken);
await prefs.setString(_kRefreshToken, session.refreshToken);
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
}
Future<void> clear() async {
final prefs = SharedPreferencesAsync();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUser);
// 设备号不清:它代表"这台设备",与登录态无关
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../pages/auth/auth_view.dart';
import '../data/services/auth_controller.dart';
import '../theme/app_colors.dart';
/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库)
Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AuthPage(signUpMode: signUpMode),
),
);
}
/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。
///
/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。
Future<bool> ensureSignedIn(BuildContext context) async {
final auth = context.read<AuthController>();
if (auth.isSignedIn) return true;
final l10n = AppLocalizations.of(context)!;
final go = await showModalBottomSheet<bool>(
context: context,
backgroundColor: AppColors.card,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.authGateTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
l10n.authGateMessage,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(sheetContext).pop(false),
child: Text(l10n.cancel),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
),
onPressed: () => Navigator.of(sheetContext).pop(true),
child: Text(l10n.authGateAction),
),
),
],
),
],
),
),
),
);
if (go == true && context.mounted) {
await openAuthPage(context);
}
return false;
}
+51 -1
View File
@@ -96,6 +96,8 @@
"lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.", "lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.",
"playbackError": "This clip could not be played.", "playbackError": "This clip could not be played.",
"catalogError": "The catalog could not be loaded.", "catalogError": "The catalog could not be loaded.",
"detailError": "The episode list could not be loaded.",
"noMediaNotice": "This episode has no playable media yet.",
"noStories": "No stories available yet.", "noStories": "No stories available yet.",
"retry": "Retry", "retry": "Retry",
"backToSeries": "Back to series", "backToSeries": "Back to series",
@@ -130,5 +132,53 @@
"continueWatchingHeading": "CONTINUE WATCHING", "continueWatchingHeading": "CONTINUE WATCHING",
"preferencesHeading": "PREFERENCES", "preferencesHeading": "PREFERENCES",
"brandMark": "NONTONKI · DEMO", "brandMark": "NONTONKI · DEMO",
"shareFooter": "Nontonki demo" "shareFooter": "Nontonki demo",
"shortsTab": "Shorts",
"myListTab": "My List",
"watchFull": "Watch Full",
"swipeForNext": "Swipe up for next",
"clearScreen": "Clear screen",
"exitClearScreen": "Exit clear screen",
"logIn": "Log in",
"logInHint": "Keep your coins and list",
"rewards": "Rewards",
"forYou": "For You",
"hot": "Hot",
"newest": "New",
"favorite": "Favorite",
"myAccount": "My account",
"coins": "Coins",
"topUpWallet": "Top Up Wallet",
"vipMembership": "VIP Membership",
"notActive": "Not Active",
"joinVip": "Join VIP",
"earnRewards": "Earn Rewards",
"freeBonus": "Get Free Bonus",
"watchHistory": "Watch History",
"settings": "Settings",
"signOut": "Sign Out",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
"mockNotice": "Sample data only.",
"topBadge": "TOP 1",
"signIn": "Sign In",
"signUp": "Sign Up",
"authSignInTitle": "Welcome back",
"authSignUpTitle": "Create your account",
"authSubtitle": "Sign in with your email and password.",
"authEmailLabel": "Email",
"authPasswordLabel": "Password",
"authPasswordHint": "At least 8 characters, letters and numbers",
"authEmailRequired": "Enter your email",
"authEmailInvalid": "That email doesn't look right",
"authPasswordRequired": "Enter your password",
"authPasswordLength": "Password must be 8-64 characters",
"authPasswordWeak": "Password needs both letters and numbers",
"authNetworkError": "Network error, please try again",
"authSwitchToSignUp": "New here? Create an account",
"authSwitchToSignIn": "Already have an account? Sign in",
"authGateTitle": "Sign in to continue",
"authGateMessage": "Favorites and comments need an account.",
"authGateAction": "Sign In",
"cancel": "Not now"
} }
+300
View File
@@ -632,6 +632,18 @@ abstract class AppLocalizations {
/// **'The catalog could not be loaded.'** /// **'The catalog could not be loaded.'**
String get catalogError; String get catalogError;
/// No description provided for @detailError.
///
/// In en, this message translates to:
/// **'The episode list could not be loaded.'**
String get detailError;
/// No description provided for @noMediaNotice.
///
/// In en, this message translates to:
/// **'This episode has no playable media yet.'**
String get noMediaNotice;
/// No description provided for @noStories. /// No description provided for @noStories.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -841,6 +853,294 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Nontonki demo'** /// **'Nontonki demo'**
String get shareFooter; String get shareFooter;
/// No description provided for @shortsTab.
///
/// In en, this message translates to:
/// **'Shorts'**
String get shortsTab;
/// No description provided for @myListTab.
///
/// In en, this message translates to:
/// **'My List'**
String get myListTab;
/// No description provided for @watchFull.
///
/// In en, this message translates to:
/// **'Watch Full'**
String get watchFull;
/// No description provided for @swipeForNext.
///
/// In en, this message translates to:
/// **'Swipe up for next'**
String get swipeForNext;
/// No description provided for @clearScreen.
///
/// In en, this message translates to:
/// **'Clear screen'**
String get clearScreen;
/// No description provided for @exitClearScreen.
///
/// In en, this message translates to:
/// **'Exit clear screen'**
String get exitClearScreen;
/// No description provided for @logIn.
///
/// In en, this message translates to:
/// **'Log in'**
String get logIn;
/// No description provided for @logInHint.
///
/// In en, this message translates to:
/// **'Keep your coins and list'**
String get logInHint;
/// No description provided for @rewards.
///
/// In en, this message translates to:
/// **'Rewards'**
String get rewards;
/// No description provided for @forYou.
///
/// In en, this message translates to:
/// **'For You'**
String get forYou;
/// No description provided for @hot.
///
/// In en, this message translates to:
/// **'Hot'**
String get hot;
/// No description provided for @newest.
///
/// In en, this message translates to:
/// **'New'**
String get newest;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @myAccount.
///
/// In en, this message translates to:
/// **'My account'**
String get myAccount;
/// No description provided for @coins.
///
/// In en, this message translates to:
/// **'Coins'**
String get coins;
/// No description provided for @topUpWallet.
///
/// In en, this message translates to:
/// **'Top Up Wallet'**
String get topUpWallet;
/// No description provided for @vipMembership.
///
/// In en, this message translates to:
/// **'VIP Membership'**
String get vipMembership;
/// No description provided for @notActive.
///
/// In en, this message translates to:
/// **'Not Active'**
String get notActive;
/// No description provided for @joinVip.
///
/// In en, this message translates to:
/// **'Join VIP'**
String get joinVip;
/// No description provided for @earnRewards.
///
/// In en, this message translates to:
/// **'Earn Rewards'**
String get earnRewards;
/// No description provided for @freeBonus.
///
/// In en, this message translates to:
/// **'Get Free Bonus'**
String get freeBonus;
/// No description provided for @watchHistory.
///
/// In en, this message translates to:
/// **'Watch History'**
String get watchHistory;
/// No description provided for @settings.
///
/// In en, this message translates to:
/// **'Settings'**
String get settings;
/// No description provided for @signOut.
///
/// In en, this message translates to:
/// **'Sign Out'**
String get signOut;
/// No description provided for @mockUserName.
///
/// In en, this message translates to:
/// **'Vicky sheng'**
String get mockUserName;
/// No description provided for @mockUserId.
///
/// In en, this message translates to:
/// **'ID: M1CZWVYE'**
String get mockUserId;
/// No description provided for @mockNotice.
///
/// In en, this message translates to:
/// **'Sample data only.'**
String get mockNotice;
/// No description provided for @topBadge.
///
/// In en, this message translates to:
/// **'TOP 1'**
String get topBadge;
/// No description provided for @signIn.
///
/// In en, this message translates to:
/// **'Sign In'**
String get signIn;
/// No description provided for @signUp.
///
/// In en, this message translates to:
/// **'Sign Up'**
String get signUp;
/// No description provided for @authSignInTitle.
///
/// In en, this message translates to:
/// **'Welcome back'**
String get authSignInTitle;
/// No description provided for @authSignUpTitle.
///
/// In en, this message translates to:
/// **'Create your account'**
String get authSignUpTitle;
/// No description provided for @authSubtitle.
///
/// In en, this message translates to:
/// **'Sign in with your email and password.'**
String get authSubtitle;
/// No description provided for @authEmailLabel.
///
/// In en, this message translates to:
/// **'Email'**
String get authEmailLabel;
/// No description provided for @authPasswordLabel.
///
/// In en, this message translates to:
/// **'Password'**
String get authPasswordLabel;
/// No description provided for @authPasswordHint.
///
/// In en, this message translates to:
/// **'At least 8 characters, letters and numbers'**
String get authPasswordHint;
/// No description provided for @authEmailRequired.
///
/// In en, this message translates to:
/// **'Enter your email'**
String get authEmailRequired;
/// No description provided for @authEmailInvalid.
///
/// In en, this message translates to:
/// **'That email doesn\'t look right'**
String get authEmailInvalid;
/// No description provided for @authPasswordRequired.
///
/// In en, this message translates to:
/// **'Enter your password'**
String get authPasswordRequired;
/// No description provided for @authPasswordLength.
///
/// In en, this message translates to:
/// **'Password must be 8-64 characters'**
String get authPasswordLength;
/// No description provided for @authPasswordWeak.
///
/// In en, this message translates to:
/// **'Password needs both letters and numbers'**
String get authPasswordWeak;
/// No description provided for @authNetworkError.
///
/// In en, this message translates to:
/// **'Network error, please try again'**
String get authNetworkError;
/// No description provided for @authSwitchToSignUp.
///
/// In en, this message translates to:
/// **'New here? Create an account'**
String get authSwitchToSignUp;
/// No description provided for @authSwitchToSignIn.
///
/// In en, this message translates to:
/// **'Already have an account? Sign in'**
String get authSwitchToSignIn;
/// No description provided for @authGateTitle.
///
/// In en, this message translates to:
/// **'Sign in to continue'**
String get authGateTitle;
/// No description provided for @authGateMessage.
///
/// In en, this message translates to:
/// **'Favorites and comments need an account.'**
String get authGateMessage;
/// No description provided for @authGateAction.
///
/// In en, this message translates to:
/// **'Sign In'**
String get authGateAction;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Not now'**
String get cancel;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+150
View File
@@ -294,6 +294,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get catalogError => 'The catalog could not be loaded.'; String get catalogError => 'The catalog could not be loaded.';
@override
String get detailError => 'The episode list could not be loaded.';
@override
String get noMediaNotice => 'This episode has no playable media yet.';
@override @override
String get noStories => 'No stories available yet.'; String get noStories => 'No stories available yet.';
@@ -401,4 +407,148 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get shareFooter => 'Nontonki demo'; String get shareFooter => 'Nontonki demo';
@override
String get shortsTab => 'Shorts';
@override
String get myListTab => 'My List';
@override
String get watchFull => 'Watch Full';
@override
String get swipeForNext => 'Swipe up for next';
@override
String get clearScreen => 'Clear screen';
@override
String get exitClearScreen => 'Exit clear screen';
@override
String get logIn => 'Log in';
@override
String get logInHint => 'Keep your coins and list';
@override
String get rewards => 'Rewards';
@override
String get forYou => 'For You';
@override
String get hot => 'Hot';
@override
String get newest => 'New';
@override
String get favorite => 'Favorite';
@override
String get myAccount => 'My account';
@override
String get coins => 'Coins';
@override
String get topUpWallet => 'Top Up Wallet';
@override
String get vipMembership => 'VIP Membership';
@override
String get notActive => 'Not Active';
@override
String get joinVip => 'Join VIP';
@override
String get earnRewards => 'Earn Rewards';
@override
String get freeBonus => 'Get Free Bonus';
@override
String get watchHistory => 'Watch History';
@override
String get settings => 'Settings';
@override
String get signOut => 'Sign Out';
@override
String get mockUserName => 'Vicky sheng';
@override
String get mockUserId => 'ID: M1CZWVYE';
@override
String get mockNotice => 'Sample data only.';
@override
String get topBadge => 'TOP 1';
@override
String get signIn => 'Sign In';
@override
String get signUp => 'Sign Up';
@override
String get authSignInTitle => 'Welcome back';
@override
String get authSignUpTitle => 'Create your account';
@override
String get authSubtitle => 'Sign in with your email and password.';
@override
String get authEmailLabel => 'Email';
@override
String get authPasswordLabel => 'Password';
@override
String get authPasswordHint => 'At least 8 characters, letters and numbers';
@override
String get authEmailRequired => 'Enter your email';
@override
String get authEmailInvalid => 'That email doesn\'t look right';
@override
String get authPasswordRequired => 'Enter your password';
@override
String get authPasswordLength => 'Password must be 8-64 characters';
@override
String get authPasswordWeak => 'Password needs both letters and numbers';
@override
String get authNetworkError => 'Network error, please try again';
@override
String get authSwitchToSignUp => 'New here? Create an account';
@override
String get authSwitchToSignIn => 'Already have an account? Sign in';
@override
String get authGateTitle => 'Sign in to continue';
@override
String get authGateMessage => 'Favorites and comments need an account.';
@override
String get authGateAction => 'Sign In';
@override
String get cancel => 'Not now';
} }
+150
View File
@@ -289,6 +289,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get catalogError => '剧目列表加载失败。'; String get catalogError => '剧目列表加载失败。';
@override
String get detailError => '剧集信息加载失败。';
@override
String get noMediaNotice => '该剧集还没有可播放的片源。';
@override @override
String get noStories => '暂无剧目。'; String get noStories => '暂无剧目。';
@@ -393,4 +399,148 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get shareFooter => 'Nontonki 演示'; String get shareFooter => 'Nontonki 演示';
@override
String get shortsTab => '短剧';
@override
String get myListTab => '片单';
@override
String get watchFull => '看全集';
@override
String get swipeForNext => '上滑看下一部';
@override
String get clearScreen => '清屏';
@override
String get exitClearScreen => '退出清屏';
@override
String get logIn => '登录';
@override
String get logInHint => '登录后保存金币和片单';
@override
String get rewards => '奖励';
@override
String get forYou => '为你推荐';
@override
String get hot => '热门';
@override
String get newest => '最新';
@override
String get favorite => '收藏';
@override
String get myAccount => '我的账户';
@override
String get coins => '金币';
@override
String get topUpWallet => '充值';
@override
String get vipMembership => '会员';
@override
String get notActive => '未开通';
@override
String get joinVip => '开通会员';
@override
String get earnRewards => '赚奖励';
@override
String get freeBonus => '领免费奖励';
@override
String get watchHistory => '观看历史';
@override
String get settings => '设置';
@override
String get signOut => '退出登录';
@override
String get mockUserName => 'Vicky sheng';
@override
String get mockUserId => 'ID: M1CZWVYE';
@override
String get mockNotice => '当前是示例数据。';
@override
String get topBadge => 'TOP 1';
@override
String get signIn => '登录';
@override
String get signUp => '注册';
@override
String get authSignInTitle => '欢迎回来';
@override
String get authSignUpTitle => '创建账号';
@override
String get authSubtitle => '用邮箱和密码登录。';
@override
String get authEmailLabel => '邮箱';
@override
String get authPasswordLabel => '密码';
@override
String get authPasswordHint => '至少 8 位,需包含字母和数字';
@override
String get authEmailRequired => '请输入邮箱';
@override
String get authEmailInvalid => '邮箱格式不正确';
@override
String get authPasswordRequired => '请输入密码';
@override
String get authPasswordLength => '密码长度需在 8-64 位之间';
@override
String get authPasswordWeak => '密码需同时包含字母和数字';
@override
String get authNetworkError => '网络异常,请稍后重试';
@override
String get authSwitchToSignUp => '还没有账号?去注册';
@override
String get authSwitchToSignIn => '已有账号?去登录';
@override
String get authGateTitle => '登录后可继续';
@override
String get authGateMessage => '收藏、评论等操作需要登录。';
@override
String get authGateAction => '去登录';
@override
String get cancel => '暂不';
} }
+51 -1
View File
@@ -91,6 +91,8 @@
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。", "lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
"playbackError": "此视频暂时无法播放。", "playbackError": "此视频暂时无法播放。",
"catalogError": "剧目列表加载失败。", "catalogError": "剧目列表加载失败。",
"detailError": "剧集信息加载失败。",
"noMediaNotice": "该剧集还没有可播放的片源。",
"noStories": "暂无剧目。", "noStories": "暂无剧目。",
"retry": "重试", "retry": "重试",
"backToSeries": "返回剧集详情", "backToSeries": "返回剧集详情",
@@ -125,5 +127,53 @@
"continueWatchingHeading": "继续观看", "continueWatchingHeading": "继续观看",
"preferencesHeading": "偏好设置", "preferencesHeading": "偏好设置",
"brandMark": "NONTONKI · 演示", "brandMark": "NONTONKI · 演示",
"shareFooter": "Nontonki 演示" "shareFooter": "Nontonki 演示",
"shortsTab": "短剧",
"myListTab": "片单",
"watchFull": "看全集",
"swipeForNext": "上滑看下一部",
"clearScreen": "清屏",
"exitClearScreen": "退出清屏",
"logIn": "登录",
"logInHint": "登录后保存金币和片单",
"rewards": "奖励",
"forYou": "为你推荐",
"hot": "热门",
"newest": "最新",
"favorite": "收藏",
"myAccount": "我的账户",
"coins": "金币",
"topUpWallet": "充值",
"vipMembership": "会员",
"notActive": "未开通",
"joinVip": "开通会员",
"earnRewards": "赚奖励",
"freeBonus": "领免费奖励",
"watchHistory": "观看历史",
"settings": "设置",
"signOut": "退出登录",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
"mockNotice": "当前是示例数据。",
"topBadge": "TOP 1",
"signIn": "登录",
"signUp": "注册",
"authSignInTitle": "欢迎回来",
"authSignUpTitle": "创建账号",
"authSubtitle": "用邮箱和密码登录。",
"authEmailLabel": "邮箱",
"authPasswordLabel": "密码",
"authPasswordHint": "至少 8 位,需包含字母和数字",
"authEmailRequired": "请输入邮箱",
"authEmailInvalid": "邮箱格式不正确",
"authPasswordRequired": "请输入密码",
"authPasswordLength": "密码长度需在 8-64 位之间",
"authPasswordWeak": "密码需同时包含字母和数字",
"authNetworkError": "网络异常,请稍后重试",
"authSwitchToSignUp": "还没有账号?去注册",
"authSwitchToSignIn": "已有账号?去登录",
"authGateTitle": "登录后可继续",
"authGateMessage": "收藏、评论等操作需要登录。",
"authGateAction": "去登录",
"cancel": "暂不"
} }
+6
View File
@@ -0,0 +1,6 @@
import 'auth_logic.dart';
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
class AuthBinding {
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
}
+50
View File
@@ -0,0 +1,50 @@
/// 登录 / 注册页的表单状态与提交逻辑。
///
/// 校验规则与后端一致(邮箱格式、密码 8-64 位且含字母数字),本地先拦一次是为了少一次网络往返、
/// 也让用户立刻看到哪个字段不合格;后端仍会再校验一遍(前端校验不作为安全边界)。
class AuthLogic {
bool signUpMode;
String email = '';
String password = '';
bool busy = false;
String? errorMessage;
AuthLogic({this.signUpMode = false, this.errorMessage});
void toggleMode() {
signUpMode = !signUpMode;
errorMessage = null;
}
/// 返回 null 表示校验通过,否则返回错误文案(由视图用 l10n 映射)
AuthFieldError? validate() {
final mail = email.trim();
if (mail.isEmpty) return AuthFieldError.emailRequired;
if (!_emailPattern.hasMatch(mail)) return AuthFieldError.emailInvalid;
if (password.isEmpty) return AuthFieldError.passwordRequired;
if (password.length < 8 || password.length > 64) {
return AuthFieldError.passwordLength;
}
if (!_hasLetter.hasMatch(password) || !_hasDigit.hasMatch(password)) {
return AuthFieldError.passwordWeak;
}
return null;
}
String get normalizedEmail => email.trim().toLowerCase();
static final _emailPattern = RegExp(
r'^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?'
r'(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,}$',
);
static final _hasLetter = RegExp('[A-Za-z]');
static final _hasDigit = RegExp('[0-9]');
}
enum AuthFieldError {
emailRequired,
emailInvalid,
passwordRequired,
passwordLength,
passwordWeak,
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import 'auth_logic.dart';
/// 登录 / 注册页:邮箱 + 密码。
///
/// 风格沿用 `nontonki` 现有页面(深色卡片 + 圆角 + 主色蓝),不另起一套视觉。
class AuthPage extends StatefulWidget {
const AuthPage({super.key, this.signUpMode = false});
final bool signUpMode;
@override
State<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
late AuthLogic _logic;
@override
void initState() {
super.initState();
_logic = AuthLogic(signUpMode: widget.signUpMode);
}
String _errorText(AppLocalizations l10n, AuthFieldError error) {
switch (error) {
case AuthFieldError.emailRequired:
return l10n.authEmailRequired;
case AuthFieldError.emailInvalid:
return l10n.authEmailInvalid;
case AuthFieldError.passwordRequired:
return l10n.authPasswordRequired;
case AuthFieldError.passwordLength:
return l10n.authPasswordLength;
case AuthFieldError.passwordWeak:
return l10n.authPasswordWeak;
}
}
Future<void> _submit() async {
final l10n = AppLocalizations.of(context)!;
final invalid = _logic.validate();
if (invalid != null) {
setState(() => _logic.errorMessage = _errorText(l10n, invalid));
return;
}
setState(() {
_logic.busy = true;
_logic.errorMessage = null;
});
final auth = context.read<AuthController>();
try {
if (_logic.signUpMode) {
await auth.register(
email: _logic.normalizedEmail,
password: _logic.password,
);
} else {
await auth.signIn(
email: _logic.normalizedEmail,
password: _logic.password,
);
}
if (!mounted) return;
Navigator.of(context).maybePop();
} on ApiException catch (error) {
// 后端文案已经是可直接展示的中文/印尼语提示,错误码不外露
setState(() => _logic.errorMessage = error.message);
} catch (_) {
setState(() => _logic.errorMessage = l10n.authNetworkError);
} finally {
if (mounted) setState(() => _logic.busy = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
elevation: 0,
foregroundColor: Colors.white,
title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
children: [
Text(
_logic.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
l10n.authSubtitle,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 22),
_field(
label: l10n.authEmailLabel,
hint: 'name@example.com',
keyboardType: TextInputType.emailAddress,
onChanged: (value) => _logic.email = value,
autofocus: true,
),
const SizedBox(height: 14),
_field(
label: l10n.authPasswordLabel,
hint: l10n.authPasswordHint,
obscure: true,
onChanged: (value) => _logic.password = value,
),
if (_logic.errorMessage != null) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: Color(0xFFFF6B6B), size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
_logic.errorMessage!,
style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 13),
),
),
],
),
],
const SizedBox(height: 22),
SizedBox(
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
onPressed: _logic.busy ? null : _submit,
child: _logic.busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
_logic.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(height: 14),
TextButton(
onPressed: _logic.busy
? null
: () => setState(() => _logic.toggleMode()),
child: Text(
_logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp,
style: const TextStyle(color: AppColors.blueSoft, fontSize: 13),
),
),
],
),
),
);
}
Widget _field({
required String label,
required String hint,
required ValueChanged<String> onChanged,
bool obscure = false,
bool autofocus = false,
TextInputType? keyboardType,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
TextField(
autofocus: autofocus,
obscureText: obscure,
keyboardType: keyboardType,
onChanged: onChanged,
style: const TextStyle(color: Colors.white, fontSize: 15),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFF6C7280), fontSize: 14),
filled: true,
fillColor: AppColors.card,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
],
);
}
}
+38 -9
View File
@@ -1,16 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../discover/discover_logic.dart'; import '../discover/discover_logic.dart';
import '../drama_detail/drama_detail_view.dart'; import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_logic.dart'; import '../mine/mine_logic.dart';
import '../player/player_view.dart';
class HomeLogic extends ChangeNotifier { class HomeLogic extends ChangeNotifier {
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first; HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
final scaffoldKey = GlobalKey<ScaffoldState>(); final scaffoldKey = GlobalKey<ScaffoldState>();
Drama currentDrama; Drama currentDrama;
int tab = 0; int tab = 1;
int searchRequest = 0; int searchRequest = 0;
int sectionRequest = 0; int sectionRequest = 0;
int resumeRequest = 0; int resumeRequest = 0;
@@ -19,6 +22,7 @@ class HomeLogic extends ChangeNotifier {
bool drawerOpen = false; bool drawerOpen = false;
bool detailOpen = false; bool detailOpen = false;
bool videoImmersive = false; bool videoImmersive = false;
bool profile = false;
bool _disposed = false; bool _disposed = false;
void notify() { void notify() {
@@ -62,9 +66,7 @@ class HomeLogic extends ChangeNotifier {
if (!context.mounted) return; if (!context.mounted) return;
try { try {
await Navigator.of(context).push<void>( await Navigator.of(context).push<void>(
MaterialPageRoute( MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)),
builder: (_) => DramaDetailPage(drama: currentDrama),
),
); );
} finally { } finally {
if (context.mounted) { if (context.mounted) {
@@ -80,6 +82,34 @@ class HomeLogic extends ChangeNotifier {
); );
} }
Future<void> playDrama(BuildContext context, Drama drama) async {
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
var playable = drama;
try {
playable = await context.read<CatalogController>().requireDetail(drama);
} catch (_) {
// 取详情失败就用卡片自带的那一集起播。
}
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(drama: playable, initialIndex: 0),
),
);
}
void openProfile() {
profile = true;
scaffoldKey.currentState?.closeDrawer();
select(2, mySection: 0);
}
void openLibrary({int section = 1}) {
profile = false;
scaffoldKey.currentState?.closeDrawer();
select(2, mySection: section);
}
void discoverAction(BuildContext context, DiscoverPageAction action) { void discoverAction(BuildContext context, DiscoverPageAction action) {
switch (action) { switch (action) {
case DiscoverPageAction.home: case DiscoverPageAction.home:
@@ -87,7 +117,7 @@ class HomeLogic extends ChangeNotifier {
case DiscoverPageAction.my: case DiscoverPageAction.my:
select(2); select(2);
case DiscoverPageAction.watchlist: case DiscoverPageAction.watchlist:
select(2, watchlist: true); openLibrary();
case DiscoverPageAction.resume: case DiscoverPageAction.resume:
select(0, resume: true); select(0, resume: true);
case DiscoverPageAction.detail: case DiscoverPageAction.detail:
@@ -112,11 +142,10 @@ class HomeLogic extends ChangeNotifier {
} else if (videoImmersive) { } else if (videoImmersive) {
backRequest++; backRequest++;
notify(); notify();
} else if (tab != 0) { } else if (tab != 1) {
select(0); select(1);
} else { } else {
backRequest++; openMenu();
notify();
} }
} }
+73 -28
View File
@@ -1,8 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.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/services/auth_controller.dart';
import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_controller.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart'; import '../discover/discover_view.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
@@ -29,7 +34,6 @@ class HomePage extends StatelessWidget {
CatalogController catalog, CatalogController catalog,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
if (catalog.loading) { if (catalog.loading) {
return const Scaffold( return const Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -46,6 +50,13 @@ class HomePage extends StatelessWidget {
Text(catalog.error == null ? l10n.noStories : l10n.catalogError), Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)), FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
if (kDebugMode) ...[
const SizedBox(height: 16),
Text(
AppConfig.apiBaseUrl,
style: const TextStyle(fontSize: 12, color: Colors.white54),
),
],
], ],
), ),
), ),
@@ -92,8 +103,11 @@ class _HomeTabsState extends State<_HomeTabs> {
); );
} }
Widget _scaffold(BuildContext context) { Widget _scaffold(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
// 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关)
final signedIn = context.watch<AuthController>().isSignedIn;
return PopScope( return PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
@@ -107,8 +121,8 @@ class _HomeTabsState extends State<_HomeTabs> {
drama: logic.currentDrama, drama: logic.currentDrama,
onOpenSeries: () => logic.openCurrentSeries(context), onOpenSeries: () => logic.openCurrentSeries(context),
onDiscover: () => logic.selectFromMenu(1), onDiscover: () => logic.selectFromMenu(1),
onHistory: () => logic.selectFromMenu(2, mySection: 0), onHistory: () => logic.openLibrary(section: 0),
onWatchlist: () => logic.selectFromMenu(2, mySection: 1), onWatchlist: logic.openLibrary,
onLiked: () => logic.selectFromMenu(2, mySection: 2), onLiked: () => logic.selectFromMenu(2, mySection: 2),
), ),
drawerEnableOpenDragGesture: false, drawerEnableOpenDragGesture: false,
@@ -127,7 +141,7 @@ class _HomeTabsState extends State<_HomeTabs> {
resumeRequest: logic.resumeRequest, resumeRequest: logic.resumeRequest,
backRequest: logic.backRequest, backRequest: logic.backRequest,
onDiscover: () => logic.select(1), onDiscover: () => logic.select(1),
onMy: () => logic.select(2), onMy: logic.openProfile,
onSearch: () => logic.select(1, search: true), onSearch: () => logic.select(1, search: true),
onMenu: logic.openMenu, onMenu: logic.openMenu,
onImmersiveChanged: logic.onImmersiveChanged, onImmersiveChanged: logic.onImmersiveChanged,
@@ -135,11 +149,15 @@ class _HomeTabsState extends State<_HomeTabs> {
DiscoverPage( DiscoverPage(
drama: logic.currentDrama, drama: logic.currentDrama,
dramas: widget.dramas, dramas: widget.dramas,
onDramaSelected: (drama) => logic.openDrama(context, drama), onDramaSelected: (drama) => logic.playDrama(context, drama),
duration: Duration.zero, duration: Duration.zero,
embedded: true, embedded: true,
active: logic.tab == 1, active: logic.tab == 1,
searchRequest: logic.searchRequest, searchRequest: logic.searchRequest,
signedIn: signedIn,
onSignIn: () => openAuthPage(context),
onCoins: logic.openProfile,
onRewards: logic.openProfile,
onAction: (action) => logic.discoverAction(context, action), onAction: (action) => logic.discoverAction(context, action),
), ),
MyPage( MyPage(
@@ -148,10 +166,15 @@ class _HomeTabsState extends State<_HomeTabs> {
onDramaSelected: (drama) => logic.openDrama(context, drama), onDramaSelected: (drama) => logic.openDrama(context, drama),
duration: Duration.zero, duration: Duration.zero,
initialSection: logic.mySection, initialSection: logic.mySection,
profile: logic.profile,
sectionRequest: logic.sectionRequest, sectionRequest: logic.sectionRequest,
embedded: true, embedded: true,
active: logic.tab == 2, active: logic.tab == 2,
onAction: logic.myAction, onAction: logic.myAction,
onOpenHistory: () => logic.openLibrary(section: 0),
signedIn: signedIn,
onSignIn: () => openAuthPage(context),
onSignOut: () => context.read<AuthController>().signOut(),
), ),
], ],
), ),
@@ -167,28 +190,38 @@ class _HomeTabsState extends State<_HomeTabs> {
border: Border(top: BorderSide(color: Color(0xFF242329))), border: Border(top: BorderSide(color: Color(0xFF242329))),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_TabButton(
key: const Key('tab-home'),
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
selected: logic.tab == 0,
onTap: () => logic.select(0),
),
_TabButton( _TabButton(
key: const Key('tab-discover'), key: const Key('tab-discover'),
icon: Icons.explore_outlined, icon: Icons.home_outlined,
label: l10n.discoverTab, selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
selected: logic.tab == 1, selected: logic.tab == 1,
onTap: () => logic.select(1), onTap: () => logic.select(1),
), ),
_TabButton(
key: const Key('tab-home'),
icon: Icons.movie_outlined,
selectedIcon: Icons.movie_rounded,
label: l10n.shortsTab,
selected: logic.tab == 0,
onTap: () => logic.select(0),
),
_TabButton(
key: const Key('tab-my-list'),
icon: Icons.bookmark_border_rounded,
selectedIcon: Icons.bookmark_rounded,
label: l10n.myListTab,
selected: logic.tab == 2 && !logic.profile,
onTap: logic.openLibrary,
),
_TabButton( _TabButton(
key: const Key('tab-my'), key: const Key('tab-my'),
icon: Icons.video_library_outlined, icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab, label: l10n.myTab,
selected: logic.tab == 2, selected: logic.tab == 2 && logic.profile,
onTap: () => logic.select(2), onTap: logic.openProfile,
), ),
], ],
), ),
@@ -203,35 +236,47 @@ class _TabButton extends StatelessWidget {
const _TabButton({ const _TabButton({
super.key, super.key,
required this.icon, required this.icon,
required this.selectedIcon,
required this.label, required this.label,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
}); });
final IconData icon; final IconData icon;
final IconData selectedIcon;
final String label; final String label;
final bool selected; final bool selected;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
Widget build(BuildContext context) => InkWell( Widget build(BuildContext context) => Expanded(
child: InkWell(
onTap: onTap, onTap: onTap,
child: SizedBox(
width: 80,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Container(
icon, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3),
size: 21, decoration: selected
color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE), ? BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(14),
)
: null,
child: Icon(
selected ? selectedIcon : icon,
size: 20,
color: selected ? Colors.white : AppColors.muted,
), ),
),
const SizedBox(height: 2),
Text( Text(
label, label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: selected fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
? const Color(0xFFFF5C37) color: selected ? AppColors.blue : AppColors.muted,
: const Color(0xFFE6E1DE),
), ),
), ),
], ],
+235 -177
View File
@@ -4,7 +4,10 @@ import 'package:provider/provider.dart';
import '../../core/formatting/localized_content.dart'; import '../../core/formatting/localized_content.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/engagement_controller.dart'; import '../../core/data/services/engagement_controller.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'mine_binding.dart'; import 'mine_binding.dart';
import 'mine_logic.dart'; import 'mine_logic.dart';
@@ -15,11 +18,16 @@ class MyPage extends StatefulWidget {
required this.drama, required this.drama,
required this.duration, required this.duration,
this.initialSection = 0, this.initialSection = 0,
this.profile = false,
this.onOpenHistory,
this.history, this.history,
this.embedded = false, this.embedded = false,
this.active = true, this.active = true,
this.sectionRequest = 0, this.sectionRequest = 0,
this.onAction, this.onAction,
this.signedIn = false,
this.onSignIn,
this.onSignOut,
this.dramas, this.dramas,
this.onDramaSelected, this.onDramaSelected,
}); });
@@ -27,11 +35,16 @@ class MyPage extends StatefulWidget {
final Drama drama; final Drama drama;
final Duration duration; final Duration duration;
final int initialSection; final int initialSection;
final bool profile;
final VoidCallback? onOpenHistory;
final WatchHistoryRepository? history; final WatchHistoryRepository? history;
final bool embedded; final bool embedded;
final bool active; final bool active;
final int sectionRequest; final int sectionRequest;
final ValueChanged<MyPageAction>? onAction; final ValueChanged<MyPageAction>? onAction;
final bool signedIn;
final VoidCallback? onSignIn;
final VoidCallback? onSignOut;
final List<Drama>? dramas; final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected; final ValueChanged<Drama>? onDramaSelected;
@@ -86,7 +99,7 @@ class _MyPageState extends State<MyPage> {
bottom: !widget.embedded, bottom: !widget.embedded,
child: Column( child: Column(
children: [ children: [
_topBar(l10n), if (!widget.profile) _topBar(l10n),
Expanded( Expanded(
child: FutureBuilder<Duration?>( child: FutureBuilder<Duration?>(
future: logic.position, future: logic.position,
@@ -101,15 +114,13 @@ class _MyPageState extends State<MyPage> {
1.0, 1.0,
); );
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: [ children: [
_profile(l10n), if (widget.profile)
const SizedBox(height: 22), ..._accountPage(l10n)
_continueCard(l10n, fraction, position, total), else ...[
const SizedBox(height: 26),
_library(l10n, position, total, fraction), _library(l10n, position, total, fraction),
const SizedBox(height: 26), ],
_preferences(l10n),
const SizedBox(height: 24), const SizedBox(height: 24),
Center( Center(
child: Text( child: Text(
@@ -147,8 +158,8 @@ class _MyPageState extends State<MyPage> {
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27), const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27),
const SizedBox(width: 7), const SizedBox(width: 7),
Text( Text(
l10n.myTab, l10n.myListTab,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
@@ -172,12 +183,74 @@ class _MyPageState extends State<MyPage> {
), ),
); );
Widget _profile(AppLocalizations l10n) => Row( List<Widget> _accountPage(AppLocalizations l10n) => [
if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n),
if (widget.signedIn) ...[const SizedBox(height: 18), _wallet(l10n)],
const SizedBox(height: 14),
_menuCard(l10n),
const SizedBox(height: 18),
Text(
l10n.preferencesHeading,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 1.1,
),
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft),
title: Text(l10n.appLanguage),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => logic.showLanguage(context),
),
];
Widget _guestRow(AppLocalizations l10n) {
return Row(
children: [ children: [
const CircleAvatar( const CircleAvatar(
radius: 30, radius: 28,
backgroundColor: Color(0xFF32343A), backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 34), child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.logInHint,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: widget.onSignIn,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: AppColors.blue,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: const StadiumBorder(),
),
child: Text(l10n.logIn),
),
],
);
}
Widget _signedInRow(AppLocalizations l10n) {
// 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案
final user = context.watch<AuthController>().user;
final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName;
final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId;
return Row(
children: [
const CircleAvatar(
radius: 28,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -185,145 +258,172 @@ class _MyPageState extends State<MyPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
l10n.localViewer, name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800), style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
), ),
const SizedBox(height: 4), ),
const SizedBox(height: 2),
Text( Text(
l10n.localDemoProfile, uidText,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 12), style: const TextStyle(color: AppColors.muted, fontSize: 12),
), ),
], ],
), ),
), ),
Container( TextButton(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6), onPressed: widget.onSignOut ?? () => _mockSnack(l10n),
decoration: BoxDecoration( style: TextButton.styleFrom(
color: const Color(0xFF24252A), backgroundColor: const Color(0xFF2A2C33),
borderRadius: BorderRadius.circular(20), foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: const StadiumBorder(),
), ),
child: Text(l10n.demo, style: const TextStyle(fontSize: 11)), child: Text(l10n.signOut),
), ),
], ],
); );
}
Widget _continueCard( Widget _wallet(AppLocalizations l10n) {
AppLocalizations l10n, return Container(
double fraction, padding: const EdgeInsets.all(14),
Duration position,
Duration total,
) => Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF201B1E), color: const Color(0xFF17191F),
borderRadius: BorderRadius.circular(13), borderRadius: BorderRadius.circular(16),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(
children: [
const Icon(Icons.history_rounded, color: _orange, size: 17),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
),
Text( Text(
l10n.episodeProgress(1, widget.drama.episodes.length), l10n.myAccount,
style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
_poster(width: 75, height: 96),
const SizedBox(width: 11),
Expanded( Expanded(
child: _accountTile(
label: l10n.coins,
value: '\$ 0',
action: l10n.topUpWallet,
color: AppColors.blue,
onPressed: () => _mockSnack(l10n),
),
),
const SizedBox(width: 10),
Expanded(
child: _accountTile(
label: l10n.vipMembership,
value: l10n.notActive,
action: l10n.joinVip,
color: const Color(0xFFF5C451),
onPressed: () => _mockSnack(l10n),
),
),
],
),
],
),
);
}
void _mockSnack(AppLocalizations l10n) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.mockNotice)));
}
Widget _accountTile({
required String label,
required String value,
required String action,
required Color color,
required VoidCallback onPressed,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF12141A),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withValues(alpha: 0.45)),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(label, style: TextStyle(color: color, fontSize: 12)),
localizedDramaTitle(l10n, widget.drama),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
l10n.dramaTag,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
const SizedBox(height: 10),
Text(
l10n.watchedPercent((fraction * 100).round()),
style: const TextStyle(
color: Color(0xFFE7DEDD),
fontSize: 11,
),
),
const SizedBox(height: 5),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: fraction,
minHeight: 4,
backgroundColor: const Color(0xFF444046),
color: _orange,
),
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'${_clock(position)} / ${_clock(total)}', value,
style: const TextStyle( style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
color: Color(0xFFE6E1E3),
fontSize: 12,
), ),
), const SizedBox(height: 10),
],
),
),
],
),
const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton(
onPressed: () => logic.act( onPressed: onPressed,
context,
MyPageAction.resume,
embedded: widget.embedded,
onAction: widget.onAction,
),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: _orange, backgroundColor: color,
foregroundColor: Colors.white, foregroundColor: color == AppColors.blue
minimumSize: const Size.fromHeight(37), ? Colors.white
shape: RoundedRectangleBorder( : const Color(0xFF2A2110),
borderRadius: BorderRadius.circular(9), visualDensity: VisualDensity.compact,
shape: const StadiumBorder(),
), ),
), child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: Text(l10n.resume),
), ),
), ),
], ],
), ),
); );
}
Widget _menuCard(AppLocalizations l10n) {
return Material(
color: const Color(0xFF17191F),
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(
leading: const Icon(
Icons.card_giftcard_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.earnRewards),
trailing: Text(
l10n.freeBonus,
style: const TextStyle(color: AppColors.blue, fontSize: 12),
),
onTap: () => _mockSnack(l10n),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.history_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.watchHistory),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: widget.onOpenHistory,
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.settings_rounded,
color: AppColors.blueSoft,
),
title: Text(l10n.settings),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => logic.showLanguage(context),
),
],
),
);
}
Widget _library( Widget _library(
AppLocalizations l10n, AppLocalizations l10n,
@@ -353,14 +453,25 @@ class _MyPageState extends State<MyPage> {
for (var index = 0; index < tabs.length; index++) ...[ for (var index = 0; index < tabs.length; index++) ...[
InkWell( InkWell(
onTap: () => logic.selectSection(index), onTap: () => logic.selectSection(index),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: logic.section == index
? AppColors.blue
: const Color(0xFF24262E),
borderRadius: BorderRadius.circular(20),
),
child: Text( child: Text(
tabs[index], tabs[index],
style: TextStyle( style: const TextStyle(
color: logic.section == index color: Colors.white,
? _orange fontSize: 14,
: const Color(0xFFE4DBDD), fontWeight: FontWeight.w700,
fontSize: 16, ),
fontWeight: FontWeight.w800,
), ),
), ),
), ),
@@ -446,7 +557,7 @@ class _MyPageState extends State<MyPage> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: Color(0xFFFFA18D), color: AppColors.blueSoft,
fontSize: 10, fontSize: 10,
), ),
), ),
@@ -507,12 +618,7 @@ class _MyPageState extends State<MyPage> {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Image.asset( child: RemoteImage(drama.posterUrl, width: 74, height: 103),
drama.posterAsset,
width: 74,
height: 103,
fit: BoxFit.cover,
),
), ),
const SizedBox(width: 11), const SizedBox(width: 11),
Expanded( Expanded(
@@ -530,7 +636,7 @@ class _MyPageState extends State<MyPage> {
), ),
const SizedBox(height: 7), const SizedBox(height: 7),
Text( Text(
l10n.episodeCount(drama.episodes.length), l10n.episodeCount(drama.episodeCount),
style: const TextStyle( style: const TextStyle(
color: Color(0xFFE6E1E3), color: Color(0xFFE6E1E3),
fontSize: 12, fontSize: 12,
@@ -545,49 +651,6 @@ class _MyPageState extends State<MyPage> {
), ),
); );
Widget _preferences(AppLocalizations l10n) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.preferencesHeading,
style: const TextStyle(
color: Color(0xFFCEBBB9),
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
const SizedBox(height: 12),
Material(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: const Icon(
Icons.translate_rounded,
color: Color(0xFFFFBC65),
),
title: Text(l10n.appLanguage),
subtitle: Text(l10n.language),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Localizations.localeOf(context).languageCode == 'zh'
? l10n.simplifiedChinese
: l10n.english,
style: const TextStyle(color: Color(0xFFFFB2A1), fontSize: 11),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, size: 18),
],
),
onTap: () => logic.showLanguage(context),
),
),
],
);
Widget _bottomNav(AppLocalizations l10n) => Container( Widget _bottomNav(AppLocalizations l10n) => Container(
height: 63, height: 63,
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -629,12 +692,7 @@ class _MyPageState extends State<MyPage> {
Widget _poster({required double width, required double height}) => ClipRRect( Widget _poster({required double width, required double height}) => ClipRRect(
borderRadius: BorderRadius.circular(7), borderRadius: BorderRadius.circular(7),
child: Image.asset( child: RemoteImage(widget.drama.posterUrl, width: width, height: height),
widget.drama.posterAsset,
width: width,
height: height,
fit: BoxFit.cover,
),
); );
} }
@@ -683,4 +741,4 @@ String _clock(Duration duration) {
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
} }
const _orange = Color(0xFFFF5B3D); const _orange = AppColors.blue;
+230 -504
View File
@@ -10,10 +10,8 @@ import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/playback_session.dart'; import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/localized_content.dart'; import '../../core/formatting/localized_content.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../home/home_feed_sections.dart';
import '../home/widgets/category_swipe_region.dart';
import '../home/widgets/home_feed_header.dart';
import 'player_binding.dart'; import 'player_binding.dart';
import 'player_logic.dart'; import 'player_logic.dart';
import 'widgets/seek_progress_bar.dart'; import 'widgets/seek_progress_bar.dart';
@@ -100,21 +98,16 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
Widget _buildPlayer(BuildContext context) { Widget _buildPlayer(BuildContext context) {
final engagement = context.watch<EngagementController>(); final engagement = context.watch<EngagementController>();
final category = logic.category;
final indices = logic.visibleIndices; final indices = logic.visibleIndices;
final pages = logic.pages; final pages = logic.pages;
final content = AnimatedBuilder( final content = AnimatedBuilder(
animation: logic.session, animation: logic.session,
builder: (context, _) => indices.isEmpty && widget.homeMode builder: (context, _) => PageView.builder(
? _buildEmptyCategory(context)
: PageView.builder(
key: ValueKey(category),
controller: pages, controller: pages,
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
allowImplicitScrolling: true, allowImplicitScrolling: true,
itemCount: indices.length, itemCount: indices.length,
onPageChanged: (position) { onPageChanged: (position) {
if (category != logic.category) return;
final index = indices[position]; final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index)); unawaited(logic.selectEpisode(index));
@@ -134,54 +127,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
); );
} }
Widget _buildEmptyCategory(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CategorySwipeRegion(
key: const Key('home-empty-swipe'),
onShift: logic.shiftCategory,
child: ColoredBox(
color: const Color(0xFF111214),
child: SafeArea(
child: Column(
children: [
HomeFeedHeader(
l10n: l10n,
category: logic.category,
onCategorySelected: (value) =>
unawaited(logic.selectCategory(value)),
onCategoryShift: logic.shiftCategory,
onSearch: () => logic.showSearch(context),
onMy: () => logic.showMy(context),
onMenu: widget.onMenu,
),
const Spacer(),
const Icon(
Icons.video_library_outlined,
size: 48,
color: Color(0xFFE6E1E3),
),
const SizedBox(height: 12),
Text(
l10n.noCategoryDramas,
style: const TextStyle(fontSize: 16, color: Colors.white),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
l10n.demoCatalogNotice,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: Color(0xFFF3EEEA)),
),
),
const Spacer(),
],
),
),
),
);
}
Widget _buildEpisode( Widget _buildEpisode(
BuildContext context, BuildContext context,
int index, int index,
@@ -190,20 +135,25 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index); final entry = logic.queue.entryAt(index);
final drama = entry.drama; final drama = entry.drama;
final episode = entry.episode; final episode = widget.homeMode
? logic.displayEpisode(drama, entry.episode)
: entry.episode;
final selected = index == logic.session.currentIndex; final selected = index == logic.session.currentIndex;
// Selection changes before the prepared slot is promoted. Use its controller // Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder. // throughout the handoff instead of briefly falling back to a placeholder.
final port = final port =
(selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); (selected ? logic.session.active : null) ??
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;
Widget? video; Widget? video;
if (ready) { if (ready) {
video = GestureDetector( video = GestureDetector(
onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
child: FittedBox( child: FittedBox(
fit: logic.immersive ? BoxFit.contain : BoxFit.cover, fit: theater ? BoxFit.contain : BoxFit.cover,
child: SizedBox( child: SizedBox(
width: videoPort!.controller.value.size.width, width: videoPort!.controller.value.size.width,
height: videoPort.controller.value.size.height, height: videoPort.controller.value.size.height,
@@ -214,28 +164,14 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
), ),
), ),
); );
if (widget.homeMode) {
video = CategorySwipeRegion(
key: selected ? const Key('home-video-swipe') : null,
onShift: logic.shiftCategory,
child: video,
);
}
} }
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (widget.homeMode && !ready && !logic.immersive)
CategorySwipeRegion(
key: selected ? const Key('home-video-swipe') : null,
onShift: logic.shiftCategory,
child: const ColoredBox(color: Colors.black),
)
else
const ColoredBox(color: Colors.black), const ColoredBox(color: Colors.black),
?video, ?video,
if (!logic.immersive) if (!logic.chromeHidden)
const IgnorePointer( const IgnorePointer(
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -243,31 +179,92 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Color(0xD6000000),
Color(0x66000000), Color(0x66000000),
Color(0x00000000),
Color(0x33000000), Color(0x33000000),
Color(0xF0000000), Color(0xE6000000),
], ],
stops: [0, 0.22, 0.5, 1], stops: [0, 0.16, 0.55, 1],
), ),
), ),
), ),
), ),
if (selected && logic.session.loading) if (selected && logic.session.loading)
const Center( const Center(
child: CircularProgressIndicator(color: Color(0xFFFF6B45)), child: CircularProgressIndicator(color: Color(0xFF2F7BFF)),
),
if (selected && !ready && logic.session.error != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
l10n.playbackError,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
// 锁定集与还没上传片源的剧集都会走到这里,之前是一片纯黑,看不出原因。
if (selected &&
!ready &&
logic.session.error == null &&
logic.session.isLocked)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.movie_filter_outlined,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
), ),
if (selected && ready && !logic.session.userWantsPlay && !logic.pip) if (selected && ready && !logic.session.userWantsPlay && !logic.pip)
Center( const Center(
child: IconButton.filled( child: IgnorePointer(
iconSize: 48, child: DecoratedBox(
onPressed: () => logic.session.setUserWantsPlay(true), decoration: BoxDecoration(
icon: const Icon(Icons.play_arrow_rounded), color: Color(0x73000000),
shape: BoxShape.circle,
),
child: Padding(
padding: EdgeInsets.all(10),
child: Icon(
Icons.play_arrow_rounded,
size: 48,
color: Colors.white,
), ),
), ),
if (widget.homeMode && (logic.pip || logic.pipPreparing)) ),
),
),
if (logic.pip || logic.pipPreparing)
const SizedBox.expand() const SizedBox.expand()
else if (widget.homeMode && logic.fullscreen) else if (logic.fullscreen)
_FullscreenControls( _FullscreenControls(
l10n: l10n, l10n: l10n,
controller: ready ? videoPort!.controller : null, controller: ready ? videoPort!.controller : null,
@@ -276,238 +273,62 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
? () => unawaited(logic.enterPip(context)) ? () => unawaited(logic.enterPip(context))
: null, : null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
) )
else if (widget.homeMode) else if (!logic.screenCleared)
_StitchHomeOverlay( _PlaybackOverlay(
l10n: l10n, l10n: l10n,
drama: drama, drama: drama,
episode: episode, episode: episode,
controller: ready ? videoPort!.controller : null, controller: ready ? videoPort!.controller : null,
liked: engagement.isLiked(drama.id), liked: engagement.isLiked(drama.id),
saved: engagement.isSaved(drama.id), saved: engagement.isSaved(drama.id),
muted: logic.muted, onBack: widget.embedded
category: logic.category, ? null
onCategorySelected: (value) => unawaited(logic.selectCategory(value)), : () => unawaited(logic.leave(context)),
onCategoryShift: logic.shiftCategory,
onDetail: () => logic.openDetail(context),
onDiscover: () => logic.showDiscover(context), onDiscover: () => logic.showDiscover(context),
onEpisodes: () => logic.showEpisodes(context), onEpisodes: () => logic.showEpisodes(context),
onMy: () => logic.showMy(context), onMy: () => logic.showMy(context),
onSearch: () => logic.showSearch(context), // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
onMenu: widget.onMenu, onLike: () async {
onMute: logic.toggleMute, if (await ensureSignedIn(context)) {
onLike: () => engagement.toggleLiked(drama.id), unawaited(engagement.toggleLiked(drama.id));
onSave: () => engagement.toggleSaved(drama.id), }
},
onSave: () async {
if (await ensureSignedIn(context)) {
unawaited(engagement.toggleSaved(drama.id));
}
},
onComments: () => logic.showComments(context, drama), onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama), onShare: () => logic.shareDrama(context, drama),
onFullscreen: () => unawaited(logic.setFullscreen(true)), onClear: () => logic.setScreenCleared(true),
onPip: logic.pictureInPicture.isPlatformSupported onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context)) ? () => unawaited(logic.enterPip(context))
: null, : null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null,
showBottomNav: !widget.embedded,
)
else
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (!widget.homeMode)
IconButton(
onPressed: () => unawaited(logic.leave(context)),
tooltip: l10n.backToSeries,
icon: const Icon(Icons.arrow_back_rounded),
)
else
const Icon(
Icons.play_circle_fill_rounded,
color: Color(0xFFFF6B45),
),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.homeMode
? l10n.appName
: localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
tooltip: l10n.language,
onPressed: () => logic.showMy(context),
icon: const Icon(Icons.language_rounded),
),
],
),
if (widget.homeMode)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.homeTab,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w800,
decoration: TextDecoration.underline,
),
),
const SizedBox(width: 22),
TextButton(
onPressed: () => logic.openDetail(context),
child: Text(
l10n.discoverTab,
style: const TextStyle(
color: Color(0xFFF3EEEA),
fontSize: 15,
),
),
),
],
),
const Spacer(),
if (selected && logic.session.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: FilledButton(
onPressed: () => unawaited(logic.selectEpisode(index)),
child: Text('${l10n.playbackError} ${l10n.retry}'),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.demoTestMedia,
style: const TextStyle(
color: Color(0xFFFFB199),
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
localizedDramaTitle(l10n, drama),
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w800,
shadows: [
Shadow(blurRadius: 12, color: Colors.black),
],
),
),
const SizedBox(height: 6),
Text(
localizedDramaSynopsis(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFF7F4F2)),
),
],
),
),
const SizedBox(width: 8),
Column(
children: [
IconButton.filledTonal(
tooltip: l10n.exploreSeries,
onPressed: () => logic.openDetail(context),
style: IconButton.styleFrom(
backgroundColor: Colors.black45,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.info_outline_rounded),
),
const SizedBox(height: 8),
IconButton.filledTonal(
tooltip: l10n.episodes,
onPressed: () => logic.showEpisodes(context),
style: IconButton.styleFrom(
backgroundColor: Colors.black45,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.grid_view_rounded),
),
const SizedBox(height: 8),
IconButton.filledTonal(
tooltip: logic.session.userWantsPlay
? l10n.pause
: l10n.play,
style: IconButton.styleFrom(
backgroundColor: Colors.black45,
foregroundColor: Colors.white,
),
onPressed: ready
? () => logic.session.setUserWantsPlay(
!logic.session.userWantsPlay,
)
: null,
icon: Icon(
logic.session.userWantsPlay
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
),
],
),
],
),
const SizedBox(height: 18),
TextButton.icon(
onPressed: () => logic.showEpisodes(context),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
'${l10n.episodeProgress(episode.number, drama.episodes.length)} · ${l10n.episodes}',
),
style: TextButton.styleFrom(foregroundColor: Colors.white),
),
SeekProgressBar(
valueListenable: ready ? videoPort!.controller : null,
semanticsLabel: l10n.seekVideo,
showTimes: false,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position) ? (position) => logic.seekTo(context, videoPort!, position)
: null, : null,
showBottomNav: false,
), ),
if (widget.homeMode) ...[ if (logic.screenCleared)
const SizedBox(height: 16), SafeArea(
Row( child: Align(
mainAxisAlignment: MainAxisAlignment.spaceAround, alignment: Alignment.topRight,
children: [ child: Padding(
_BottomAction( padding: const EdgeInsets.only(top: 4, right: 8),
icon: Icons.home_rounded, child: IconButton(
label: l10n.homeTab, tooltip: l10n.exitClearScreen,
selected: true, onPressed: () => logic.setScreenCleared(false),
onTap: () {}, style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
), ),
_BottomAction( icon: const Icon(Icons.close_fullscreen_rounded),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: () => logic.showDiscover(context),
), ),
_BottomAction(
icon: Icons.person_outline_rounded,
label: l10n.myTab,
onTap: () => logic.showMy(context),
),
],
),
],
],
), ),
), ),
), ),
@@ -555,11 +376,10 @@ class _FullscreenControls extends StatelessWidget {
), ),
const Spacer(), const Spacer(),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: SeekProgressBar( child: SeekProgressBar(
valueListenable: controller, valueListenable: controller,
semanticsLabel: l10n.seekVideo, semanticsLabel: l10n.seekVideo,
compactIdle: true,
onScrubStart: onScrubStart, onScrubStart: onScrubStart,
onSeek: onSeek, onSeek: onSeek,
), ),
@@ -569,30 +389,23 @@ class _FullscreenControls extends StatelessWidget {
); );
} }
class _StitchHomeOverlay extends StatelessWidget { class _PlaybackOverlay extends StatelessWidget {
const _StitchHomeOverlay({ const _PlaybackOverlay({
required this.l10n, required this.l10n,
required this.drama, required this.drama,
required this.episode, required this.episode,
required this.controller, required this.controller,
required this.liked, required this.liked,
required this.saved, required this.saved,
required this.muted, required this.onBack,
required this.category,
required this.onCategorySelected,
required this.onCategoryShift,
required this.onDetail,
required this.onDiscover, required this.onDiscover,
required this.onEpisodes, required this.onEpisodes,
required this.onMy, required this.onMy,
required this.onSearch,
required this.onMenu,
required this.onMute,
required this.onLike, required this.onLike,
required this.onSave, required this.onSave,
required this.onComments, required this.onComments,
required this.onShare, required this.onShare,
required this.onFullscreen, required this.onClear,
required this.onPip, required this.onPip,
required this.onScrubStart, required this.onScrubStart,
required this.onSeek, required this.onSeek,
@@ -605,22 +418,15 @@ class _StitchHomeOverlay extends StatelessWidget {
final VideoPlayerController? controller; final VideoPlayerController? controller;
final bool liked; final bool liked;
final bool saved; final bool saved;
final bool muted; final VoidCallback? onBack;
final HomeFeedCategory category;
final ValueChanged<HomeFeedCategory> onCategorySelected;
final ValueChanged<int> onCategoryShift;
final VoidCallback onDetail;
final VoidCallback onDiscover; final VoidCallback onDiscover;
final VoidCallback onEpisodes; final VoidCallback onEpisodes;
final VoidCallback onMy; final VoidCallback onMy;
final VoidCallback onSearch;
final VoidCallback? onMenu;
final VoidCallback onMute;
final VoidCallback onLike; final VoidCallback onLike;
final VoidCallback onSave; final VoidCallback onSave;
final VoidCallback onComments; final VoidCallback onComments;
final VoidCallback onShare; final VoidCallback onShare;
final VoidCallback onFullscreen; 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;
@@ -629,202 +435,146 @@ class _StitchHomeOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) => SafeArea( Widget build(BuildContext context) => SafeArea(
bottom: showBottomNav, bottom: showBottomNav,
child: Column( child: Stack(
children: [
Column(
children: [ children: [
HomeFeedHeader(
l10n: l10n,
category: category,
onCategorySelected: onCategorySelected,
onCategoryShift: onCategoryShift,
onSearch: onSearch,
onMy: onMy,
onMenu: onMenu,
onMute: onMute,
muted: muted,
),
const Spacer(), const Spacer(),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 12, 0), padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(child: _info()),
child: Column( _rail(),
mainAxisSize: MainAxisSize.min, ],
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MetaChip(text: l10n.freeTestMedia, accent: true),
const SizedBox(height: 5),
Text(
localizedDramaTitle(l10n, drama),
style: const TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
), ),
), ),
const SizedBox(height: 3), const SizedBox(height: 4),
Text( Padding(
localizedDramaSynopsis(l10n, drama), padding: EdgeInsets.fromLTRB(
maxLines: 1, 16,
overflow: TextOverflow.ellipsis, 0,
style: const TextStyle( 16,
fontSize: 11, 20 + MediaQuery.paddingOf(context).bottom,
height: 1.2, ),
color: Colors.white, child: SeekProgressBar(
shadows: [Shadow(blurRadius: 8, color: Colors.black)], valueListenable: controller,
semanticsLabel: l10n.seekVideo,
onScrubStart: onScrubStart,
onSeek: onSeek,
), ),
), ),
Tooltip( if (showBottomNav) _fallbackNav(),
message: l10n.exploreSeries, ],
child: TextButton(
onPressed: onDetail,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 28),
foregroundColor: const Color(0xFFFFA18B),
), ),
child: Text( if (onBack != null)
l10n.exploreSeries, Positioned(
style: const TextStyle(fontSize: 12), 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(width: 8), ),
Column( 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
_RailAction(
key: const Key('rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: l10n.like,
selected: liked,
onTap: onLike,
),
_RailAction( _RailAction(
key: const Key('rail-save'), key: const Key('rail-save'),
icon: saved icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
label: l10n.save, label: l10n.save,
selected: saved, selected: saved,
onTap: onSave, onTap: onSave,
), ),
_RailAction(
icon: Icons.grid_view_rounded,
label: l10n.episodes,
onTap: onEpisodes,
),
_RailAction( _RailAction(
key: const Key('rail-comments'), key: const Key('rail-comments'),
icon: Icons.mode_comment_outlined, icon: Icons.mode_comment_outlined,
label: l10n.comments, label: l10n.comments,
onTap: onComments, onTap: onComments,
), ),
_RailAction(
key: const Key('rail-like'),
icon: liked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
label: l10n.like,
selected: liked,
onTap: onLike,
),
_RailAction( _RailAction(
key: const Key('rail-share'), key: const Key('rail-share'),
icon: Icons.share_rounded, icon: Icons.share_rounded,
label: l10n.share, label: l10n.share,
onTap: onShare, onTap: onShare,
), ),
],
),
],
),
),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Tooltip(
message: l10n.watchFullscreen,
child: TextButton.icon(
onPressed: onFullscreen,
icon: const Icon(Icons.screen_rotation_outlined, size: 15),
label: Text(l10n.watchFullscreen),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 11),
visualDensity: VisualDensity.compact,
),
),
),
if (onPip != null) if (onPip != null)
IconButton( IconButton(
tooltip: l10n.pictureInPicture, tooltip: l10n.pictureInPicture,
onPressed: onPip, onPressed: onPip,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
iconSize: 17, iconSize: 20,
color: Colors.white,
icon: const Icon(Icons.picture_in_picture_alt_outlined), icon: const Icon(Icons.picture_in_picture_alt_outlined),
), ),
], ],
), );
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), Widget _fallbackNav() => Container(
child: Tooltip(
message: l10n.episodes,
child: InkWell(
onTap: onEpisodes,
borderRadius: BorderRadius.circular(8),
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: const Color(0xE6212125),
borderRadius: BorderRadius.circular(8),
),
child: IconTheme(
data: const IconThemeData(color: Colors.white),
child: DefaultTextStyle.merge(
style: const TextStyle(color: Colors.white),
child: Row(
children: [
const Icon(Icons.play_circle_outline_rounded, size: 19),
const SizedBox(width: 7),
Expanded(
child: Text(
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
Text(
'${l10n.episodes} (${drama.episodes.length})',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const Icon(Icons.keyboard_arrow_up_rounded, size: 18),
],
),
),
),
),
),
),
),
const SizedBox(height: 2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
onScrubStart: onScrubStart,
onSeek: onSeek,
compactIdle: true,
),
),
const SizedBox(height: 2),
if (showBottomNav)
Container(
height: 62, height: 62,
color: const Color(0xF20C0C0F), color: const Color(0xF20C0C0F),
child: Row( child: Row(
@@ -848,32 +598,6 @@ class _StitchHomeOverlay extends StatelessWidget {
), ),
], ],
), ),
),
],
),
);
}
class _MetaChip extends StatelessWidget {
const _MetaChip({required this.text, this.accent = false});
final String text;
final bool accent;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xB51F1F22),
borderRadius: BorderRadius.circular(12),
),
child: Text(
text,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: accent ? const Color(0xFFFFC2B0) : const Color(0xFFF7F4F2),
),
),
); );
} }
@@ -894,14 +618,14 @@ class _RailAction extends StatelessWidget {
Widget build(BuildContext context) => Column( Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton.filledTonal( IconButton(
tooltip: label, tooltip: label,
onPressed: onTap, onPressed: onTap,
style: IconButton.styleFrom( visualDensity: VisualDensity.compact,
backgroundColor: const Color(0xB719191B), padding: EdgeInsets.zero,
foregroundColor: selected ? const Color(0xFFFF6B45) : Colors.white, constraints: const BoxConstraints.tightFor(width: 42, height: 36),
), color: selected ? const Color(0xFF8FB4FF) : Colors.white,
icon: Icon(icon, size: 22), icon: Icon(icon, size: 24),
), ),
Text( Text(
label, label,
@@ -940,14 +664,16 @@ class _BottomAction extends StatelessWidget {
children: [ children: [
Icon( Icon(
icon, icon,
color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA), color: selected ? const Color(0xFF2F7BFF) : const Color(0xFFF3EEEA),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA), color: selected
? const Color(0xFF2F7BFF)
: const Color(0xFFF3EEEA),
), ),
), ),
], ],
+156
View File
@@ -0,0 +1,156 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.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';
// 包名此刻仍是 nontonki,1-3 改名时会一并替换成 fruitshorts
import 'package:nontonki/core/data/remote/api_client.dart';
import 'package:nontonki/core/data/remote/auth_api.dart';
import 'package:nontonki/core/data/services/auth_controller.dart';
import 'package:nontonki/core/data/services/auth_session_store.dart';
/// 用假的 http.Client 覆盖三条关键路径:注册成功、401 → 刷新 → 重试、刷新失败 → 清会话。
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
/// http.Response(String) 默认按 Latin-1 编码,中文消息会直接抛
/// "Contains invalid characters" —— 统一用 UTF-8 字节构造。
http.Response jsonResponse(String body, [int statusCode = 200]) {
return http.Response.bytes(
utf8.encode(body),
statusCode,
headers: const {'content-type': 'application/json; charset=utf-8'},
);
}
String tokenBody({
required String accessToken,
required String refreshToken,
String uid = 'U1',
}) {
return jsonEncode({
'code': 0,
'message': 'ok',
'data': {
'accessToken': accessToken,
'refreshToken': refreshToken,
'expiresIn': 7200,
'user': {'uid': uid, 'nickname': 'Tester', 'avatarUrl': ''},
},
});
}
ApiClient clientWith(
AuthController Function() auth, {
required MockClient httpClient,
}) {
final store = AuthSessionStore();
return ApiClient(
baseUrl: 'http://127.0.0.1:8081',
client: httpClient,
accessTokenReader: () => auth().accessToken,
tokenRefresher: () => auth().refresh(),
onUnauthorized: () => auth().clear(),
deviceIdReader: store.deviceId,
);
}
test('register stores session and exposes signed-in state', () async {
late AuthController auth;
final mock = MockClient((request) async {
expect(request.url.path, '/app/auth/register');
expect(request.headers['X-Device-Id'], isNotEmpty);
expect(request.headers.containsKey('Authorization'), isFalse);
return jsonResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
});
final store = AuthSessionStore();
auth = AuthController(AuthApi(clientWith(() => auth, httpClient: mock)), store);
expect(auth.isSignedIn, isFalse);
await auth.register(email: 'a@b.com', password: 'abcd1234');
expect(auth.isSignedIn, isTrue);
expect(auth.accessToken, 'a1');
expect(auth.user?.nickname, 'Tester');
// 落盘:新建一个 store 也能读回来
expect((await AuthSessionStore().read())?.refreshToken, 'r1');
});
test('401 triggers refresh once and retries with the new token', () async {
var calls = 0;
late AuthController auth;
final mock = MockClient((request) async {
calls++;
if (request.url.path == '/app/auth/refresh') {
return jsonResponse(tokenBody(accessToken: 'a2', refreshToken: 'r2'));
}
if (request.url.path != '/app/me') {
// 注册等其它请求:直接发第一版令牌
return jsonResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
}
final token = request.headers['Authorization'];
if (token == 'Bearer a1') {
return jsonResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
);
}
expect(token, 'Bearer a2');
return jsonResponse(jsonEncode({'code': 0, 'message': 'ok', 'data': {'hit': true}}));
});
final store = AuthSessionStore();
auth = AuthController(AuthApi(clientWith(() => auth, httpClient: mock)), store);
await auth.register(email: 'a@b.com', password: 'abcd1234');
calls = 0;
final api = clientWith(() => auth, httpClient: mock);
final data = await api.get('/app/me');
expect((data as Map)['hit'], isTrue);
expect(calls, 3); // 原请求(401) + 刷新 + 重试
expect(auth.accessToken, 'a2');
});
test('refresh failure clears the session (401 handling)', () async {
late AuthController auth;
final mock = MockClient((request) async {
if (request.url.path == '/app/auth/refresh') {
return jsonResponse(jsonEncode({'code': 401, 'message': '登录已过期', 'data': null}));
}
if (request.url.path == '/app/auth/register') {
return jsonResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
}
return jsonResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
);
});
final store = AuthSessionStore();
auth = AuthController(AuthApi(clientWith(() => auth, httpClient: mock)), store);
await auth.register(email: 'a@b.com', password: 'abcd1234');
final api = clientWith(() => auth, httpClient: mock);
await expectLater(api.get('/app/me'), throwsA(isA<ApiException>()));
expect(auth.isSignedIn, isFalse);
expect(await AuthSessionStore().read(), isNull);
});
test('signOut clears local session even if the server call fails', () async {
late AuthController auth;
final mock = MockClient((request) async {
if (request.url.path == '/app/auth/logout') {
return jsonResponse('{}', 500);
}
return jsonResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
});
final store = AuthSessionStore();
auth = AuthController(AuthApi(clientWith(() => auth, httpClient: mock)), store);
await auth.register(email: 'a@b.com', password: 'abcd1234');
await auth.signOut();
expect(auth.isSignedIn, isFalse);
expect(await AuthSessionStore().read(), isNull);
});
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.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:nontonki/core/data/remote/auth_api.dart';
import 'package:nontonki/core/data/remote/api_client.dart';
import 'package:nontonki/core/data/services/auth_controller.dart';
import 'package:nontonki/core/data/services/auth_session_store.dart';
import 'package:nontonki/l10n/app_localizations.dart';
import 'package:nontonki/pages/auth/auth_view.dart';
/// 登录/注册页的可读证据:校验提示 + 登录/注册切换 + 未登录时不会去发请求。
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
Future<void> pumpAuthPage(WidgetTester tester, {bool signUp = false}) async {
final auth = AuthController(
AuthApi(
// 这个测试不联网:请求一律 500,正好用于验证"本地校验没过就不发请求"
ApiClient(
baseUrl: 'http://127.0.0.1:1',
client: MockClient((_) async => http.Response('{}', 500)),
),
),
AuthSessionStore(),
);
await tester.pumpWidget(
ChangeNotifierProvider<AuthController>.value(
value: auth,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AuthPage(signUpMode: signUp),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('empty form shows the email error and stays on the page', (tester) async {
await pumpAuthPage(tester);
expect(find.text('Welcome back'), findsOneWidget);
await tester.tap(find.byType(FilledButton));
await tester.pumpAndSettle();
expect(find.text('Enter your email'), findsOneWidget);
expect(find.text('Welcome back'), findsOneWidget);
});
testWidgets('weak password is rejected locally', (tester) async {
await pumpAuthPage(tester);
await tester.enterText(find.byType(TextField).first, 'a@b.com');
await tester.enterText(find.byType(TextField).last, 'abcdefgh');
await tester.tap(find.byType(FilledButton));
await tester.pumpAndSettle();
expect(find.text('Password needs both letters and numbers'), findsOneWidget);
});
testWidgets('switch link flips between sign in and sign up', (tester) async {
await pumpAuthPage(tester);
expect(find.text('Welcome back'), findsOneWidget);
await tester.tap(find.text('New here? Create an account'));
await tester.pumpAndSettle();
expect(find.text('Create your account'), findsOneWidget);
expect(find.text('Already have an account? Sign in'), findsOneWidget);
});
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/models/drama.dart';
import 'package:nontonki/pages/discover/discover_logic.dart';
import 'package:nontonki/pages/home/home_logic.dart';
import 'package:nontonki/pages/mine/mine_logic.dart';
Drama _drama(String id) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: const [],
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final dramas = [_drama('signal'), _drama('city')];
test('the first catalog entry becomes the current drama', () {
final logic = HomeLogic(dramas);
expect(logic.currentDrama.id, 'signal');
expect(logic.tab, 1);
expect(logic.mySection, 0);
expect(logic.drawerOpen, isFalse);
expect(logic.profile, isFalse);
expect(logic.videoImmersive, isFalse);
logic.dispose();
});
test('an empty catalog cannot build the shell', () {
expect(() => HomeLogic(const []), throwsStateError);
});
test('a plain tab switch raises no request counter', () {
final logic = HomeLogic(dramas);
var notifications = 0;
logic.addListener(() => notifications++);
logic.select(1);
expect(logic.tab, 1);
expect(logic.searchRequest, 0);
expect(logic.sectionRequest, 0);
expect(logic.resumeRequest, 0);
expect(logic.backRequest, 0);
expect(notifications, 1);
logic.dispose();
});
test('each select flag raises exactly its own counter', () {
final logic = HomeLogic(dramas);
logic.select(1, search: true);
expect(logic.tab, 1);
expect(logic.searchRequest, 1);
expect(logic.sectionRequest, 0);
logic.select(2, mySection: 0);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
expect(logic.searchRequest, 1);
logic.select(0, resume: true);
expect(logic.resumeRequest, 1);
expect(logic.sectionRequest, 1);
expect(logic.backRequest, 0);
logic.dispose();
});
test('the watchlist shortcut defaults to the second section', () {
final logic = HomeLogic(dramas);
logic.select(2, watchlist: true);
expect(logic.tab, 2);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 1);
logic.dispose();
});
test('openProfile and openLibrary disagree about the profile flag', () {
final logic = HomeLogic(dramas);
logic.openProfile();
expect(logic.profile, isTrue);
expect(logic.tab, 2);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
logic.openLibrary(section: 1);
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 2);
logic.openLibrary();
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 3);
logic.dispose();
});
test('system back returns home, and home itself opens the drawer', () {
final logic = HomeLogic(dramas);
logic.onSystemBack();
expect(logic.drawerOpen, isTrue);
expect(logic.tab, 1);
expect(logic.backRequest, 0);
logic.onDrawerChanged(false);
logic.select(0);
logic.onSystemBack();
expect(logic.tab, 1);
expect(logic.backRequest, 0);
logic.select(0);
logic.onImmersiveChanged(true);
logic.onSystemBack();
expect(logic.backRequest, 1);
expect(logic.tab, 0);
logic.onDrawerChanged(true);
logic.onSystemBack();
expect(logic.backRequest, 1);
logic.onDrawerChanged(false);
logic.onImmersiveChanged(false);
logic.onSystemBack();
expect(logic.tab, 1);
logic.dispose();
});
test('drawer state only notifies on an actual change', () {
final logic = HomeLogic(dramas);
var notifications = 0;
logic.addListener(() => notifications++);
logic.onDrawerChanged(false);
expect(notifications, 0);
logic.onDrawerChanged(true);
expect(logic.drawerOpen, isTrue);
expect(notifications, 1);
logic.onDrawerChanged(true);
expect(notifications, 1);
logic.dispose();
});
test('drama and immersive callbacks update state', () {
final logic = HomeLogic(dramas);
logic.onDramaChanged(dramas[1]);
expect(logic.currentDrama.id, 'city');
logic.onImmersiveChanged(true);
expect(logic.videoImmersive, isTrue);
logic.dispose();
});
test('my page actions map onto root tabs', () {
final logic = HomeLogic(dramas);
logic.myAction(MyPageAction.discover);
expect(logic.tab, 1);
expect(logic.searchRequest, 1);
logic.myAction(MyPageAction.resume);
expect(logic.tab, 0);
expect(logic.resumeRequest, 1);
logic.myAction(MyPageAction.home);
expect(logic.tab, 0);
expect(logic.resumeRequest, 1);
logic.dispose();
});
test('notifying after dispose is a no-op', () {
final logic = HomeLogic(dramas);
var notifications = 0;
logic.addListener(() => notifications++);
logic.dispose();
logic.notify();
expect(notifications, 0);
});
testWidgets('discover actions map onto root tabs', (tester) async {
final logic = HomeLogic(dramas);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (inner) {
context = inner;
return const SizedBox.shrink();
},
),
),
);
logic.discoverAction(context, DiscoverPageAction.watchlist);
expect(logic.tab, 2);
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 1);
logic.discoverAction(context, DiscoverPageAction.my);
expect(logic.tab, 2);
expect(logic.profile, isFalse);
logic.discoverAction(context, DiscoverPageAction.resume);
expect(logic.tab, 0);
expect(logic.resumeRequest, 1);
logic.discoverAction(context, DiscoverPageAction.home);
expect(logic.tab, 0);
expect(tester.takeException(), isNull);
logic.dispose();
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/config/app_config.dart';
import 'package:nontonki/core/data/models/drama.dart';
import 'package:nontonki/core/data/services/video_player_port.dart';
Episode _episode(String videoUrl) => Episode(
id: '1',
number: 1,
title: '第 1 集',
isFree: true,
videoUrl: videoUrl,
);
void main() {
test('相对播放地址补上服务地址', () {
final uri = VideoPlayerPort.resolveUri(
_episode('/files/episode/202610/clip.mp4'),
);
expect(uri.isAbsolute, isTrue);
// Uri.parse().toString() 会把 host 规范成小写,而 devHostBaseUrl 里的
// Bonjour 名字带大写(jiangdeMacBook-Air.local)—— 比较时统一小写,
// 否则会依赖运行平台(flutter test 里 defaultTargetPlatform 默认是 android)
expect(
uri.toString().toLowerCase(),
'${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(),
);
});
test('已经是绝对地址的不再拼接', () {
final uri = VideoPlayerPort.resolveUri(
_episode('https://cdn.example.test/clip.mp4'),
);
expect(uri.toString(), 'https://cdn.example.test/clip.mp4');
});
}
+143 -184
View File
@@ -6,12 +6,25 @@ import 'package:nontonki/pages/player/player_view.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'support/fake_catalog.dart';
import 'support/widget_player.dart'; import 'support/widget_player.dart';
void main() { void main() {
setUp(() { /// 默认按「已登录」起测:账号体系接上后,"登录"不再是本地演示开关,
/// 而是本地会话(这里直接给 shared_preferences 的内存实现塞一份会话)。
/// 昵称/uid 与演示文案保持一致,避免为显示细节改写既有断言。
void seedSignedInSession() {
SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty(); InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'test-access',
'auth.refreshToken': 'test-refresh',
'auth.user':
'{"uid":"M1CZWVYE","nickname":"Vicky sheng","avatarUrl":""}',
});
}
setUp(() {
seedSignedInSession();
}); });
testWidgets( testWidgets(
@@ -24,9 +37,12 @@ void main() {
created.add(episode.id); created.add(episode.id);
return WidgetPlayer(); return WidgetPlayer();
}, },
catalogRepository: FakeCatalogRepository(),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(created, hasLength(2)); expect(created, hasLength(2));
expect(find.byType(CircularProgressIndicator), findsNothing); expect(find.byType(CircularProgressIndicator), findsNothing);
await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.drag(find.byType(PageView), const Offset(0, -550));
@@ -48,25 +64,25 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
expect(find.byType(Slider), findsOneWidget); expect(find.byType(Slider), findsOneWidget);
expect(find.text('Home'), findsWidgets); expect(find.text('Home'), findsWidgets);
expect(find.text('Recommended'), findsOneWidget);
expect(find.textContaining('Playing now'), findsOneWidget); expect(find.textContaining('Playing now'), findsOneWidget);
expect(find.text('Recommended'), findsNothing);
expect(find.text('Following'), findsNothing);
expect(find.byTooltip('Episodes'), findsWidgets); expect(find.byTooltip('Episodes'), findsWidgets);
expect(find.byTooltip('Watch in landscape'), findsOneWidget); expect(find.byTooltip('Clear screen'), findsOneWidget);
expect(find.byTooltip('Picture in picture'), findsOneWidget); expect(find.byTooltip('Picture in picture'), findsOneWidget);
expect(find.text('Watch Full'), findsNothing);
await tester.tap(find.byTooltip('Explore series')); expect(find.byTooltip('Explore series'), findsNothing);
await tester.pumpAndSettle();
expect(find.text('Watch episode 1'), findsOneWidget);
expect(find.text('Episodes'), findsOneWidget);
}); });
testWidgets('landscape control hides tabs and restores them on exit', ( testWidgets('clear screen hides tabs and restores them on tap', (
tester, tester,
) async { ) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
@@ -75,16 +91,19 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Watch in landscape')); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Clear screen'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byKey(const Key('home-tabs-bar')), findsNothing);
expect(find.byTooltip('Exit fullscreen'), findsOneWidget); expect(find.byTooltip('Exit clear screen'), findsOneWidget);
await tester.tap(find.byTooltip('Exit fullscreen')); await tester.tap(find.byTooltip('Exit clear screen'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
expect(find.byTooltip('Clear screen'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
@@ -97,17 +116,23 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Open quick menu')); expect(find.text('Continue Watching'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).active, isFalse); expect(
tester
.widget<PlayerPage>(find.byType(PlayerPage, skipOffstage: false))
.active,
isFalse,
);
expect(find.text('Quick access'), findsOneWidget); expect(find.text('Quick access'), findsOneWidget);
expect( expect(
find.descendant( find.descendant(
of: find.byType(Drawer), of: find.byType(Drawer),
matching: find.text('Same demo clip · free'), matching: find.text('7 EPISODES'),
), ),
findsOneWidget, findsOneWidget,
); );
@@ -118,15 +143,21 @@ void main() {
expect(find.text('Watch episode 1'), findsOneWidget); expect(find.text('Watch episode 1'), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).active, isTrue); expect(
tester
.widget<PlayerPage>(find.byType(PlayerPage, skipOffstage: false))
.active,
isFalse,
);
expect(find.text('Continue Watching'), findsOneWidget);
await tester.tap(find.byTooltip('Open quick menu')); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('drawer-discover'))); await tester.tap(find.byKey(const Key('drawer-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Trending Micro-Dramas'), findsOneWidget); expect(find.text('Continue Watching'), findsOneWidget);
expect( expect(
Navigator.of(tester.element(find.text('Trending Micro-Dramas'))).canPop(), Navigator.of(tester.element(find.text('Continue Watching'))).canPop(),
isFalse, isFalse,
); );
}); });
@@ -140,37 +171,26 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byTooltip('Captions'), findsNothing); expect(find.byTooltip('Captions'), findsNothing);
await tester.tap(find.byTooltip('My')); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Continue Watching'), findsOneWidget); expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('History'), findsOneWidget); expect(find.text('Coins'), findsOneWidget);
expect(find.text('Watchlist'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('App Language'),
120,
scrollable: find
.descendant(
of: find.byType(ListView),
matching: find.byType(Scrollable),
)
.first,
);
expect(find.text('PREFERENCES'), findsOneWidget); expect(find.text('PREFERENCES'), findsOneWidget);
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); expect(find.text('我的账户'), findsOneWidget);
expect(find.text('找剧'), findsOneWidget); expect(find.text('首页'), findsOneWidget);
await tester.tap(find.text('找剧')); await tester.tap(find.text('首页'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('热门短剧'), findsOneWidget); expect(find.text('为你推荐'), findsOneWidget);
expect(find.text('排行榜'), findsOneWidget); expect(find.text('热门'), findsOneWidget);
}); });
testWidgets('home controls fit a compact Android viewport', (tester) async { testWidgets('home controls fit a compact Android viewport', (tester) async {
@@ -180,16 +200,18 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Recommended'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.drag(find.byType(PageView), const Offset(0, -500));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
await tester.tap(find.byTooltip('Explore series')); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Watch episode 1')); await tester.tap(find.text('City Lights & Velvet Nights'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget); expect(find.byType(Slider), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
@@ -204,17 +226,18 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('My')); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Continue Watching'), findsOneWidget);
expect(find.text('History'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.text('Home').last); expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.text('Shorts'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Recommended'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
}); });
testWidgets( testWidgets(
@@ -226,58 +249,53 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Discover')); await tester.tap(find.text('Home'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Trending Micro-Dramas'), findsOneWidget); expect(find.text('For You'), findsOneWidget);
expect(find.text('Filter'), findsOneWidget); expect(find.text('Continue Watching'), findsOneWidget);
expect(find.text('Top Charts'), findsOneWidget); expect(find.text('Rewards'), findsOneWidget);
expect(find.text('New Releases'), findsOneWidget); expect(find.text(r'$ 0'), findsOneWidget);
expect(find.text('Watchlist'), findsOneWidget); expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget);
expect(find.text('Same demo clip · free'), findsNWidgets(3)); expect(
expect(find.text('Video pending'), findsOneWidget); find.text('Action · Thriller', skipOffstage: false),
findsOneWidget,
);
expect(find.byTooltip('Search'), findsNothing);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.enterText(find.byType(TextField), 'Vengeance'); await tester.tap(find.byKey(const Key('tab-my-list')));
await tester.pumpAndSettle();
expect(find.text('Vengeance Rising'), findsOneWidget);
expect(find.text('Same demo clip · free'), findsOneWidget);
await tester.tap(find.text('Watchlist'));
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('Home').last); await tester.tap(find.text('Shorts'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Recommended'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
}, },
); );
testWidgets('bottom tabs stay on one route and preserve Discover search', ( testWidgets('bottom tabs stay on one route', (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;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Discover')); await tester.tap(find.text('Home'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Vengeance'); expect(find.text('Continue Watching'), findsOneWidget);
await tester.pumpAndSettle(); expect(find.byTooltip('Search'), findsNothing);
expect(find.text('Vengeance Rising'), findsOneWidget);
expect( expect(
Navigator.of(tester.element(find.byType(TextField))).canPop(), Navigator.of(tester.element(find.text('Continue Watching'))).canPop(),
isFalse, isFalse,
); );
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my-list')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('History'), findsOneWidget); expect(find.text('History'), findsOneWidget);
expect( expect(
@@ -290,20 +308,20 @@ 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.widgetWithText(TextField, 'Vengeance'), findsOneWidget); expect(find.text('Continue Watching'), findsOneWidget);
expect(find.text('Vengeance Rising'), findsOneWidget); expect(find.byTooltip('Search'), findsNothing);
expect( expect(
Navigator.of(tester.element(find.byType(TextField))).canPop(), Navigator.of(tester.element(find.text('Continue Watching'))).canPop(),
isFalse, isFalse,
); );
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my-list')));
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')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Recommended'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
expect( expect(
Navigator.of(tester.element(find.text('Recommended'))).canPop(), Navigator.of(tester.element(find.text('The Signal'))).canPop(),
isFalse, isFalse,
); );
}); });
@@ -317,9 +335,11 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.drag(find.byType(PageView), const Offset(0, -550));
@@ -327,66 +347,38 @@ void main() {
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
expect(find.textContaining('Episode 1'), findsOneWidget); expect(find.textContaining('Episode 1'), findsOneWidget);
await tester.tap(find.byTooltip('Explore series')); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); await tester.tap(find.text('City Lights & Velvet Nights'));
await tester.tap(find.text('Watch episode 1'));
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.textContaining('EPISODE 2 / 3'), findsOneWidget); expect(find.text('Episode 2 · Playing now'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(find.text('City Lights & Velvet Nights'), findsWidgets);
}, },
); );
testWidgets('home categories switch by video, top row and tap', ( testWidgets('shorts page has no category navigation', (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;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byIcon(Icons.tune_rounded), findsNothing); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.text('Following'), findsNothing);
expect(find.text('Animated'), findsNothing);
expect(find.text('Live Action'), findsNothing);
expect(find.text('Recommended'), findsNothing);
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
await tester.drag(
find.byKey(const Key('home-video-swipe')),
const Offset(220, 0),
);
await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
expect(find.byKey(const Key('home-category-liveAction')), 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('Vengeance Rising'), findsOneWidget);
await tester.drag(
find.byKey(const Key('home-category-swipe')),
const Offset(190, 0),
);
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
await tester.drag(
find.byKey(const Key('home-category-swipe')),
const Offset(-190, 0),
);
await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
await tester.tap(find.byKey(const Key('home-category-following')));
await tester.pumpAndSettle();
expect(find.text('No dramas in this category yet.'), findsOneWidget);
await tester.tap(find.byKey(const Key('home-category-recommended')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('right rail saves, comments, likes and invokes share in order', ( testWidgets('right rail saves, comments, likes and invokes share in order', (
@@ -412,13 +404,15 @@ void main() {
); );
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
final keys = [ final keys = [
const Key('rail-like'),
const Key('rail-save'), const Key('rail-save'),
const Key('rail-comments'), const Key('rail-comments'),
const Key('rail-like'),
const Key('rail-share'), const Key('rail-share'),
]; ];
final tops = keys final tops = keys
@@ -426,9 +420,9 @@ void main() {
.toList(); .toList();
expect(tops, orderedEquals(tops.toList()..sort())); expect(tops, orderedEquals(tops.toList()..sort()));
await tester.tap(find.byKey(keys[0])); await tester.tap(find.byKey(const Key('rail-save')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(keys[1])); await tester.tap(find.byKey(const Key('rail-comments')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Only visible on this device'), findsOneWidget); expect(find.text('Only visible on this device'), findsOneWidget);
await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo');
@@ -438,75 +432,38 @@ void main() {
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(keys[2])); await tester.tap(find.byKey(const Key('rail-like')));
await tester.tap(find.byKey(keys[3])); await tester.tap(find.byKey(const Key('rail-share')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(sharedText, contains('The Signal')); expect(sharedText, contains('The Signal'));
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets( testWidgets('system back opens the drawer and stays on home', (tester) async {
'right swipe from leftmost category opens drawer and root stays',
(tester) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
for (var i = 0; i < 3; i++) { expect(find.text('Continue Watching'), findsOneWidget);
await tester.drag(
find.byKey(const Key('home-category-swipe')),
const Offset(200, 0),
);
await tester.pumpAndSettle();
}
expect(find.text('No dramas in this category yet.'), findsOneWidget);
await tester.drag(
find.byKey(const Key('home-empty-swipe')),
const Offset(200, 0),
);
await tester.pumpAndSettle();
expect(find.text('Quick access'), findsOneWidget);
await tester.drag(find.byType(Drawer), const Offset(150, 0));
await tester.pumpAndSettle();
expect(find.text('Quick access'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
},
);
testWidgets('system back steps categories then opens drawer without exit', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer),
);
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('No dramas in this category yet.'), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Quick access'), findsOneWidget); expect(find.text('Quick access'), findsOneWidget);
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Quick access'), findsNothing);
expect(find.text('Continue Watching'), findsOneWidget);
expect(
Navigator.of(
tester.element(find.byKey(const Key('home-tabs-bar'))),
).canPop(),
isFalse,
);
}); });
testWidgets('My watchlist lists saved dramas across the home feed', ( testWidgets('My watchlist lists saved dramas across the home feed', (
@@ -517,9 +474,11 @@ void main() {
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
const LumaDramaApp(playerFactory: createWidgetPlayer), LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
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();
@@ -527,7 +486,7 @@ 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'))); await tester.tap(find.byKey(const Key('tab-my-list')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Watchlist')); await tester.tap(find.text('Watchlist'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();