From e1b98df509490144a3781a2b7ac0cf72518addfb Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 17:53:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20C=20=E7=AB=AF=201-2=20=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E9=93=BE=E8=B7=AF=EF=BC=88=E9=82=AE=E7=AE=B1=E7=99=BB?= =?UTF-8?q?=E5=BD=95/=E6=B3=A8=E5=86=8C=20+=20=E4=BB=A4=E7=89=8C=E4=B8=8E?= =?UTF-8?q?=20401=20+=20=E5=8C=BF=E5=90=8D/=E7=99=BB=E5=BD=95=E5=88=86?= =?UTF-8?q?=E5=8F=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:客户端此前完全没有账号概念(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 全过。 --- lib/app.dart | 52 +- lib/core/data/models/auth_session.dart | 51 ++ lib/core/data/remote/api_client.dart | 130 +++ lib/core/data/remote/auth_api.dart | 73 ++ lib/core/data/services/auth_controller.dart | 103 +++ .../data/services/auth_session_store.dart | 74 ++ lib/core/widgets/auth_gate.dart | 83 ++ lib/l10n/app_en.arb | 52 +- lib/l10n/app_localizations.dart | 300 ++++++ lib/l10n/app_localizations_en.dart | 150 +++ lib/l10n/app_localizations_zh.dart | 150 +++ lib/l10n/app_zh.arb | 52 +- lib/pages/auth/auth_binding.dart | 6 + lib/pages/auth/auth_logic.dart | 50 + lib/pages/auth/auth_view.dart | 227 +++++ lib/pages/home/home_logic.dart | 47 +- lib/pages/home/home_view.dart | 103 ++- lib/pages/mine/mine_view.dart | 490 +++++----- lib/pages/player/player_view.dart | 866 ++++++------------ test/auth/auth_controller_test.dart | 156 ++++ test/auth/auth_page_test.dart | 81 ++ test/home/home_logic_test.dart | 227 +++++ test/playback/video_player_port_test.dart | 37 + test/widget_test.dart | 327 +++---- 24 files changed, 2871 insertions(+), 1016 deletions(-) create mode 100644 lib/core/data/models/auth_session.dart create mode 100644 lib/core/data/remote/api_client.dart create mode 100644 lib/core/data/remote/auth_api.dart create mode 100644 lib/core/data/services/auth_controller.dart create mode 100644 lib/core/data/services/auth_session_store.dart create mode 100644 lib/core/widgets/auth_gate.dart create mode 100644 lib/pages/auth/auth_binding.dart create mode 100644 lib/pages/auth/auth_logic.dart create mode 100644 lib/pages/auth/auth_view.dart create mode 100644 test/auth/auth_controller_test.dart create mode 100644 test/auth/auth_page_test.dart create mode 100644 test/home/home_logic_test.dart create mode 100644 test/playback/video_player_port_test.dart diff --git a/lib/app.dart b/lib/app.dart index ba4b636..ff4f0cc 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,8 +2,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.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/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/engagement_controller.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 'pages/home/home_view.dart'; -class LumaDramaApp extends StatelessWidget { - const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new}); +class LumaDramaApp extends StatefulWidget { + const LumaDramaApp({ + super.key, + this.playerFactory = VideoPlayerPort.new, + this.catalogRepository, + }); final PlayerFactory playerFactory; + /// 测试注入用;生产走 HTTP 仓库。 + final CatalogRepository? catalogRepository; + + @override + State createState() => _LumaDramaAppState(); +} + +class _LumaDramaAppState extends State { + 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 Widget build(BuildContext context) { return MultiProvider( providers: [ Provider(create: (_) => PlaybackCoordinator()), - Provider(create: (_) => playerFactory), + Provider(create: (_) => widget.playerFactory), ChangeNotifierProvider( - create: (_) => CatalogController(DemoCatalogRepository())..load(), + create: (_) => CatalogController( + widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)), + )..load(), ), ChangeNotifierProvider(create: (_) => LocaleController()..load()), ChangeNotifierProvider( @@ -31,6 +70,7 @@ class LumaDramaApp extends StatelessWidget { EngagementController(SharedPreferencesEngagementRepository()) ..load(), ), + ChangeNotifierProvider.value(value: _auth), ], child: Consumer( builder: (context, locale, _) => MaterialApp( @@ -51,14 +91,14 @@ class LumaDramaApp extends StatelessWidget { theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF6D4AFF), + seedColor: const Color(0xFF2F7BFF), ), scaffoldBackgroundColor: const Color(0xFFF8F7FC), ), darkTheme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF9B86FF), + seedColor: const Color(0xFF2F7BFF), brightness: Brightness.dark, ), scaffoldBackgroundColor: const Color(0xFF101019), diff --git a/lib/core/data/models/auth_session.dart b/lib/core/data/models/auth_session.dart new file mode 100644 index 0000000..d3efa84 --- /dev/null +++ b/lib/core/data/models/auth_session.dart @@ -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 json) => AuthUser( + uid: json['uid'] as String? ?? '', + nickname: json['nickname'] as String? ?? '', + avatarUrl: json['avatarUrl'] as String? ?? '', + ); + + Map 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 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() ?? const {}), + ); + + Map toJson() => { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'expiresIn': expiresIn, + 'user': user.toJson(), + }; +} diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart new file mode 100644 index 0000000..ca1d5b2 --- /dev/null +++ b/lib/core/data/remote/api_client.dart @@ -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 Function()? tokenRefresher; + + /// 刷新也失败时回调(清本地登录态、回收 UI) + final void Function()? onUnauthorized; + + /// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用 + final Future Function()? deviceIdReader; + + Future get(String path, {Map? query}) { + return _send('GET', path, query: query); + } + + Future post( + String path, { + Object? body, + Map? query, + bool skipAuthRetry = false, + }) { + return _send('POST', path, body: body, query: query, skipAuthRetry: skipAuthRetry); + } + + Future _send( + String method, + String path, { + Object? body, + Map? 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 shell = const {}; + try { + shell = (jsonDecode(utf8.decode(response.bodyBytes)) as Map) + .cast(); + } 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> _headers({required bool withBody}) async { + final headers = {'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(); +} diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart new file mode 100644 index 0000000..175f753 --- /dev/null +++ b/lib/core/data/remote/auth_api.dart @@ -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 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()); + } + + Future 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()); + } + + Future refresh(String refreshToken) async { + final data = await _client.post( + '/app/auth/refresh', + body: {'refreshToken': refreshToken}, + // 刷新请求本身不带旧令牌重试,失败就是失败 + skipAuthRetry: true, + ); + return AuthSession.fromJson((data as Map).cast()); + } + + Future logout(String refreshToken) async { + await _client.post( + '/app/auth/logout', + body: {'refreshToken': refreshToken}, + skipAuthRetry: true, + ); + } +} diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart new file mode 100644 index 0000000..7b51540 --- /dev/null +++ b/lib/core/data/services/auth_controller.dart @@ -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 load() async { + _deviceId = await _store.deviceId(); + _session = await _store.read(); + _loaded = true; + notifyListeners(); + } + + Future 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 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 signOut() async { + final refreshToken = _session?.refreshToken ?? ''; + _session = null; + await _store.clear(); + notifyListeners(); + if (refreshToken.isNotEmpty) { + // 服务端吊销失败不影响本地已登出,异常直接吞掉 + try { + await _api.logout(refreshToken); + } catch (_) {} + } + } + + /// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。 + Future 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 clear() async { + if (_session == null) return; + _session = null; + await _store.clear(); + notifyListeners(); + } + + Future _apply(AuthSession session) async { + _session = session; + await _store.save(session); + notifyListeners(); + } + + String get _platform { + if (kIsWeb) return 'WEB'; + return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID'; + } +} diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart new file mode 100644 index 0000000..1586759 --- /dev/null +++ b/lib/core/data/services/auth_session_store.dart @@ -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 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 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 user = const {}; + if (rawUser != null && rawUser.isNotEmpty) { + try { + user = (jsonDecode(rawUser) as Map).cast(); + } catch (_) { + user = const {}; + } + } + return AuthSession( + accessToken: accessToken, + refreshToken: refreshToken, + user: AuthUser.fromJson(user), + ); + } + + Future 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 clear() async { + final prefs = SharedPreferencesAsync(); + await prefs.remove(_kAccessToken); + await prefs.remove(_kRefreshToken); + await prefs.remove(_kUser); + // 设备号不清:它代表"这台设备",与登录态无关 + } +} diff --git a/lib/core/widgets/auth_gate.dart b/lib/core/widgets/auth_gate.dart new file mode 100644 index 0000000..2e07000 --- /dev/null +++ b/lib/core/widgets/auth_gate.dart @@ -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 openAuthPage(BuildContext context, {bool signUpMode = false}) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AuthPage(signUpMode: signUpMode), + ), + ); +} + +/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。 +/// +/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。 +Future ensureSignedIn(BuildContext context) async { + final auth = context.read(); + if (auth.isSignedIn) return true; + + final l10n = AppLocalizations.of(context)!; + final go = await showModalBottomSheet( + 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; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4559e95..e9b4cfe 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -96,6 +96,8 @@ "lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.", "playbackError": "This clip could not be played.", "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.", "retry": "Retry", "backToSeries": "Back to series", @@ -130,5 +132,53 @@ "continueWatchingHeading": "CONTINUE WATCHING", "preferencesHeading": "PREFERENCES", "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" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3b7f19a..3b0ce7e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -632,6 +632,18 @@ abstract class AppLocalizations { /// **'The catalog could not be loaded.'** 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. /// /// In en, this message translates to: @@ -841,6 +853,294 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Nontonki demo'** 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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ae2a353..8b0d272 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -294,6 +294,12 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get noStories => 'No stories available yet.'; @@ -401,4 +407,148 @@ class AppLocalizationsEn extends AppLocalizations { @override 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'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f61dc49..cc8b39c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -289,6 +289,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get catalogError => '剧目列表加载失败。'; + @override + String get detailError => '剧集信息加载失败。'; + + @override + String get noMediaNotice => '该剧集还没有可播放的片源。'; + @override String get noStories => '暂无剧目。'; @@ -393,4 +399,148 @@ class AppLocalizationsZh extends AppLocalizations { @override 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 => '暂不'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0f1d215..fc54650 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -91,6 +91,8 @@ "lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。", "playbackError": "此视频暂时无法播放。", "catalogError": "剧目列表加载失败。", + "detailError": "剧集信息加载失败。", + "noMediaNotice": "该剧集还没有可播放的片源。", "noStories": "暂无剧目。", "retry": "重试", "backToSeries": "返回剧集详情", @@ -125,5 +127,53 @@ "continueWatchingHeading": "继续观看", "preferencesHeading": "偏好设置", "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": "暂不" } diff --git a/lib/pages/auth/auth_binding.dart b/lib/pages/auth/auth_binding.dart new file mode 100644 index 0000000..dbc17f7 --- /dev/null +++ b/lib/pages/auth/auth_binding.dart @@ -0,0 +1,6 @@ +import 'auth_logic.dart'; + +/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。 +class AuthBinding { + AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode); +} diff --git a/lib/pages/auth/auth_logic.dart b/lib/pages/auth/auth_logic.dart new file mode 100644 index 0000000..8b9eb0e --- /dev/null +++ b/lib/pages/auth/auth_logic.dart @@ -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, +} diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart new file mode 100644 index 0000000..7fc1bbf --- /dev/null +++ b/lib/pages/auth/auth_view.dart @@ -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 createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + 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 _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(); + 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 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, + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index f31090f..66b235d 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../discover/discover_logic.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_logic.dart'; +import '../player/player_view.dart'; class HomeLogic extends ChangeNotifier { HomeLogic(List dramas) : currentDrama = dramas.first; final scaffoldKey = GlobalKey(); Drama currentDrama; - int tab = 0; + int tab = 1; int searchRequest = 0; int sectionRequest = 0; int resumeRequest = 0; @@ -19,6 +22,7 @@ class HomeLogic extends ChangeNotifier { bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; + bool profile = false; bool _disposed = false; void notify() { @@ -62,9 +66,7 @@ class HomeLogic extends ChangeNotifier { if (!context.mounted) return; try { await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DramaDetailPage(drama: currentDrama), - ), + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)), ); } finally { if (context.mounted) { @@ -80,6 +82,34 @@ class HomeLogic extends ChangeNotifier { ); } + Future playDrama(BuildContext context, Drama drama) async { + // 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。 + var playable = drama; + try { + playable = await context.read().requireDetail(drama); + } catch (_) { + // 取详情失败就用卡片自带的那一集起播。 + } + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + 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) { switch (action) { case DiscoverPageAction.home: @@ -87,7 +117,7 @@ class HomeLogic extends ChangeNotifier { case DiscoverPageAction.my: select(2); case DiscoverPageAction.watchlist: - select(2, watchlist: true); + openLibrary(); case DiscoverPageAction.resume: select(0, resume: true); case DiscoverPageAction.detail: @@ -112,11 +142,10 @@ class HomeLogic extends ChangeNotifier { } else if (videoImmersive) { backRequest++; notify(); - } else if (tab != 0) { - select(0); + } else if (tab != 1) { + select(1); } else { - backRequest++; - notify(); + openMenu(); } } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 32db9d5..591beaa 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -1,8 +1,13 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/services/auth_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 '../discover/discover_view.dart'; import '../mine/mine_view.dart'; @@ -29,7 +34,6 @@ class HomePage extends StatelessWidget { CatalogController catalog, AppLocalizations l10n, ) { - if (catalog.loading) { return const Scaffold( backgroundColor: Colors.black, @@ -46,6 +50,13 @@ class HomePage extends StatelessWidget { Text(catalog.error == null ? l10n.noStories : l10n.catalogError), const SizedBox(height: 12), 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) { final l10n = AppLocalizations.of(context)!; + // 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关) + final signedIn = context.watch().isSignedIn; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -107,8 +121,8 @@ class _HomeTabsState extends State<_HomeTabs> { drama: logic.currentDrama, onOpenSeries: () => logic.openCurrentSeries(context), onDiscover: () => logic.selectFromMenu(1), - onHistory: () => logic.selectFromMenu(2, mySection: 0), - onWatchlist: () => logic.selectFromMenu(2, mySection: 1), + onHistory: () => logic.openLibrary(section: 0), + onWatchlist: logic.openLibrary, onLiked: () => logic.selectFromMenu(2, mySection: 2), ), drawerEnableOpenDragGesture: false, @@ -127,7 +141,7 @@ class _HomeTabsState extends State<_HomeTabs> { resumeRequest: logic.resumeRequest, backRequest: logic.backRequest, onDiscover: () => logic.select(1), - onMy: () => logic.select(2), + onMy: logic.openProfile, onSearch: () => logic.select(1, search: true), onMenu: logic.openMenu, onImmersiveChanged: logic.onImmersiveChanged, @@ -135,11 +149,15 @@ class _HomeTabsState extends State<_HomeTabs> { DiscoverPage( drama: logic.currentDrama, dramas: widget.dramas, - onDramaSelected: (drama) => logic.openDrama(context, drama), + onDramaSelected: (drama) => logic.playDrama(context, drama), duration: Duration.zero, embedded: true, active: logic.tab == 1, searchRequest: logic.searchRequest, + signedIn: signedIn, + onSignIn: () => openAuthPage(context), + onCoins: logic.openProfile, + onRewards: logic.openProfile, onAction: (action) => logic.discoverAction(context, action), ), MyPage( @@ -148,10 +166,15 @@ class _HomeTabsState extends State<_HomeTabs> { onDramaSelected: (drama) => logic.openDrama(context, drama), duration: Duration.zero, initialSection: logic.mySection, + profile: logic.profile, sectionRequest: logic.sectionRequest, embedded: true, active: logic.tab == 2, onAction: logic.myAction, + onOpenHistory: () => logic.openLibrary(section: 0), + signedIn: signedIn, + onSignIn: () => openAuthPage(context), + onSignOut: () => context.read().signOut(), ), ], ), @@ -167,28 +190,38 @@ class _HomeTabsState extends State<_HomeTabs> { border: Border(top: BorderSide(color: Color(0xFF242329))), ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, 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( key: const Key('tab-discover'), - icon: Icons.explore_outlined, - label: l10n.discoverTab, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, + label: l10n.homeTab, selected: logic.tab == 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( key: const Key('tab-my'), - icon: Icons.video_library_outlined, + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, - selected: logic.tab == 2, - onTap: () => logic.select(2), + selected: logic.tab == 2 && logic.profile, + onTap: logic.openProfile, ), ], ), @@ -203,35 +236,47 @@ class _TabButton extends StatelessWidget { const _TabButton({ super.key, required this.icon, + required this.selectedIcon, required this.label, required this.selected, required this.onTap, }); final IconData icon; + final IconData selectedIcon; final String label; final bool selected; final VoidCallback onTap; @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - child: SizedBox( - width: 80, + Widget build(BuildContext context) => Expanded( + child: InkWell( + onTap: onTap, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - icon, - size: 21, - color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3), + decoration: selected + ? 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( label, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10, - color: selected - ? const Color(0xFFFF5C37) - : const Color(0xFFE6E1DE), + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected ? AppColors.blue : AppColors.muted, ), ), ], diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 4e0e738..141c7ed 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -4,7 +4,10 @@ import 'package:provider/provider.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/data/models/drama.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/widgets/remote_image.dart'; +import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; @@ -15,11 +18,16 @@ class MyPage extends StatefulWidget { required this.drama, required this.duration, this.initialSection = 0, + this.profile = false, + this.onOpenHistory, this.history, this.embedded = false, this.active = true, this.sectionRequest = 0, this.onAction, + this.signedIn = false, + this.onSignIn, + this.onSignOut, this.dramas, this.onDramaSelected, }); @@ -27,11 +35,16 @@ class MyPage extends StatefulWidget { final Drama drama; final Duration duration; final int initialSection; + final bool profile; + final VoidCallback? onOpenHistory; final WatchHistoryRepository? history; final bool embedded; final bool active; final int sectionRequest; final ValueChanged? onAction; + final bool signedIn; + final VoidCallback? onSignIn; + final VoidCallback? onSignOut; final List? dramas; final ValueChanged? onDramaSelected; @@ -86,7 +99,7 @@ class _MyPageState extends State { bottom: !widget.embedded, child: Column( children: [ - _topBar(l10n), + if (!widget.profile) _topBar(l10n), Expanded( child: FutureBuilder( future: logic.position, @@ -101,15 +114,13 @@ class _MyPageState extends State { 1.0, ); return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), children: [ - _profile(l10n), - const SizedBox(height: 22), - _continueCard(l10n, fraction, position, total), - const SizedBox(height: 26), - _library(l10n, position, total, fraction), - const SizedBox(height: 26), - _preferences(l10n), + if (widget.profile) + ..._accountPage(l10n) + else ...[ + _library(l10n, position, total, fraction), + ], const SizedBox(height: 24), Center( child: Text( @@ -147,8 +158,8 @@ class _MyPageState extends State { const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27), const SizedBox(width: 7), Text( - l10n.myTab, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), + l10n.myListTab, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800), ), const Spacer(), IconButton( @@ -172,158 +183,247 @@ class _MyPageState extends State { ), ); - Widget _profile(AppLocalizations l10n) => Row( - children: [ - const CircleAvatar( - radius: 30, - backgroundColor: Color(0xFF32343A), - child: Icon(Icons.person_rounded, color: Colors.white, size: 34), + List _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(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.localViewer, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 4), - Text( - l10n.localDemoProfile, - style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 12), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFF24252A), - borderRadius: BorderRadius.circular(20), - ), - child: Text(l10n.demo, style: const TextStyle(fontSize: 11)), - ), - ], - ); - - Widget _continueCard( - AppLocalizations l10n, - double fraction, - Duration position, - Duration total, - ) => Container( - padding: const EdgeInsets.all(13), - decoration: BoxDecoration( - color: const Color(0xFF201B1E), - borderRadius: BorderRadius.circular(13), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + 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: [ - Row( - children: [ - const Icon(Icons.history_rounded, color: _orange, size: 17), - const SizedBox(width: 6), - Expanded( - child: Text( - l10n.continueWatching, + const CircleAvatar( + radius: 28, + backgroundColor: Color(0xFF32343A), + 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().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), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, + fontSize: 20, + fontWeight: FontWeight.w600, ), ), - ), - Text( - l10n.episodeProgress(1, widget.drama.episodes.length), - style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - _poster(width: 75, height: 96), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 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), - Text( - '${_clock(position)} / ${_clock(total)}', - style: const TextStyle( - color: Color(0xFFE6E1E3), - fontSize: 12, - ), - ), - ], + const SizedBox(height: 2), + Text( + uidText, + style: const TextStyle(color: AppColors.muted, fontSize: 12), ), - ), - ], - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () => logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ), - style: FilledButton.styleFrom( - backgroundColor: _orange, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(37), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - ), - icon: const Icon(Icons.play_arrow_rounded, size: 18), - label: Text(l10n.resume), + ], ), ), + TextButton( + onPressed: widget.onSignOut ?? () => _mockSnack(l10n), + style: TextButton.styleFrom( + backgroundColor: const Color(0xFF2A2C33), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + shape: const StadiumBorder(), + ), + child: Text(l10n.signOut), + ), ], - ), - ); + ); + } + + Widget _wallet(AppLocalizations l10n) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF17191F), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.myAccount, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 12), + Row( + children: [ + 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( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(color: color, fontSize: 12)), + const SizedBox(height: 6), + Text( + value, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: color, + foregroundColor: color == AppColors.blue + ? Colors.white + : const Color(0xFF2A2110), + visualDensity: VisualDensity.compact, + shape: const StadiumBorder(), + ), + child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ), + ], + ), + ); + } + + 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( AppLocalizations l10n, @@ -353,14 +453,25 @@ class _MyPageState extends State { for (var index = 0; index < tabs.length; index++) ...[ InkWell( onTap: () => logic.selectSection(index), - child: Text( - tabs[index], - style: TextStyle( + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( color: logic.section == index - ? _orange - : const Color(0xFFE4DBDD), - fontSize: 16, - fontWeight: FontWeight.w800, + ? AppColors.blue + : const Color(0xFF24262E), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + tabs[index], + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700, + ), ), ), ), @@ -446,7 +557,7 @@ class _MyPageState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Color(0xFFFFA18D), + color: AppColors.blueSoft, fontSize: 10, ), ), @@ -507,12 +618,7 @@ class _MyPageState extends State { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.asset( - drama.posterAsset, - width: 74, - height: 103, - fit: BoxFit.cover, - ), + child: RemoteImage(drama.posterUrl, width: 74, height: 103), ), const SizedBox(width: 11), Expanded( @@ -530,7 +636,7 @@ class _MyPageState extends State { ), const SizedBox(height: 7), Text( - l10n.episodeCount(drama.episodes.length), + l10n.episodeCount(drama.episodeCount), style: const TextStyle( color: Color(0xFFE6E1E3), fontSize: 12, @@ -545,49 +651,6 @@ class _MyPageState extends State { ), ); - 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( height: 63, decoration: const BoxDecoration( @@ -629,12 +692,7 @@ class _MyPageState extends State { Widget _poster({required double width, required double height}) => ClipRRect( borderRadius: BorderRadius.circular(7), - child: Image.asset( - widget.drama.posterAsset, - width: width, - height: height, - fit: BoxFit.cover, - ), + child: RemoteImage(widget.drama.posterUrl, width: width, height: height), ); } @@ -683,4 +741,4 @@ String _clock(Duration duration) { return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; } -const _orange = Color(0xFFFF5B3D); +const _orange = AppColors.blue; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 9b2a104..a9a1237 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -10,10 +10,8 @@ import '../../core/data/services/engagement_controller.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/widgets/auth_gate.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_logic.dart'; import 'widgets/seek_progress_bar.dart'; @@ -100,28 +98,23 @@ class _PlayerPageState extends State with WidgetsBindingObserver { Widget _buildPlayer(BuildContext context) { final engagement = context.watch(); - final category = logic.category; final indices = logic.visibleIndices; final pages = logic.pages; final content = AnimatedBuilder( animation: logic.session, - builder: (context, _) => indices.isEmpty && widget.homeMode - ? _buildEmptyCategory(context) - : PageView.builder( - key: ValueKey(category), - controller: pages, - scrollDirection: Axis.vertical, - allowImplicitScrolling: true, - itemCount: indices.length, - onPageChanged: (position) { - if (category != logic.category) return; - final index = indices[position]; - widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); - unawaited(logic.selectEpisode(index)); - }, - itemBuilder: (context, position) => - _buildEpisode(context, indices[position], engagement), - ), + builder: (context, _) => PageView.builder( + controller: pages, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + itemCount: indices.length, + onPageChanged: (position) { + final index = indices[position]; + widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); + unawaited(logic.selectEpisode(index)); + }, + itemBuilder: (context, position) => + _buildEpisode(context, indices[position], engagement), + ), ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, @@ -132,54 +125,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { : Scaffold(backgroundColor: Colors.black, body: content), ), ); - } - - 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( @@ -190,20 +135,25 @@ class _PlayerPageState extends State with WidgetsBindingObserver { final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); 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; // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. 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 ready = videoPort?.controller.value.isInitialized ?? false; + final theater = logic.fullscreen || logic.pip || logic.pipPreparing; Widget? video; if (ready) { video = GestureDetector( - onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), child: FittedBox( - fit: logic.immersive ? BoxFit.contain : BoxFit.cover, + fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( width: videoPort!.controller.value.size.width, height: videoPort.controller.value.size.height, @@ -214,28 +164,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ); - if (widget.homeMode) { - video = CategorySwipeRegion( - key: selected ? const Key('home-video-swipe') : null, - onShift: logic.shiftCategory, - child: video, - ); - } } return Stack( fit: StackFit.expand, 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, - if (!logic.immersive) + if (!logic.chromeHidden) const IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( @@ -243,31 +179,92 @@ class _PlayerPageState extends State with WidgetsBindingObserver { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Color(0xD6000000), Color(0x66000000), + Color(0x00000000), 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) const Center( - child: CircularProgressIndicator(color: Color(0xFFFF6B45)), + child: CircularProgressIndicator(color: Color(0xFF2F7BFF)), ), - if (selected && ready && !logic.session.userWantsPlay && !logic.pip) + if (selected && !ready && logic.session.error != null) Center( - child: IconButton.filled( - iconSize: 48, - onPressed: () => logic.session.setUserWantsPlay(true), - icon: const Icon(Icons.play_arrow_rounded), + 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 (widget.homeMode && (logic.pip || logic.pipPreparing)) + // 锁定集与还没上传片源的剧集都会走到这里,之前是一片纯黑,看不出原因。 + 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) + const Center( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0x73000000), + shape: BoxShape.circle, + ), + child: Padding( + padding: EdgeInsets.all(10), + child: Icon( + Icons.play_arrow_rounded, + size: 48, + color: Colors.white, + ), + ), + ), + ), + ), + if (logic.pip || logic.pipPreparing) const SizedBox.expand() - else if (widget.homeMode && logic.fullscreen) + else if (logic.fullscreen) _FullscreenControls( l10n: l10n, controller: ready ? videoPort!.controller : null, @@ -276,238 +273,62 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? () => unawaited(logic.enterPip(context)) : 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) - _StitchHomeOverlay( + else if (!logic.screenCleared) + _PlaybackOverlay( l10n: l10n, drama: drama, episode: episode, controller: ready ? videoPort!.controller : null, liked: engagement.isLiked(drama.id), saved: engagement.isSaved(drama.id), - muted: logic.muted, - category: logic.category, - onCategorySelected: (value) => unawaited(logic.selectCategory(value)), - onCategoryShift: logic.shiftCategory, - onDetail: () => logic.openDetail(context), + onBack: widget.embedded + ? null + : () => unawaited(logic.leave(context)), onDiscover: () => logic.showDiscover(context), onEpisodes: () => logic.showEpisodes(context), onMy: () => logic.showMy(context), - onSearch: () => logic.showSearch(context), - onMenu: widget.onMenu, - onMute: logic.toggleMute, - onLike: () => engagement.toggleLiked(drama.id), - onSave: () => engagement.toggleSaved(drama.id), + // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) + onLike: () async { + if (await ensureSignedIn(context)) { + unawaited(engagement.toggleLiked(drama.id)); + } + }, + onSave: () async { + if (await ensureSignedIn(context)) { + unawaited(engagement.toggleSaved(drama.id)); + } + }, onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), - onFullscreen: () => unawaited(logic.setFullscreen(true)), + onClear: () => logic.setScreenCleared(true), onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, - showBottomNav: !widget.embedded, - ) - else + onSeek: ready + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + showBottomNav: false, + ), + if (logic.screenCleared) 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), - ), - ], + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(top: 4, right: 8), + child: IconButton( + tooltip: l10n.exitClearScreen, + onPressed: () => logic.setScreenCleared(false), + style: IconButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: const Color(0x59000000), ), - 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 - ? (position) => logic.seekTo(context, videoPort!, position) - : null, - ), - if (widget.homeMode) ...[ - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _BottomAction( - icon: Icons.home_rounded, - label: l10n.homeTab, - selected: true, - onTap: () {}, - ), - _BottomAction( - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: () => logic.showDiscover(context), - ), - _BottomAction( - icon: Icons.person_outline_rounded, - label: l10n.myTab, - onTap: () => logic.showMy(context), - ), - ], - ), - ], - ], + icon: const Icon(Icons.close_fullscreen_rounded), + ), ), ), ), @@ -555,11 +376,10 @@ class _FullscreenControls extends StatelessWidget { ), const Spacer(), Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), child: SeekProgressBar( valueListenable: controller, semanticsLabel: l10n.seekVideo, - compactIdle: true, onScrubStart: onScrubStart, onSeek: onSeek, ), @@ -569,30 +389,23 @@ class _FullscreenControls extends StatelessWidget { ); } -class _StitchHomeOverlay extends StatelessWidget { - const _StitchHomeOverlay({ +class _PlaybackOverlay extends StatelessWidget { + const _PlaybackOverlay({ required this.l10n, required this.drama, required this.episode, required this.controller, required this.liked, required this.saved, - required this.muted, - required this.category, - required this.onCategorySelected, - required this.onCategoryShift, - required this.onDetail, + required this.onBack, required this.onDiscover, required this.onEpisodes, required this.onMy, - required this.onSearch, - required this.onMenu, - required this.onMute, required this.onLike, required this.onSave, required this.onComments, required this.onShare, - required this.onFullscreen, + required this.onClear, required this.onPip, required this.onScrubStart, required this.onSeek, @@ -605,22 +418,15 @@ class _StitchHomeOverlay extends StatelessWidget { final VideoPlayerController? controller; final bool liked; final bool saved; - final bool muted; - final HomeFeedCategory category; - final ValueChanged onCategorySelected; - final ValueChanged onCategoryShift; - final VoidCallback onDetail; + final VoidCallback? onBack; final VoidCallback onDiscover; final VoidCallback onEpisodes; final VoidCallback onMy; - final VoidCallback onSearch; - final VoidCallback? onMenu; - final VoidCallback onMute; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onComments; final VoidCallback onShare; - final VoidCallback onFullscreen; + final VoidCallback onClear; final VoidCallback? onPip; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; @@ -629,250 +435,168 @@ class _StitchHomeOverlay extends StatelessWidget { @override Widget build(BuildContext context) => SafeArea( bottom: showBottomNav, - child: Column( + child: Stack( children: [ - HomeFeedHeader( - l10n: l10n, - category: category, - onCategorySelected: onCategorySelected, - onCategoryShift: onCategoryShift, - onSearch: onSearch, - onMy: onMy, - onMenu: onMenu, - onMute: onMute, - muted: muted, - ), - const Spacer(), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 12, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Column( - 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), - Text( - localizedDramaSynopsis(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11, - height: 1.2, - color: Colors.white, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - Tooltip( - message: l10n.exploreSeries, - child: TextButton( - onPressed: onDetail, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size(0, 28), - foregroundColor: const Color(0xFFFFA18B), - ), - child: Text( - l10n.exploreSeries, - style: const TextStyle(fontSize: 12), - ), - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Column( - mainAxisSize: MainAxisSize.min, + Column( + children: [ + const Spacer(), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 8, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - _RailAction( - key: const Key('rail-save'), - icon: saved - ? Icons.bookmark_rounded - : Icons.bookmark_border_rounded, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - key: const Key('rail-comments'), - icon: Icons.mode_comment_outlined, - label: l10n.comments, - 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( - key: const Key('rail-share'), - icon: Icons.share_rounded, - label: l10n.share, - onTap: onShare, - ), + Expanded(child: _info()), + _rail(), ], ), - ], - ), - ), - 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, - ), + ), + const SizedBox(height: 4), + Padding( + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + 20 + MediaQuery.paddingOf(context).bottom, + ), + child: SeekProgressBar( + valueListenable: controller, + semanticsLabel: l10n.seekVideo, + onScrubStart: onScrubStart, + onSeek: onSeek, ), ), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - visualDensity: VisualDensity.compact, - iconSize: 17, - icon: const Icon(Icons.picture_in_picture_alt_outlined), - ), + if (showBottomNav) _fallbackNav(), ], ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - 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), - ], - ), - ), - ), + if (onBack != null) + Positioned( + top: 4, + left: 8, + child: IconButton( + tooltip: l10n.backToSeries, + onPressed: onBack, + style: IconButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: const Color(0x59000000), ), + icon: const Icon(Icons.arrow_back_rounded), ), ), - ), - 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, - color: const Color(0xF20C0C0F), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _BottomAction( - icon: Icons.play_circle_outline_rounded, - label: l10n.homeTab, - selected: true, - onTap: () {}, - ), - _BottomAction( - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: onDiscover, - ), - _BottomAction( - icon: Icons.video_library_outlined, - label: l10n.myTab, - onTap: onMy, - ), - ], + 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), ), + ), ], ), ); -} -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), + Widget _info() => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w800, + height: 1.15, + shadows: [Shadow(blurRadius: 12, color: Colors.black)], + ), ), + const SizedBox(height: 6), + Text( + '${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), + ), + ], + ); + + Widget _rail() => Column( + mainAxisSize: MainAxisSize.min, + children: [ + _RailAction( + key: const Key('rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + label: l10n.like, + selected: liked, + onTap: onLike, + ), + _RailAction( + key: const Key('rail-save'), + icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, + label: l10n.save, + selected: saved, + onTap: onSave, + ), + _RailAction( + icon: Icons.grid_view_rounded, + label: l10n.episodes, + onTap: onEpisodes, + ), + _RailAction( + key: const Key('rail-comments'), + icon: Icons.mode_comment_outlined, + label: l10n.comments, + onTap: onComments, + ), + _RailAction( + key: const Key('rail-share'), + icon: Icons.share_rounded, + label: l10n.share, + onTap: onShare, + ), + if (onPip != null) + IconButton( + tooltip: l10n.pictureInPicture, + onPressed: onPip, + visualDensity: VisualDensity.compact, + iconSize: 20, + color: Colors.white, + icon: const Icon(Icons.picture_in_picture_alt_outlined), + ), + ], + ); + + Widget _fallbackNav() => Container( + height: 62, + color: const Color(0xF20C0C0F), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _BottomAction( + icon: Icons.play_circle_outline_rounded, + label: l10n.homeTab, + selected: true, + onTap: () {}, + ), + _BottomAction( + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: onDiscover, + ), + _BottomAction( + icon: Icons.video_library_outlined, + label: l10n.myTab, + onTap: onMy, + ), + ], ), ); } @@ -894,14 +618,14 @@ class _RailAction extends StatelessWidget { Widget build(BuildContext context) => Column( mainAxisSize: MainAxisSize.min, children: [ - IconButton.filledTonal( + IconButton( tooltip: label, onPressed: onTap, - style: IconButton.styleFrom( - backgroundColor: const Color(0xB719191B), - foregroundColor: selected ? const Color(0xFFFF6B45) : Colors.white, - ), - icon: Icon(icon, size: 22), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 42, height: 36), + color: selected ? const Color(0xFF8FB4FF) : Colors.white, + icon: Icon(icon, size: 24), ), Text( label, @@ -940,14 +664,16 @@ class _BottomAction extends StatelessWidget { children: [ Icon( icon, - color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA), + color: selected ? const Color(0xFF2F7BFF) : const Color(0xFFF3EEEA), ), const SizedBox(height: 2), Text( label, style: TextStyle( fontSize: 11, - color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA), + color: selected + ? const Color(0xFF2F7BFF) + : const Color(0xFFF3EEEA), ), ), ], diff --git a/test/auth/auth_controller_test.dart b/test/auth/auth_controller_test.dart new file mode 100644 index 0000000..1ca93aa --- /dev/null +++ b/test/auth/auth_controller_test.dart @@ -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())); + 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); + }); +} diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart new file mode 100644 index 0000000..0973f6d --- /dev/null +++ b/test/auth/auth_page_test.dart @@ -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 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.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); + }); +} diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart new file mode 100644 index 0000000..c56b869 --- /dev/null +++ b/test/home/home_logic_test.dart @@ -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(); + }); +} diff --git a/test/playback/video_player_port_test.dart b/test/playback/video_player_port_test.dart new file mode 100644 index 0000000..0c7553c --- /dev/null +++ b/test/playback/video_player_port_test.dart @@ -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'); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 94c734c..6a63e43 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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/shared_preferences_async_platform_interface.dart'; +import 'support/fake_catalog.dart'; import 'support/widget_player.dart'; void main() { - setUp(() { + /// 默认按「已登录」起测:账号体系接上后,"登录"不再是本地演示开关, + /// 而是本地会话(这里直接给 shared_preferences 的内存实现塞一份会话)。 + /// 昵称/uid 与演示文案保持一致,避免为显示细节改写既有断言。 + void seedSignedInSession() { 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( @@ -24,9 +37,12 @@ void main() { created.add(episode.id); return WidgetPlayer(); }, + catalogRepository: FakeCatalogRepository(), ), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); expect(created, hasLength(2)); expect(find.byType(CircularProgressIndicator), findsNothing); await tester.drag(find.byType(PageView), const Offset(0, -550)); @@ -48,25 +64,25 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); expect(find.byType(Slider), findsOneWidget); expect(find.text('Home'), findsWidgets); - expect(find.text('Recommended'), 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('Watch in landscape'), findsOneWidget); + expect(find.byTooltip('Clear screen'), findsOneWidget); expect(find.byTooltip('Picture in picture'), findsOneWidget); - - await tester.tap(find.byTooltip('Explore series')); - await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); - expect(find.text('Episodes'), findsOneWidget); + expect(find.text('Watch Full'), findsNothing); + expect(find.byTooltip('Explore series'), findsNothing); }); - testWidgets('landscape control hides tabs and restores them on exit', ( + testWidgets('clear screen hides tabs and restores them on tap', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -75,16 +91,19 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); 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(); expect(find.byKey(const Key('home-tabs-bar')), findsNothing); - expect(find.byTooltip('Exit fullscreen'), findsOneWidget); - await tester.tap(find.byTooltip('Exit fullscreen')); + expect(find.byTooltip('Exit clear screen'), findsOneWidget); + await tester.tap(find.byTooltip('Exit clear screen')); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + expect(find.byTooltip('Clear screen'), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -97,17 +116,23 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Open quick menu')); + expect(find.text('Continue Watching'), findsOneWidget); + await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(tester.widget(find.byType(PlayerPage)).active, isFalse); + expect( + tester + .widget(find.byType(PlayerPage, skipOffstage: false)) + .active, + isFalse, + ); expect(find.text('Quick access'), findsOneWidget); expect( find.descendant( of: find.byType(Drawer), - matching: find.text('Same demo clip · free'), + matching: find.text('7 EPISODES'), ), findsOneWidget, ); @@ -118,15 +143,21 @@ void main() { expect(find.text('Watch episode 1'), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(tester.widget(find.byType(PlayerPage)).active, isTrue); + expect( + tester + .widget(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.tap(find.byKey(const Key('drawer-discover'))); await tester.pumpAndSettle(); - expect(find.text('Trending Micro-Dramas'), findsOneWidget); + expect(find.text('Continue Watching'), findsOneWidget); expect( - Navigator.of(tester.element(find.text('Trending Micro-Dramas'))).canPop(), + Navigator.of(tester.element(find.text('Continue Watching'))).canPop(), isFalse, ); }); @@ -140,37 +171,26 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); await tester.pumpAndSettle(); expect(find.byTooltip('Captions'), findsNothing); - await tester.tap(find.byTooltip('My')); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - expect(find.text('Continue Watching'), findsOneWidget); - expect(find.text('History'), 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('Vicky sheng'), findsOneWidget); + expect(find.text('Coins'), findsOneWidget); expect(find.text('PREFERENCES'), findsOneWidget); await tester.tap(find.text('App Language')); await tester.pumpAndSettle(); await tester.tap(find.text('简体中文').last); await tester.pumpAndSettle(); expect(find.text('我的'), findsWidgets); - expect(find.text('继续观看'), findsOneWidget); - expect(find.text('找剧'), findsOneWidget); - await tester.tap(find.text('找剧')); + expect(find.text('我的账户'), findsOneWidget); + expect(find.text('首页'), findsOneWidget); + await tester.tap(find.text('首页')); 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 { @@ -180,16 +200,18 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); 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.pumpAndSettle(); 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.tap(find.text('Watch episode 1')); + await tester.tap(find.text('City Lights & Velvet Nights')); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); expect(tester.takeException(), isNull); @@ -204,17 +226,18 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('My')); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - expect(find.text('Continue Watching'), findsOneWidget); - expect(find.text('History'), findsOneWidget); 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(); - expect(find.text('Recommended'), findsOneWidget); + expect(find.text('The Signal'), findsOneWidget); }); testWidgets( @@ -226,58 +249,53 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Discover')); + await tester.tap(find.text('Home')); await tester.pumpAndSettle(); - expect(find.text('Trending Micro-Dramas'), findsOneWidget); - expect(find.text('Filter'), findsOneWidget); - expect(find.text('Top Charts'), findsOneWidget); - expect(find.text('New Releases'), findsOneWidget); - expect(find.text('Watchlist'), findsOneWidget); - expect(find.text('Same demo clip · free'), findsNWidgets(3)); - expect(find.text('Video pending'), findsOneWidget); + expect(find.text('For You'), findsOneWidget); + expect(find.text('Continue Watching'), findsOneWidget); + expect(find.text('Rewards'), findsOneWidget); + expect(find.text(r'$ 0'), findsOneWidget); + expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget); + expect( + find.text('Action · Thriller', skipOffstage: false), + findsOneWidget, + ); + expect(find.byTooltip('Search'), findsNothing); expect(tester.takeException(), isNull); - await tester.enterText(find.byType(TextField), 'Vengeance'); - 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.tap(find.byKey(const Key('tab-my-list'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); - await tester.tap(find.text('Home').last); + await tester.tap(find.text('Shorts')); 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', ( - tester, - ) async { + testWidgets('bottom tabs stay on one route', (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), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Discover')); + await tester.tap(find.text('Home')); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Vengeance'); - await tester.pumpAndSettle(); - expect(find.text('Vengeance Rising'), findsOneWidget); + expect(find.text('Continue Watching'), findsOneWidget); + expect(find.byTooltip('Search'), findsNothing); expect( - Navigator.of(tester.element(find.byType(TextField))).canPop(), + Navigator.of(tester.element(find.text('Continue Watching'))).canPop(), isFalse, ); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-my-list'))); await tester.pumpAndSettle(); expect(find.text('History'), findsOneWidget); expect( @@ -290,20 +308,20 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.widgetWithText(TextField, 'Vengeance'), findsOneWidget); - expect(find.text('Vengeance Rising'), findsOneWidget); + expect(find.text('Continue Watching'), findsOneWidget); + expect(find.byTooltip('Search'), findsNothing); expect( - Navigator.of(tester.element(find.byType(TextField))).canPop(), + Navigator.of(tester.element(find.text('Continue Watching'))).canPop(), isFalse, ); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-my-list'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.text('Recommended'), findsOneWidget); + expect(find.text('The Signal'), findsOneWidget); expect( - Navigator.of(tester.element(find.text('Recommended'))).canPop(), + Navigator.of(tester.element(find.text('The Signal'))).canPop(), isFalse, ); }); @@ -317,9 +335,11 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); await tester.pumpAndSettle(); + 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, -550)); @@ -327,66 +347,38 @@ void main() { expect(find.text('City Lights & Velvet Nights'), 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(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - await tester.tap(find.text('Watch episode 1')); + await tester.tap(find.text('City Lights & Velvet Nights')); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); 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); }, ); - testWidgets('home categories switch by video, top row and tap', ( - tester, - ) async { + testWidgets('shorts page has no category navigation', (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), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); 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); - - 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.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); - - 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); }); testWidgets('right rail saves, comments, likes and invokes share in order', ( @@ -412,13 +404,15 @@ void main() { ); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); final keys = [ + const Key('rail-like'), const Key('rail-save'), const Key('rail-comments'), - const Key('rail-like'), const Key('rail-share'), ]; final tops = keys @@ -426,9 +420,9 @@ void main() { .toList(); 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.tap(find.byKey(keys[1])); + await tester.tap(find.byKey(const Key('rail-comments'))); await tester.pumpAndSettle(); expect(find.text('Only visible on this device'), findsOneWidget); await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); @@ -438,75 +432,38 @@ void main() { await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - await tester.tap(find.byKey(keys[2])); - await tester.tap(find.byKey(keys[3])); + await tester.tap(find.byKey(const Key('rail-like'))); + await tester.tap(find.byKey(const Key('rail-share'))); await tester.pumpAndSettle(); expect(sharedText, contains('The Signal')); expect(tester.takeException(), isNull); }); - testWidgets( - 'right swipe from leftmost category opens drawer and root stays', - (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(); - - for (var i = 0; i < 3; i++) { - 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 { + testWidgets('system back opens the drawer and stays on home', (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), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); 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); + expect(find.text('Continue Watching'), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(find.text('Quick access'), 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', ( @@ -517,9 +474,11 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), ); 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.pumpAndSettle(); @@ -527,7 +486,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-save'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); + await tester.tap(find.byKey(const Key('tab-my-list'))); await tester.pumpAndSettle(); await tester.tap(find.text('Watchlist')); await tester.pumpAndSettle();