Author SHA1 Message Date
Vicky 94ff9f1177 fix(client): 兼容后端 Long 字段字符串化(id 与计数)
后端 J1 起 Long/long 一律以字符串下发(含雪花 id 与 viewCount/expiresIn)。
客户端 id 方向本来就是 (json['id'] ?? '').toString(),不受影响;会被 as num? 打挂的
是 Long 计数,这里加统一兼容解析并替换 3 处:

- lib/core/data/models/json_numbers.dart(新增,接受数字或数字字符串)
- lib/core/data/models/auth_session.dart(expiresIn,后端 long)
- lib/core/data/models/drama.dart(viewCount,后端 Long ×2)
- test/core/json_id_parsing_test.dart(新增 8 条回归用例)

其余 as num?(durationSec/unlockCoin/number/totalEpisodes/freeEpisodes/code)对应后端
Integer/int,仍是数字,未改动;未碰任何业务逻辑与 UI。
2026-10-16 16:44:02 +08:00
Vicky 61454e36b1 feat(client): 改名为 FruitShorts 并新增 id-ID 印尼语文案(1-3 + 1-4 合并)
为什么:a) 应用对外名称要统一成 FruitShorts(产品名,nontonki 是原型代号);
b) 首发市场是印尼,界面必须有印尼语;c) 归档要求"新增 id-ID 与改名合并生成一次 l10n,别分两次"。

改名:
- pubspec name: nontonki → fruitshorts(Dart 包名),description 同步;28 个文件的 package: 导入前缀替换
- Android android:label、iOS CFBundleDisplayName/CFBundleName → FruitShorts
- 图标资源 assets/icon/nontonki.png → fruitshorts.png(含 pubspec 两处引用)
- README 同步(补印尼语说明,顺带修掉写错的模块名 reelshort-app-api → fruitshorts-app-api)

文案:
- 三份 arb 的 appName → FruitShorts、brandMark → FRUITSHORTS、shareFooter → FruitShorts(不留 demo 标记)
- 新增 lib/l10n/app_id.arb:174 个值键与 en/zh 完全一致,按印尼语习惯翻译(Coin→Koin、Episode→Episode)
- 一次性 flutter gen-l10n 生成 app_localizations_id.dart;id 自动进入 supportedLocales,默认 locale 未改
- 新增 test/config/l10n_id_test.dart:钉住 id 文案、supportedLocales 含 id、品牌文案无 demo 标记、占位符渲染

不动:applicationId / bundle id(lumadrama 遗留,等裁定)、目录名、业务逻辑与页面结构、语言选择页(仍只有 en/zh)

门禁:dart analyze lib test 零告警;flutter test 159 全过(基线 155 + 新增 4);残留扫描 0 命中;
三份 arb 键集一致(174/174/174);真机(iPhone iOS 26.6.1)构建安装启动成功,devicectl 列出显示名 FruitShorts
2026-10-16 10:16:59 +08:00
Vicky 0e5d550b50 test(client): 用例名统一为 should_<期望>_when_<条件>(155/155)
为什么:红线第 8 条门禁立即生效,要求 Flutter 测试命名合规;此前 155 条用例名是英文自然语言
或中文描述,合规数 0/155,属存量欠账,按"先补齐再上新功能"在本批处理。

- 25 个测试文件、155 条用例改名,按「文件 + 旧名」精确定位,每条断言命中恰好 1 次才替换
- 只改名字字面量:改动行 155 删 + 155 增,无一行不含字符串字面量(断言与用例体零改动)
- 门禁:dart analyze lib test = No issues found;flutter test = 155 全过
2026-10-16 09:41:14 +08:00
Vicky 1853e1845c refactor(client): 网络层由官方 http 换成 dio(对外行为不变)
为什么:权威文档(AGENTS.md / CODING_STANDARDS.md)强制网络层用 dio,差异清单 A 组第 1 题
用户裁定选 A;原来用官方 http 是在无文档状态下按口头要求选的。

- ApiClient 改用 dio + BaseOptions(validateStatus 恒真、8s 超时、ResponseType.bytes 自行 UTF-8 解码)
- 新增 AuthHeaderInterceptor(Authorization / X-Device-Id)与 EnvelopeInterceptor
  (解包 {code,message,data} + 错误码映射 + 401 判定)
- 401 → 刷新 → 重发一次在 ApiClient 里编排,刻意不放在拦截器内重发:dio 的排队拦截器
  在回调里再发请求会自锁
- 接口层(auth_api/catalog_api)签名、全部 controller/logic/page 未改动
- 测试:新增假 dio adapter 替代 MockClient,断言口径一条未改;用例名改为 should_<期望>_when_<条件>;
  新增 api_client_test(解包/错误映射/请求头/查询参数/跳过重试)与 live_auth_test(真实后端 POST 链路)
- pubspec:加 dio、移除已无引用的 http
- 实测:dart analyze lib test 零告警;flutter test 155 全过;真实联调(8081)GET/POST 链路均通
2026-10-16 09:30:46 +08:00
Vicky ae49b10407 chore(client): 归档发现页 widgets(原型阶段新增目录) 2026-10-15 17:53:54 +08:00
Vicky feecc349a0 chore(client): 归档原型阶段新增文件(配置、主题、模型与测试)
为什么:上一批归档只覆盖了「已跟踪文件的修改」,原型阶段新增的文件(app_config、theme、
home_feed/play_ticket 模型、catalog_api、测试目录等)还是未入库状态 —— 而已入库的代码会 import 它们,
留在工作区会导致 HEAD 处的代码编译不过。一并归档,让 HEAD 自洽。

纯归档,无行为改动。
2026-10-15 17:53:45 +08:00
Vicky e1b98df509 feat(client): C 端 1-2 账号链路(邮箱登录/注册 + 令牌与 401 + 匿名/登录分叉)
为什么:客户端此前完全没有账号概念(mine 页的"登录"只是本地 demo 开关),
而受限动作(收藏/评论)要对到具体账号。本批把账号链路接上,为后续互动、进度同步、下载打底。

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

门禁:flutter analyze 0 问题;flutter test 148 全过。
2026-10-15 17:53:35 +08:00
Vicky 77d5748dbb chore(client): 归档原型阶段未提交改动(binding/logic/view 分层与各页迭代)
为什么:客户端原型在 14d7bd3 之后又做了一轮(页面分层、发现页、分类滑动、播放器控件等),
一直留在工作区没入库。下一批账号链路要改其中的 app.dart / home_view / player_view / mine_view,
先归档这批既存改动,本批的实际 diff 才说得清边界。

只归档,不改行为:当前工作区 flutter analyze 0 问题、flutter test 148 全过。
2026-10-15 17:53:27 +08:00
89 changed files with 7899 additions and 2150 deletions

No files matched your search

+35 -2
View File
@@ -5,13 +5,46 @@
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
- **互动与多语言**:本地演示评论、系统分享,以及英文、简体中文和印尼语界面。
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
## 技术实现
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
## 对接后端
目录数据来自短剧服务的客户端接口(`FruitServer` 的 `fruitshorts-app-api`,默认 `http://127.0.0.1:8081`):
- `GET /app/home/feed`、`GET /app/discover/feed`:轮播 + 分区,一次请求渲染整页。
每张卡片带 `preview`(首个可播免费集),首页竖屏播放可以直接起播;
没有片源的剧不下发 `preview`,也不会出现在首页的滑动队列里
- `GET /app/dramas/{id}`:详情(含剧集列表;可播放的剧集直接带播放地址)
- `GET /app/episodes/{id}/play`:播放票据,返回可访问性与地址
启动后端后直接 `flutter run` 或 Xcode 里 Run 即可,接口地址按运行环境自动选择
(见 `lib/core/config/app_config.dart`):
| 运行环境 | 默认地址 |
| --- | --- |
| iOS 模拟器 / 桌面端 | `http://127.0.0.1:8081` |
| iPhone 真机 | [`devHostBaseUrl`],即电脑当前的局域网地址(`ipconfig getifaddr en0`) |
| Android 模拟器 | 需手动指定 `--dart-define=API_BASE_URL=http://10.0.2.2:8081` |
任何环境都可以用 `--dart-define=API_BASE_URL=http://x.x.x.x:8081` 覆盖默认值。
电脑换网络后局域网地址会变,改 `devHostBaseUrl` 即可;也可以换成 Bonjour 名字
(`http://$(scutil --get LocalHostName).local:8081`),换网络也不用改。
真机调试还需注意:手机和电脑在同一个 Wi-Fi;首次启动时 iPhone 会弹「本地网络」权限,
必须允许;iOS 的明文 HTTP 与局域网访问已在 `ios/Runner/Info.plist` 里放行
(`NSAllowsLocalNetworking`、`NSLocalNetworkUsageDescription`),改动这些配置后
要重新构建安装,热重载不生效;如果仍连不上,到「系统设置 → 网络 → 防火墙 → 选项」
把 `java` 设为允许传入连接。
封面与视频都走 `http://<host>:8081/files/**`,本地不再打包演示素材
(原先 `assets/media` 里的演示视频有 316MB,已从打包清单移除)。
## 当前说明
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发);
金币与会员相关界面仍是占位数据。
@@ -4,4 +4,6 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
<application android:usesCleartextTraffic="true"/>
</manifest>
+2 -1
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="Nontonki"
android:label="FruitShorts"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -4,4 +4,6 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
<application android:usesCleartextTraffic="true"/>
</manifest>
+3 -3
View File
@@ -360,7 +360,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -487,7 +487,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -538,7 +538,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+9 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nontonki</string>
<string>FruitShorts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Nontonki</string>
<string>FruitShorts</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -26,6 +26,13 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+46 -6
View File
@@ -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<LumaDramaApp> createState() => _LumaDramaAppState();
}
class _LumaDramaAppState extends State<LumaDramaApp> {
late final AuthSessionStore _sessionStore = AuthSessionStore();
/// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求
/// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。
late final ApiClient _apiClient = ApiClient(
accessTokenReader: () => _auth.accessToken,
tokenRefresher: () => _auth.refresh(),
onUnauthorized: () => _auth.clear(),
deviceIdReader: _sessionStore.deviceId,
);
late final AuthController _auth = AuthController(AuthApi(_apiClient), _sessionStore);
@override
void initState() {
super.initState();
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
_auth.load();
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider(create: (_) => PlaybackCoordinator()),
Provider<PlayerFactory>(create: (_) => playerFactory),
Provider<PlayerFactory>(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<LocaleController>(
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),
+50
View File
@@ -0,0 +1,50 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
/// 客户端与后端约定的运行时配置。
///
/// 接口地址的优先级:`--dart-define=API_BASE_URL=...` > 按运行环境判断的默认值。
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。
class AppConfig {
const AppConfig._();
/// 开发机的局域网地址。真机上的 `127.0.0.1` 指向手机自己,只能走这个地址。
///
/// 这个地址跟着电脑所在的网络走,换网络后要用
/// `ipconfig getifaddr en0` 重新确认并改这里;
/// 也可以换成 Bonjour 名字 `http://<LocalHostName>.local:8081`(本机是
/// `jiangdeMacBook-Air.local`),换网络不用改,但部分办公网会拦组播解析。
static const String devHostBaseUrl =
'http://jiangdeMacBook-Air.local:8081';
static const String _loopbackBaseUrl = 'http://127.0.0.1:8081';
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
static String get apiBaseUrl =>
_overrideBaseUrl.isNotEmpty ? _overrideBaseUrl : _defaultBaseUrl;
static final String _defaultBaseUrl =
_runsOnDevMachine ? _loopbackBaseUrl : devHostBaseUrl;
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
/// Android 没有等价的探测方式,统一按真机处理(模拟器用
/// `--dart-define=API_BASE_URL=http://10.0.2.2:8081` 覆盖)。
static final bool _runsOnDevMachine =
kIsWeb ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
(defaultTargetPlatform == TargetPlatform.iOS &&
Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'));
static const Duration requestTimeout = Duration(seconds: 8);
/// 后端返回的是相对路径(/files/...),这里补上服务地址。
static String mediaUrl(String path) {
if (path.isEmpty) return '';
if (path.startsWith('http://') || path.startsWith('https://')) return path;
return '$apiBaseUrl$path';
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'json_numbers.dart';
/// 登录态:服务端下发的令牌与用户摘要。
///
/// 只保留客户端真正要用的字段(uid / 昵称 / 头像),不做整份 AppUserRespVO 的映射 ——
/// 首页卡片、个人页需要的都在这里,避免为用不到的字段维护一份平行模型。
class AuthUser {
const AuthUser({required this.uid, required this.nickname, this.avatarUrl = ''});
final String uid;
final String nickname;
final String avatarUrl;
factory AuthUser.fromJson(Map<String, dynamic> json) => AuthUser(
uid: json['uid'] as String? ?? '',
nickname: json['nickname'] as String? ?? '',
avatarUrl: json['avatarUrl'] as String? ?? '',
);
Map<String, dynamic> toJson() => {
'uid': uid,
'nickname': nickname,
'avatarUrl': avatarUrl,
};
}
class AuthSession {
const AuthSession({
required this.accessToken,
required this.refreshToken,
this.expiresIn = 0,
required this.user,
});
final String accessToken;
final String refreshToken;
final int expiresIn;
final AuthUser user;
factory AuthSession.fromJson(Map<String, dynamic> json) => AuthSession(
accessToken: json['accessToken'] as String? ?? '',
refreshToken: json['refreshToken'] as String? ?? '',
// expiresIn 后端是 long,按字符串下发(见 json_numbers.dart)
expiresIn: jsonToInt(json['expiresIn']),
user: AuthUser.fromJson((json['user'] as Map?)?.cast<String, dynamic>() ?? const {}),
);
Map<String, dynamic> toJson() => {
'accessToken': accessToken,
'refreshToken': refreshToken,
'expiresIn': expiresIn,
'user': user.toJson(),
};
}
+101 -8
View File
@@ -1,19 +1,53 @@
enum DramaFormat { animated, liveAction }
import 'json_numbers.dart';
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
///
/// 封面与播放地址都是后端返回的相对路径(`/files/...`),
/// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。
class Episode {
const Episode({
required this.id,
required this.number,
required this.title,
required this.assetPath,
required this.isFree,
this.videoUrl = '',
this.durationSec = 0,
this.locked = false,
});
final String id;
final int number;
final String title;
final String assetPath;
final bool isFree;
/// 播放地址:详情接口不下发,由 `/app/episodes/{id}/play` 返回后写入。
final String videoUrl;
final int durationSec;
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
final bool locked;
Episode withVideoUrl(String url) => Episode(
id: id,
number: number,
title: title,
isFree: isFree,
videoUrl: url,
durationSec: durationSec,
locked: locked,
);
factory Episode.fromJson(Map<String, dynamic> json) {
return Episode(
id: (json['id'] ?? '').toString(),
number: (json['number'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '',
isFree: json['isFree'] == true,
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
locked: json['locked'] == true,
videoUrl: json['playUrl'] as String? ?? '',
);
}
}
class Drama {
@@ -21,15 +55,74 @@ class Drama {
required this.id,
required this.title,
required this.synopsis,
required this.posterAsset,
required this.episodes,
this.format = DramaFormat.liveAction,
required this.posterUrl,
this.episodes = const [],
this.category = '',
this.genre = '',
this.totalEpisodes = 0,
this.freeEpisodes = 0,
this.isHot = false,
this.viewCount = 0,
this.detailLoaded = false,
});
final String id;
final String title;
final String synopsis;
final String posterAsset;
final String posterUrl;
final List<Episode> episodes;
final DramaFormat format;
final String category;
final String genre;
final int totalEpisodes;
final int freeEpisodes;
final bool isHot;
final int viewCount;
/// 详情接口是否已经取过完整剧集列表。
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
final bool detailLoaded;
/// 卡片只带一集预览,集数以后端给的总集数为准。
int get episodeCount => totalEpisodes > 0 ? totalEpisodes : episodes.length;
/// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。
factory Drama.fromCardJson(Map<String, dynamic> json) {
final preview = json['preview'];
return Drama(
id: (json['id'] ?? '').toString(),
title: json['title'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '',
episodes: preview is Map<String, dynamic>
? [Episode.fromJson(preview)]
: const [],
category: json['category'] as String? ?? '',
genre: json['genre'] as String? ?? '',
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
isHot: json['isHot'] == true,
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
viewCount: jsonToInt(json['viewCount']),
);
}
/// 详情:带剧集列表。
factory Drama.fromDetailJson(Map<String, dynamic> json) {
return Drama(
id: (json['id'] ?? '').toString(),
title: json['title'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '',
category: json['category'] as String? ?? '',
genre: json['genre'] as String? ?? '',
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
isHot: json['isHot'] == true,
viewCount: jsonToInt(json['viewCount']),
detailLoaded: true,
episodes: (json['episodes'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Episode.fromJson)
.toList(),
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'drama.dart';
/// 首页/发现页聚合数据,一次请求拿到轮播与分区。
class HomeFeed {
const HomeFeed({this.banners = const [], this.sections = const []});
final List<FeedBanner> banners;
final List<FeedSection> sections;
bool get isEmpty => sections.every((section) => section.dramas.isEmpty);
/// 分区打平后的短剧列表,供现有 UI 的横滑/竖滑复用;按 id 去重。
List<Drama> get dramas {
final seen = <String>{};
final result = <Drama>[];
for (final section in sections) {
for (final drama in section.dramas) {
if (seen.add(drama.id)) result.add(drama);
}
}
return result;
}
factory HomeFeed.fromJson(Map<String, dynamic> json) {
final banners = (json['banners'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(FeedBanner.fromJson)
.toList();
final sections = (json['sections'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(FeedSection.fromJson)
.toList();
return HomeFeed(banners: banners, sections: sections);
}
}
class FeedSection {
const FeedSection({
required this.code,
required this.title,
this.dramas = const [],
});
final String code;
final String title;
final List<Drama> dramas;
factory FeedSection.fromJson(Map<String, dynamic> json) {
return FeedSection(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
dramas: (json['dramas'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Drama.fromCardJson)
.toList(),
);
}
}
class FeedBanner {
const FeedBanner({
required this.id,
required this.imageUrl,
this.linkType = 'NONE',
this.linkValue = '',
});
final String id;
final String imageUrl;
final String linkType;
final String linkValue;
/// 点击轮播要打开的短剧 id,非短剧跳转返回 null。
String? get dramaId => linkType == 'DRAMA' && linkValue.isNotEmpty
? linkValue
: null;
factory FeedBanner.fromJson(Map<String, dynamic> json) {
return FeedBanner(
id: (json['id'] ?? '').toString(),
imageUrl: json['imageUrl'] as String? ?? '',
linkType: json['linkType'] as String? ?? 'NONE',
linkValue: json['linkValue'] as String? ?? '',
);
}
}
+13
View File
@@ -0,0 +1,13 @@
// Author: fruit.com.cn
/// 数值字段的兼容解析。
///
/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段**一律以字符串下发**
/// (雪花 id 与计数都算),`Integer`/`int` 仍是数字。所以这里既接受数字也接受数字字符串,
/// 否则一个 `"12345"` 就能让 `as num?` 直接抛异常、整页解析失败。
int jsonToInt(dynamic value, {int fallback = 0}) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value.trim()) ?? fallback;
return fallback;
}
+37
View File
@@ -0,0 +1,37 @@
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
class PlayTicket {
const PlayTicket({
required this.episodeId,
required this.accessible,
required this.accessType,
this.playUrl = '',
this.durationSec = 0,
this.unlockCoin = 0,
});
static const String free = 'FREE';
static const String coinRequired = 'COIN_REQUIRED';
static const String vipRequired = 'VIP_REQUIRED';
static const String noMedia = 'NO_MEDIA';
final String episodeId;
final bool accessible;
final String accessType;
final String playUrl;
final int durationSec;
final int unlockCoin;
bool get needsUnlock =>
accessType == coinRequired || accessType == vipRequired;
factory PlayTicket.fromJson(Map<String, dynamic> json) {
return PlayTicket(
episodeId: (json['episodeId'] ?? '').toString(),
accessible: json['accessible'] == true,
accessType: json['accessType'] as String? ?? noMedia,
playUrl: json['playUrl'] as String? ?? '',
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
unlockCoin: (json['unlockCoin'] as num?)?.toInt() ?? 0,
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
import 'api_exception.dart';
import 'api_interceptors.dart';
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
export 'api_exception.dart';
/// 后端统一响应壳 `{code, message, data}` 的解析入口。
///
/// 职责:
/// - 用 dio 发请求(`AuthHeaderInterceptor` 注入令牌与设备号,`EnvelopeInterceptor` 解包与映射错误码);
/// - 编排「401 → 刷新令牌 → 重发一次」——**刻意不放在拦截器里重发**,因为 dio 的排队拦截器
/// 在回调里再发起请求会把自己排到队尾,造成自锁。
class ApiClient {
ApiClient({
String? baseUrl,
HttpClientAdapter? adapter,
this.accessTokenReader,
this.tokenRefresher,
this.onUnauthorized,
this.deviceIdReader,
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl {
_dio = Dio(
BaseOptions(
baseUrl: this.baseUrl,
connectTimeout: AppConfig.requestTimeout,
receiveTimeout: AppConfig.requestTimeout,
// 拿原始字节自己解码:后端消息是 UTF-8,且"不是 JSON 的响应体"要能被识别出来
responseType: ResponseType.bytes,
// 非 2xx 不抛异常:业务结果由响应体里的 code 表达(与旧实现一致)
validateStatus: (_) => true,
),
);
_dio.interceptors.addAll([
AuthHeaderInterceptor(
accessTokenReader: accessTokenReader,
deviceIdReader: deviceIdReader,
),
const EnvelopeInterceptor(),
]);
if (adapter != null) {
_dio.httpClientAdapter = adapter;
}
}
final String baseUrl;
late final Dio _dio;
/// 取当前 access token(未登录返回 null)
final String? Function()? accessTokenReader;
/// 刷新令牌:返回 true 表示刷新成功,可以重试原请求
final Future<bool> Function()? tokenRefresher;
/// 刷新也失败时回调(清本地登录态、回收 UI)
final void Function()? onUnauthorized;
/// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用
final Future<String> Function()? deviceIdReader;
Future<Object?> get(String path, {Map<String, String>? query}) {
return _request('GET', path, query: query);
}
Future<Object?> post(
String path, {
Object? body,
Map<String, String>? query,
bool skipAuthRetry = false,
}) {
return _request(
'POST',
path,
body: body,
query: query,
skipAuthRetry: skipAuthRetry,
);
}
Future<Object?> _request(
String method,
String path, {
Object? body,
Map<String, String>? query,
bool retried = false,
bool skipAuthRetry = false,
}) async {
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
try {
final response = await _dio.request<Object?>(
path,
data: body,
queryParameters: (query?.isEmpty ?? true) ? null : query,
options: Options(
method: method,
contentType: body == null ? null : Headers.jsonContentType,
extra: {
ApiRequestFlags.disableAuthRetry: disableAuthRetry,
ApiRequestFlags.retried: retried,
},
),
);
return response.data;
} on DioException catch (error) {
final cause = error.error;
if (cause is TokenExpiredException) {
final refreshed = await tokenRefresher?.call() ?? false;
if (refreshed) {
return _request(
method,
path,
body: body,
query: query,
retried: true,
skipAuthRetry: skipAuthRetry,
);
}
onUnauthorized?.call();
throw ApiException(401, cause.message ?? '登录已过期');
}
if (cause is ApiException) {
throw cause;
}
rethrow;
}
}
void close() => _dio.close(force: true);
}
+15
View File
@@ -0,0 +1,15 @@
// Author: fruit.com.cn
/// 接口异常:code 为后端业务码,message 可直接展示。
///
/// 单独成文件是为了让 [ApiClient] 与拦截器都能引用它而不互相 import
/// (`api_client.dart` 会 `export` 本文件,历史调用方继续 `import 'api_client.dart'` 即可拿到它)。
class ApiException implements Exception {
const ApiException(this.code, this.message);
final int code;
final String message;
@override
String toString() => 'ApiException($code, $message)';
}
+133
View File
@@ -0,0 +1,133 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:dio/dio.dart';
import 'api_exception.dart';
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
class ApiRequestFlags {
const ApiRequestFlags._();
/// 本次请求不参与「401 → 刷新 → 重试」:刷新令牌自身、登出,以及没有刷新回调时。
static const String disableAuthRetry = 'app.auth.disableRetry';
/// 本次请求已经是刷新后的重试,见到它就不再刷新,避免无限循环。
static const String retried = 'app.auth.retried';
}
/// 内部信号:响应体里 `code == 401`,应当由 `ApiClient` 刷新令牌后重发一次。
///
/// 只在本层流转,不会漏给调用方 —— 调用方拿到的始终是 `ApiException`。
class TokenExpiredException implements Exception {
const TokenExpiredException(this.message);
/// 后端返回的提示语(可能为空),用于刷新失败时拼 `ApiException` 的消息。
final String? message;
}
/// 注入鉴权与设备标识请求头。
///
/// 每个请求都重新取一次令牌,所以刷新成功后重发的那次会自动带上新令牌。
class AuthHeaderInterceptor extends Interceptor {
AuthHeaderInterceptor({this.accessTokenReader, this.deviceIdReader});
/// 当前 access token;未登录返回 null 或空串。
final String? Function()? accessTokenReader;
/// 设备号(`X-Device-Id`):注册风控、下载绑定都用它。
final Future<String> Function()? deviceIdReader;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
options.headers['Accept'] = 'application/json';
final token = accessTokenReader?.call();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
final deviceId = await deviceIdReader?.call();
if (deviceId != null && deviceId.isNotEmpty) {
options.headers['X-Device-Id'] = deviceId;
}
handler.next(options);
}
}
/// 解包后端统一响应壳 `{code,message,data}`,并把业务码映射成异常。
///
/// 判定口径与旧的 `http` 实现逐条对齐:
/// - body 不是合法 JSON → 视为 `code = -1`,抛 `ApiException(状态码, 'HTTP <状态码>')`
/// - `code != 0` → 抛 `ApiException(code, message ?? '请求失败')`
/// - `code == 401` 且允许刷新 → 抛 [TokenExpiredException] 作为信号(由 `ApiClient` 刷新后重发)
class EnvelopeInterceptor extends Interceptor {
const EnvelopeInterceptor();
@override
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
final options = response.requestOptions;
final shell = _decodeShell(response.data);
final code = shell['code'] as int? ?? -1;
final message = shell['message'] as String?;
final retried = options.extra[ApiRequestFlags.retried] == true;
final disableRetry =
options.extra[ApiRequestFlags.disableAuthRetry] == true;
if (code == 401 && !retried && !disableRetry) {
return handler.reject(
DioException(
requestOptions: options,
response: response,
type: DioExceptionType.badResponse,
error: TokenExpiredException(message),
),
true,
);
}
if (code == -1) {
final status = response.statusCode ?? -1;
return handler.reject(
DioException(
requestOptions: options,
response: response,
type: DioExceptionType.badResponse,
error: ApiException(status, 'HTTP $status'),
),
true,
);
}
if (code != 0) {
return handler.reject(
DioException(
requestOptions: options,
response: response,
type: DioExceptionType.badResponse,
error: ApiException(code, message ?? '请求失败'),
),
true,
);
}
response.data = shell['data'];
handler.next(response);
}
/// 统一按 UTF-8 解码:后端的中文提示语不能因为缺 charset 而乱码。
Map<String, dynamic> _decodeShell(Object? body) {
final text = switch (body) {
List<int> bytes => utf8.decode(bytes, allowMalformed: true),
String text => text,
_ => '',
};
try {
return (jsonDecode(text) as Map).cast<String, dynamic>();
} catch (_) {
return const {};
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import '../models/auth_session.dart';
import 'api_client.dart';
/// 账号相关接口(/app/auth/**)。
///
/// 注册/登录都用「邮箱 + 密码」;邮箱注册不需要验证码(后端按 targetType 放开),
/// 手机号注册仍要验证码 —— 客户端这一批只开放邮箱入口。
class AuthApi {
const AuthApi(this._client);
final ApiClient _client;
Future<AuthSession> register({
required String email,
required String password,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/register',
body: {
'targetType': 'EMAIL',
'target': email,
'password': password,
'countryCode': 'ID',
'deviceId': deviceId,
'platform': platform,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> login({
required String email,
required String password,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/login',
body: {
'targetType': 'EMAIL',
'target': email,
'password': password,
'deviceId': deviceId,
'platform': platform,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> refresh(String refreshToken) async {
final data = await _client.post(
'/app/auth/refresh',
body: {'refreshToken': refreshToken},
// 刷新请求本身不带旧令牌重试,失败就是失败
skipAuthRetry: true,
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<void> logout(String refreshToken) async {
await _client.post(
'/app/auth/logout',
body: {'refreshToken': refreshToken},
skipAuthRetry: true,
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../models/play_ticket.dart';
import 'api_client.dart';
/// 客户端目录接口,对应后端 `/app/**`。
class CatalogApi {
const CatalogApi(this._client);
final ApiClient _client;
Future<HomeFeed> homeFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed')));
}
Future<HomeFeed> discoverFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
}
Future<Drama> dramaDetail(String dramaId) async {
return Drama.fromDetailJson(
_asMap(await _client.get('/app/dramas/$dramaId')),
);
}
Future<PlayTicket> playTicket(String episodeId) async {
return PlayTicket.fromJson(
_asMap(await _client.get('/app/episodes/$episodeId/play')),
);
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '返回数据格式异常');
}
}
@@ -1,104 +1,36 @@
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../models/play_ticket.dart';
import '../remote/catalog_api.dart';
/// 目录数据来源:首页/发现页聚合、详情、播放票据。
///
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
abstract interface class CatalogRepository {
Future<List<Drama>> fetchDramas();
Future<HomeFeed> fetchHomeFeed();
Future<HomeFeed> fetchDiscoverFeed();
Future<Drama> fetchDrama(String dramaId);
Future<PlayTicket> resolvePlay(String episodeId);
}
class DemoCatalogRepository implements CatalogRepository {
class HttpCatalogRepository implements CatalogRepository {
HttpCatalogRepository(this._api);
final CatalogApi _api;
@override
Future<List<Drama>> fetchDramas() async => const [
Drama(
id: 'the-signal-demo',
title: 'The Signal',
synopsis:
'A mock series for testing vertical episode playback with one reused local clip.',
posterAsset: 'assets/images/stitch_signal_poster_clean.png',
format: DramaFormat.animated,
episodes: [
Episode(
id: 'signal-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'signal-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'signal-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
Drama(
id: 'city-lights-demo',
title: 'City Lights & Velvet Nights',
synopsis:
'A mock romance series using the same local video for navigation testing.',
posterAsset: 'assets/images/stitch_city_poster_clean.png',
format: DramaFormat.liveAction,
episodes: [
Episode(
id: 'city-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'city-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'city-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
Drama(
id: 'vengeance-demo',
title: 'Vengeance Rising',
synopsis:
'A mock action series using the same local video for navigation testing.',
posterAsset: 'assets/images/stitch_vengeance_poster_clean.png',
format: DramaFormat.liveAction,
episodes: [
Episode(
id: 'vengeance-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'vengeance-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'vengeance-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
];
Future<HomeFeed> fetchHomeFeed() => _api.homeFeed();
@override
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
@override
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
@override
Future<PlayTicket> resolvePlay(String episodeId) =>
_api.playTicket(episodeId);
}
@@ -1,5 +1,7 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../models/drama.dart';
const demoProfileId = 'demo-local-v1';
abstract interface class WatchHistoryRepository {
@@ -60,6 +62,17 @@ class SharedPreferencesWatchHistoryRepository
}
}
/// 续看位置:列表卡片没有剧集列表,取不到首集时退回最近观看的那一集。
extension WatchHistoryResume on WatchHistoryRepository {
Future<Duration?> resumePositionFor(String profileId, Drama drama) async {
final episodeId = drama.episodes.isNotEmpty
? drama.episodes.first.id
: await lastEpisodeId(profileId, drama.id);
if (episodeId == null) return null;
return positionFor(profileId, drama.id, episodeId);
}
}
Duration clampResumePosition(Duration saved, Duration duration) {
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
if (saved <= Duration.zero) return Duration.zero;
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/foundation.dart';
import '../models/auth_session.dart';
import '../remote/api_client.dart';
import '../remote/auth_api.dart';
import 'auth_session_store.dart';
/// 登录态的唯一来源。
///
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
class AuthController extends ChangeNotifier {
AuthController(this._api, this._store);
final AuthApi _api;
final AuthSessionStore _store;
AuthSession? _session;
String _deviceId = '';
bool _loaded = false;
bool get isLoaded => _loaded;
bool get isSignedIn => _session != null;
AuthUser? get user => _session?.user;
String? get accessToken => _session?.accessToken;
String get deviceId => _deviceId;
/// App 启动时调用:恢复本地令牌与设备号。
Future<void> load() async {
_deviceId = await _store.deviceId();
_session = await _store.read();
_loaded = true;
notifyListeners();
}
Future<void> register({required String email, required String password}) async {
final session = await _api.register(
email: email,
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
);
await _apply(session);
}
Future<void> signIn({required String email, required String password}) async {
final session = await _api.login(
email: email,
password: password,
deviceId: await _store.deviceId(),
platform: _platform,
);
await _apply(session);
}
Future<void> signOut() async {
final refreshToken = _session?.refreshToken ?? '';
_session = null;
await _store.clear();
notifyListeners();
if (refreshToken.isNotEmpty) {
// 服务端吊销失败不影响本地已登出,异常直接吞掉
try {
await _api.logout(refreshToken);
} catch (_) {}
}
}
/// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。
Future<bool> refresh() async {
final refreshToken = _session?.refreshToken ?? '';
if (refreshToken.isEmpty) {
await clear();
return false;
}
try {
await _apply(await _api.refresh(refreshToken));
return true;
} catch (_) {
await clear();
return false;
}
}
Future<void> clear() async {
if (_session == null) return;
_session = null;
await _store.clear();
notifyListeners();
}
Future<void> _apply(AuthSession session) async {
_session = session;
await _store.save(session);
notifyListeners();
}
String get _platform {
if (kIsWeb) return 'WEB';
return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID';
}
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/auth_session.dart';
/// 令牌与设备号的本地持久化。
///
/// 用 `SharedPreferencesAsync`(与项目里 engagement 仓库一致):旧的
/// `SharedPreferences.getInstance()` 在新版本里已标注弃用,两套混用还会让
/// widget 测试只 mock 到其中一套。
///
/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**;
/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。
class AuthSessionStore {
static const _kAccessToken = 'auth.accessToken';
static const _kRefreshToken = 'auth.refreshToken';
static const _kUser = 'auth.user';
static const _kDeviceId = 'device.id';
String? _cachedDeviceId;
Future<String> deviceId() async {
if (_cachedDeviceId != null) return _cachedDeviceId!;
final prefs = SharedPreferencesAsync();
var value = await prefs.getString(_kDeviceId) ?? '';
if (value.isEmpty) {
final random = Random();
value =
'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-'
'${random.nextInt(1 << 32).toRadixString(36)}';
await prefs.setString(_kDeviceId, value);
}
_cachedDeviceId = value;
return value;
}
Future<AuthSession?> read() async {
final prefs = SharedPreferencesAsync();
final accessToken = await prefs.getString(_kAccessToken) ?? '';
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
final rawUser = await prefs.getString(_kUser);
Map<String, dynamic> user = const {};
if (rawUser != null && rawUser.isNotEmpty) {
try {
user = (jsonDecode(rawUser) as Map).cast<String, dynamic>();
} catch (_) {
user = const {};
}
}
return AuthSession(
accessToken: accessToken,
refreshToken: refreshToken,
user: AuthUser.fromJson(user),
);
}
Future<void> save(AuthSession session) async {
final prefs = SharedPreferencesAsync();
await prefs.setString(_kAccessToken, session.accessToken);
await prefs.setString(_kRefreshToken, session.refreshToken);
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
}
Future<void> clear() async {
final prefs = SharedPreferencesAsync();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUser);
// 设备号不清:它代表"这台设备",与登录态无关
}
}
+51 -5
View File
@@ -1,26 +1,72 @@
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../repositories/catalog_repository.dart';
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../repositories/catalog_repository.dart';
import '../remote/api_client.dart';
/// 目录状态:首页/发现页聚合数据 + 详情按需加载。
///
/// 列表卡片不含剧集,只有进详情或播放时才取一次详情,
/// 这样首页接口能保持很小的响应体。
class CatalogController extends ChangeNotifier {
CatalogController(this.repository);
final CatalogRepository repository;
List<Drama> dramas = const [];
HomeFeed feed = const HomeFeed();
bool loading = false;
String? error;
List<Drama> get dramas => feed.dramas;
List<FeedBanner> get banners => feed.banners;
List<FeedSection> get sections => feed.sections;
Future<void> load() async {
loading = true;
error = null;
notifyListeners();
try {
dramas = await repository.fetchDramas();
feed = await repository.fetchHomeFeed();
} on ApiException catch (exception) {
error = exception.message;
} catch (_) {
error = 'The catalog could not be loaded.';
error = 'catalogError';
} finally {
loading = false;
notifyListeners();
}
}
/// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。
Future<Drama> requireDetail(Drama drama) async {
if (drama.detailLoaded) return drama;
final detail = await repository.fetchDrama(drama.id);
_replace(detail);
return detail;
}
/// 播放前解析票据:返回带播放地址的剧集,锁定集保持无地址。
Future<Episode> resolvePlay(Episode episode) async {
if (episode.videoUrl.isNotEmpty) return episode;
final ticket = await repository.resolvePlay(episode.id);
return ticket.accessible ? episode.withVideoUrl(ticket.playUrl) : episode;
}
void _replace(Drama detail) {
final sections = feed.sections
.map(
(section) => FeedSection(
code: section.code,
title: section.title,
dramas: [
for (final drama in section.dramas)
if (drama.id == detail.id) detail else drama,
],
),
)
.toList();
feed = HomeFeed(banners: feed.banners, sections: sections);
notifyListeners();
}
}
+13 -1
View File
@@ -128,6 +128,9 @@ class PlaybackSession extends ChangeNotifier {
if (!slot.retiring && !_closed) slot.prepared = true;
} catch (error) {
slot.failure = error;
if (kDebugMode) {
debugPrint('片源准备失败 ${episodes[index].videoUrl}: $error');
}
}
}();
return slot;
@@ -149,14 +152,23 @@ class PlaybackSession extends ChangeNotifier {
/// use exactly the same mechanism without warming an unrelated episode.
Future<void> select(int index, {int? preloadIndex}) async {
if (_closed || index < 0 || index >= episodes.length) return;
// 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。
// 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。
if (!_selected || index != _currentIndex) {
_userWantsPlay = true;
}
final request = ++_generation;
_selected = true;
_currentIndex = index;
_nextIndex = preloadIndex;
_error = null;
if (!_visible) {
_loading = false;
_notify();
return;
}
_loading = preparedPlayerAt(index) == null && !isLocked;
_notify();
if (!_visible) return;
final acquired = await _coordinator._acquire(
this,
() => _visible && request == _generation,
+20 -5
View File
@@ -1,5 +1,6 @@
import 'package:video_player/video_player.dart';
import '../../config/app_config.dart';
import '../models/drama.dart';
import '../models/player_port.dart';
@@ -7,11 +8,25 @@ class VideoPlayerPort implements PlayerPort {
VideoPlayerPort(
Episode episode, {
Future<ClosedCaptionFile>? closedCaptionFile,
}) : controller = VideoPlayerController.asset(
episode.assetPath,
closedCaptionFile: closedCaptionFile,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
);
}) : controller = _open(episode, closedCaptionFile);
/// 后端下发的是相对路径(`/files/...`),交给播放器前补上服务地址。
/// 否则 Uri 没有 scheme/host,AVPlayer 和 ExoPlayer 都加载不了。
static Uri resolveUri(Episode episode) =>
Uri.parse(AppConfig.mediaUrl(episode.videoUrl));
static VideoPlayerController _open(
Episode episode,
Future<ClosedCaptionFile>? closedCaptionFile,
) {
final options = VideoPlayerOptions(mixWithOthers: false);
// 片源统一由后端下发(详情里的可播放地址 / 播放票据)
return VideoPlayerController.networkUrl(
resolveUri(episode),
closedCaptionFile: closedCaptionFile,
videoPlayerOptions: options,
);
}
final VideoPlayerController controller;
Future<void>? _initialization;
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
/// Shared colors. Blue is the primary action color.
abstract final class AppColors {
static const blue = Color(0xFF2F7BFF);
static const blueSoft = Color(0xFF8FB4FF);
static const ink = Color(0xFF0B0C10);
static const bar = Color(0xFF101114);
static const card = Color(0xFF17191F);
static const muted = Color(0xFFB7BDC9);
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../pages/auth/auth_view.dart';
import '../data/services/auth_controller.dart';
import '../theme/app_colors.dart';
/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库)
Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AuthPage(signUpMode: signUpMode),
),
);
}
/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。
///
/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。
Future<bool> ensureSignedIn(BuildContext context) async {
final auth = context.read<AuthController>();
if (auth.isSignedIn) return true;
final l10n = AppLocalizations.of(context)!;
final go = await showModalBottomSheet<bool>(
context: context,
backgroundColor: AppColors.card,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.authGateTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
l10n.authGateMessage,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(sheetContext).pop(false),
child: Text(l10n.cancel),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
),
onPressed: () => Navigator.of(sheetContext).pop(true),
child: Text(l10n.authGateAction),
),
),
],
),
],
),
),
),
);
if (go == true && context.mounted) {
await openAuthPage(context);
}
return false;
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../config/app_config.dart';
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
class RemoteImage extends StatelessWidget {
const RemoteImage(
this.path, {
super.key,
this.width,
this.height,
this.fit = BoxFit.cover,
});
final String path;
final double? width;
final double? height;
final BoxFit fit;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _fallback();
return Image.network(
url,
width: width,
height: height,
fit: fit,
errorBuilder: (_, _, _) => _fallback(),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return _fallback();
},
);
}
Widget _fallback() {
return Container(
width: width,
height: height,
color: const Color(0xFF1C1F26),
);
}
}
+53 -3
View File
@@ -1,6 +1,6 @@
{
"@@locale": "en",
"appName": "Nontonki",
"appName": "FruitShorts",
"demo": "DEMO",
"homeHeadline": "Watch now",
"homeSubtitle": "Free short drama preview.",
@@ -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",
@@ -129,6 +131,54 @@
"genreDramaMystery": "Drama · Mystery",
"continueWatchingHeading": "CONTINUE WATCHING",
"preferencesHeading": "PREFERENCES",
"brandMark": "NONTONKI · DEMO",
"shareFooter": "Nontonki demo"
"brandMark": "FRUITSHORTS",
"shareFooter": "FruitShorts",
"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"
}
+184
View File
@@ -0,0 +1,184 @@
{
"@@locale": "id",
"appName": "FruitShorts",
"demo": "Demo",
"homeHeadline": "Tonton sekarang",
"homeSubtitle": "Pratinjau drama pendek gratis.",
"mediaNotice": "Video demo lokal dari pengguna · Pratinjau gratis",
"featuredDemo": "UNGGULAN · DEMO TEKNIS",
"demoTitle": "Sinyal",
"demoSynopsis": "Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.",
"homeTab": "Beranda",
"openQuickMenu": "Buka menu cepat",
"quickAccess": "Akses cepat",
"currentSeries": "Serial saat ini",
"discoverTab": "Jelajahi",
"searchDiscover": "Cari drama, aktor, genre...",
"featured": "Unggulan",
"suspense": "Thriller",
"romance": "Romantis",
"actionGenre": "Aksi",
"filter": "Filter",
"topCharts": "Peringkat Teratas",
"newReleases": "Rilisan Baru",
"trendingMicroDramas": "Drama Mini Populer",
"seeAll": "Lihat Semua",
"artworkOnly": "Gambar",
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
"videoPending": "Video belum siap",
"watchNow": "Tonton gratis",
"noSearchResults": "Tidak ada drama yang cocok.",
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
"myTab": "Saya",
"localViewer": "Penonton Lokal",
"localDemoProfile": "Profil demo lokal",
"continueWatching": "Lanjut Tonton",
"watchedPercent": "Sudah ditonton {percent}%",
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"resume": "Lanjutkan",
"history": "Riwayat",
"watchlist": "Daftar Tonton",
"likedTab": "Disukai",
"all": "Semua",
"inProgress": "Sedang Berjalan",
"completed": "Selesai",
"noSavedShows": "Belum ada tontonan di sini.",
"preferences": "Preferensi",
"appLanguage": "Bahasa Aplikasi",
"followingTab": "Mengikuti",
"animatedTab": "Animasi",
"recommendedTab": "Rekomendasi",
"noCategoryDramas": "Belum ada drama di kategori ini.",
"trendingTab": "Sedang Tren",
"liveActionTab": "Live Action",
"search": "Cari",
"free": "Gratis",
"mute": "Bisukan",
"unmute": "Nyalakan suara",
"like": "Suka",
"save": "Simpan",
"comments": "Komentar",
"share": "Bagikan",
"localOnlyComments": "Hanya terlihat di perangkat ini",
"noComments": "Belum ada komentar.",
"writeComment": "Tulis komentar",
"postComment": "Kirim",
"shareFailed": "Tidak bisa membuka menu berbagi.",
"nowPlaying": "Sedang diputar",
"watchFullscreen": "Tonton mode lanskap",
"exitFullscreen": "Keluar dari layar penuh",
"pictureInPicture": "Gambar dalam gambar",
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
"originalTag": "Original",
"dramaTag": "Drama pendek",
"episodeLabel": "Episode {number}",
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"exploreSeries": "Lihat serial",
"episodeCount": "{count} EPISODE",
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
"technicalDemoCount": "PRATINJAU GRATIS · {count} EPISODE",
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
"episodes": "Episode",
"watchEpisodeOne": "Tonton episode 1",
"continueEpisode": "Lanjut episode {number}",
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
"freeTestMedia": "Klip demo sama · gratis",
"lockedStoreUnavailable": "Terkunci · toko belum tersedia",
"freeTestClip": "Klip uji gratis",
"locked": "Terkunci",
"freeSamplesNotice": "Dua sampel gratis · pengaturan toko belum selesai",
"demoTestMedia": "GRATIS · KLIP DEMO SAMA",
"episodeProgress": "EPISODE {number} / {total}",
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
"testVideoNotice": "Video uji teknis buatan sendiri",
"lockedTitle": "Episode {number} terkunci",
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
"lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.",
"playbackError": "Klip ini tidak bisa diputar.",
"catalogError": "Katalog gagal dimuat.",
"detailError": "Daftar episode gagal dimuat.",
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
"noStories": "Belum ada cerita.",
"retry": "Coba lagi",
"backToSeries": "Kembali ke serial",
"nextEpisode": "Episode berikutnya",
"pause": "Jeda",
"play": "Putar",
"seekVideo": "Geser posisi video",
"seekFailed": "Gagal mengubah posisi putar.",
"captions": "Subtitle",
"captionsOff": "Subtitle nonaktif",
"englishCaptions": "Subtitle bahasa Inggris",
"chineseCaptions": "Subtitle bahasa Mandarin",
"language": "Bahasa",
"systemLanguage": "Sistem",
"english": "English",
"simplifiedChinese": "简体中文",
"episodeOneTitle": "Cahaya Pertama",
"episodeTwoTitle": "Frekuensi Baru",
"episodeThreeTitle": "Menyeberang",
"episodeFourTitle": "Setelah Gema",
"dramaSignalTitle": "Sinyal",
"dramaSignalSynopsis": "Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.",
"dramaCityTitle": "Cahaya Kota & Malam Beludru",
"dramaCitySynopsis": "Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.",
"dramaVengeanceTitle": "Badai Pembalasan",
"dramaVengeanceSynopsis": "Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.",
"dramaEchoesTitle": "Gema Takdir",
"genreSuspenseMystery": "Thriller · Misteri",
"genreUrbanRomance": "Romansa Kota",
"genreActionThriller": "Aksi · Thriller",
"genreDramaMystery": "Drama · Misteri",
"continueWatchingHeading": "LANJUT TONTON",
"preferencesHeading": "PREFERENSI",
"brandMark": "FRUITSHORTS",
"shareFooter": "FruitShorts",
"shortsTab": "Drama Pendek",
"myListTab": "Daftar Saya",
"watchFull": "Tonton Penuh",
"swipeForNext": "Geser ke atas untuk lanjut",
"clearScreen": "Bersihkan layar",
"exitClearScreen": "Keluar dari mode bersih",
"logIn": "Masuk",
"logInHint": "Simpan koin dan daftar kamu",
"rewards": "Hadiah",
"forYou": "Untuk Kamu",
"hot": "Populer",
"newest": "Terbaru",
"favorite": "Favorit",
"myAccount": "Akun saya",
"coins": "Koin",
"topUpWallet": "Isi Ulang Dompet",
"vipMembership": "Keanggotaan VIP",
"notActive": "Tidak Aktif",
"joinVip": "Gabung VIP",
"earnRewards": "Dapatkan Hadiah",
"freeBonus": "Bonus Gratis",
"watchHistory": "Riwayat Tontonan",
"settings": "Pengaturan",
"signOut": "Keluar",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
"mockNotice": "Hanya data contoh.",
"topBadge": "TOP 1",
"signIn": "Masuk",
"signUp": "Daftar",
"authSignInTitle": "Selamat datang kembali",
"authSignUpTitle": "Buat akun kamu",
"authSubtitle": "Masuk dengan email dan kata sandi.",
"authEmailLabel": "Email",
"authPasswordLabel": "Kata sandi",
"authPasswordHint": "Minimal 8 karakter, berisi huruf dan angka",
"authEmailRequired": "Masukkan email kamu",
"authEmailInvalid": "Email itu sepertinya tidak benar",
"authPasswordRequired": "Masukkan kata sandi kamu",
"authPasswordLength": "Kata sandi harus 8-64 karakter",
"authPasswordWeak": "Kata sandi perlu huruf dan angka",
"authNetworkError": "Jaringan bermasalah, coba lagi",
"authSwitchToSignUp": "Baru di sini? Buat akun",
"authSwitchToSignIn": "Sudah punya akun? Masuk",
"authGateTitle": "Masuk untuk melanjutkan",
"authGateMessage": "Favorit dan komentar perlu akun.",
"authGateAction": "Masuk",
"cancel": "Nanti saja"
}
+308 -4
View File
@@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_id.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -95,13 +96,14 @@ abstract class AppLocalizations {
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('id'),
Locale('zh'),
];
/// No description provided for @appName.
///
/// In en, this message translates to:
/// **'Nontonki'**
/// **'FruitShorts'**
String get appName;
/// No description provided for @demo.
@@ -632,6 +634,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:
@@ -833,14 +847,302 @@ abstract class AppLocalizations {
/// No description provided for @brandMark.
///
/// In en, this message translates to:
/// **'NONTONKI · DEMO'**
/// **'FRUITSHORTS'**
String get brandMark;
/// No description provided for @shareFooter.
///
/// In en, this message translates to:
/// **'Nontonki demo'**
/// **'FruitShorts'**
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
@@ -854,7 +1156,7 @@ class _AppLocalizationsDelegate
@override
bool isSupported(Locale locale) =>
<String>['en', 'zh'].contains(locale.languageCode);
<String>['en', 'id', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
@@ -865,6 +1167,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'id':
return AppLocalizationsId();
case 'zh':
return AppLocalizationsZh();
}
+153 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appName => 'Nontonki';
String get appName => 'FruitShorts';
@override
String get demo => 'DEMO';
@@ -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.';
@@ -397,8 +403,152 @@ class AppLocalizationsEn extends AppLocalizations {
String get preferencesHeading => 'PREFERENCES';
@override
String get brandMark => 'NONTONKI · DEMO';
String get brandMark => 'FRUITSHORTS';
@override
String get shareFooter => 'Nontonki demo';
String get shareFooter => 'FruitShorts';
@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';
}
+556
View File
@@ -0,0 +1,556 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Indonesian (`id`).
class AppLocalizationsId extends AppLocalizations {
AppLocalizationsId([String locale = 'id']) : super(locale);
@override
String get appName => 'FruitShorts';
@override
String get demo => 'Demo';
@override
String get homeHeadline => 'Tonton sekarang';
@override
String get homeSubtitle => 'Pratinjau drama pendek gratis.';
@override
String get mediaNotice => 'Video demo lokal dari pengguna · Pratinjau gratis';
@override
String get featuredDemo => 'UNGGULAN · DEMO TEKNIS';
@override
String get demoTitle => 'Sinyal';
@override
String get demoSynopsis =>
'Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.';
@override
String get homeTab => 'Beranda';
@override
String get openQuickMenu => 'Buka menu cepat';
@override
String get quickAccess => 'Akses cepat';
@override
String get currentSeries => 'Serial saat ini';
@override
String get discoverTab => 'Jelajahi';
@override
String get searchDiscover => 'Cari drama, aktor, genre...';
@override
String get featured => 'Unggulan';
@override
String get suspense => 'Thriller';
@override
String get romance => 'Romantis';
@override
String get actionGenre => 'Aksi';
@override
String get filter => 'Filter';
@override
String get topCharts => 'Peringkat Teratas';
@override
String get newReleases => 'Rilisan Baru';
@override
String get trendingMicroDramas => 'Drama Mini Populer';
@override
String get seeAll => 'Lihat Semua';
@override
String get artworkOnly => 'Gambar';
@override
String get artworkOnlyNotice =>
'Ini hanya gambar katalog. Videonya belum tersedia.';
@override
String get videoPending => 'Video belum siap';
@override
String get watchNow => 'Tonton gratis';
@override
String get noSearchResults => 'Tidak ada drama yang cocok.';
@override
String get demoCatalogNotice =>
'Tiga drama demo memakai video lokal yang sama.';
@override
String get myTab => 'Saya';
@override
String get localViewer => 'Penonton Lokal';
@override
String get localDemoProfile => 'Profil demo lokal';
@override
String get continueWatching => 'Lanjut Tonton';
@override
String watchedPercent(int percent) {
return 'Sudah ditonton $percent%';
}
@override
String get resume => 'Lanjutkan';
@override
String get history => 'Riwayat';
@override
String get watchlist => 'Daftar Tonton';
@override
String get likedTab => 'Disukai';
@override
String get all => 'Semua';
@override
String get inProgress => 'Sedang Berjalan';
@override
String get completed => 'Selesai';
@override
String get noSavedShows => 'Belum ada tontonan di sini.';
@override
String get preferences => 'Preferensi';
@override
String get appLanguage => 'Bahasa Aplikasi';
@override
String get followingTab => 'Mengikuti';
@override
String get animatedTab => 'Animasi';
@override
String get recommendedTab => 'Rekomendasi';
@override
String get noCategoryDramas => 'Belum ada drama di kategori ini.';
@override
String get trendingTab => 'Sedang Tren';
@override
String get liveActionTab => 'Live Action';
@override
String get search => 'Cari';
@override
String get free => 'Gratis';
@override
String get mute => 'Bisukan';
@override
String get unmute => 'Nyalakan suara';
@override
String get like => 'Suka';
@override
String get save => 'Simpan';
@override
String get comments => 'Komentar';
@override
String get share => 'Bagikan';
@override
String get localOnlyComments => 'Hanya terlihat di perangkat ini';
@override
String get noComments => 'Belum ada komentar.';
@override
String get writeComment => 'Tulis komentar';
@override
String get postComment => 'Kirim';
@override
String get shareFailed => 'Tidak bisa membuka menu berbagi.';
@override
String get nowPlaying => 'Sedang diputar';
@override
String get watchFullscreen => 'Tonton mode lanskap';
@override
String get exitFullscreen => 'Keluar dari layar penuh';
@override
String get pictureInPicture => 'Gambar dalam gambar';
@override
String get pipUnavailable =>
'Gambar dalam gambar tidak tersedia di perangkat ini';
@override
String get originalTag => 'Original';
@override
String get dramaTag => 'Drama pendek';
@override
String episodeLabel(int number) {
return 'Episode $number';
}
@override
String get exploreSeries => 'Lihat serial';
@override
String episodeCount(int count) {
return '$count EPISODE';
}
@override
String technicalDemoCount(int count) {
return 'PRATINJAU GRATIS · $count EPISODE';
}
@override
String get episodes => 'Episode';
@override
String get watchEpisodeOne => 'Tonton episode 1';
@override
String continueEpisode(int number) {
return 'Lanjut episode $number';
}
@override
String get freeTestMedia => 'Klip demo sama · gratis';
@override
String get lockedStoreUnavailable => 'Terkunci · toko belum tersedia';
@override
String get freeTestClip => 'Klip uji gratis';
@override
String get locked => 'Terkunci';
@override
String get freeSamplesNotice =>
'Dua sampel gratis · pengaturan toko belum selesai';
@override
String get demoTestMedia => 'GRATIS · KLIP DEMO SAMA';
@override
String episodeProgress(int number, int total) {
return 'EPISODE $number / $total';
}
@override
String get testVideoNotice => 'Video uji teknis buatan sendiri';
@override
String lockedTitle(int number) {
return 'Episode $number terkunci';
}
@override
String get lockedBody =>
'Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.';
@override
String get playbackError => 'Klip ini tidak bisa diputar.';
@override
String get catalogError => 'Katalog gagal dimuat.';
@override
String get detailError => 'Daftar episode gagal dimuat.';
@override
String get noMediaNotice =>
'Episode ini belum punya media yang bisa diputar.';
@override
String get noStories => 'Belum ada cerita.';
@override
String get retry => 'Coba lagi';
@override
String get backToSeries => 'Kembali ke serial';
@override
String get nextEpisode => 'Episode berikutnya';
@override
String get pause => 'Jeda';
@override
String get play => 'Putar';
@override
String get seekVideo => 'Geser posisi video';
@override
String get seekFailed => 'Gagal mengubah posisi putar.';
@override
String get captions => 'Subtitle';
@override
String get captionsOff => 'Subtitle nonaktif';
@override
String get englishCaptions => 'Subtitle bahasa Inggris';
@override
String get chineseCaptions => 'Subtitle bahasa Mandarin';
@override
String get language => 'Bahasa';
@override
String get systemLanguage => 'Sistem';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get episodeOneTitle => 'Cahaya Pertama';
@override
String get episodeTwoTitle => 'Frekuensi Baru';
@override
String get episodeThreeTitle => 'Menyeberang';
@override
String get episodeFourTitle => 'Setelah Gema';
@override
String get dramaSignalTitle => 'Sinyal';
@override
String get dramaSignalSynopsis =>
'Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.';
@override
String get dramaCityTitle => 'Cahaya Kota & Malam Beludru';
@override
String get dramaCitySynopsis =>
'Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.';
@override
String get dramaVengeanceTitle => 'Badai Pembalasan';
@override
String get dramaVengeanceSynopsis =>
'Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.';
@override
String get dramaEchoesTitle => 'Gema Takdir';
@override
String get genreSuspenseMystery => 'Thriller · Misteri';
@override
String get genreUrbanRomance => 'Romansa Kota';
@override
String get genreActionThriller => 'Aksi · Thriller';
@override
String get genreDramaMystery => 'Drama · Misteri';
@override
String get continueWatchingHeading => 'LANJUT TONTON';
@override
String get preferencesHeading => 'PREFERENSI';
@override
String get brandMark => 'FRUITSHORTS';
@override
String get shareFooter => 'FruitShorts';
@override
String get shortsTab => 'Drama Pendek';
@override
String get myListTab => 'Daftar Saya';
@override
String get watchFull => 'Tonton Penuh';
@override
String get swipeForNext => 'Geser ke atas untuk lanjut';
@override
String get clearScreen => 'Bersihkan layar';
@override
String get exitClearScreen => 'Keluar dari mode bersih';
@override
String get logIn => 'Masuk';
@override
String get logInHint => 'Simpan koin dan daftar kamu';
@override
String get rewards => 'Hadiah';
@override
String get forYou => 'Untuk Kamu';
@override
String get hot => 'Populer';
@override
String get newest => 'Terbaru';
@override
String get favorite => 'Favorit';
@override
String get myAccount => 'Akun saya';
@override
String get coins => 'Koin';
@override
String get topUpWallet => 'Isi Ulang Dompet';
@override
String get vipMembership => 'Keanggotaan VIP';
@override
String get notActive => 'Tidak Aktif';
@override
String get joinVip => 'Gabung VIP';
@override
String get earnRewards => 'Dapatkan Hadiah';
@override
String get freeBonus => 'Bonus Gratis';
@override
String get watchHistory => 'Riwayat Tontonan';
@override
String get settings => 'Pengaturan';
@override
String get signOut => 'Keluar';
@override
String get mockUserName => 'Vicky sheng';
@override
String get mockUserId => 'ID: M1CZWVYE';
@override
String get mockNotice => 'Hanya data contoh.';
@override
String get topBadge => 'TOP 1';
@override
String get signIn => 'Masuk';
@override
String get signUp => 'Daftar';
@override
String get authSignInTitle => 'Selamat datang kembali';
@override
String get authSignUpTitle => 'Buat akun kamu';
@override
String get authSubtitle => 'Masuk dengan email dan kata sandi.';
@override
String get authEmailLabel => 'Email';
@override
String get authPasswordLabel => 'Kata sandi';
@override
String get authPasswordHint => 'Minimal 8 karakter, berisi huruf dan angka';
@override
String get authEmailRequired => 'Masukkan email kamu';
@override
String get authEmailInvalid => 'Email itu sepertinya tidak benar';
@override
String get authPasswordRequired => 'Masukkan kata sandi kamu';
@override
String get authPasswordLength => 'Kata sandi harus 8-64 karakter';
@override
String get authPasswordWeak => 'Kata sandi perlu huruf dan angka';
@override
String get authNetworkError => 'Jaringan bermasalah, coba lagi';
@override
String get authSwitchToSignUp => 'Baru di sini? Buat akun';
@override
String get authSwitchToSignIn => 'Sudah punya akun? Masuk';
@override
String get authGateTitle => 'Masuk untuk melanjutkan';
@override
String get authGateMessage => 'Favorit dan komentar perlu akun.';
@override
String get authGateAction => 'Masuk';
@override
String get cancel => 'Nanti saja';
}
+153 -3
View File
@@ -9,7 +9,7 @@ class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appName => 'Nontonki';
String get appName => 'FruitShorts';
@override
String get demo => '演示';
@@ -289,6 +289,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get catalogError => '剧目列表加载失败。';
@override
String get detailError => '剧集信息加载失败。';
@override
String get noMediaNotice => '该剧集还没有可播放的片源。';
@override
String get noStories => '暂无剧目。';
@@ -389,8 +395,152 @@ class AppLocalizationsZh extends AppLocalizations {
String get preferencesHeading => '偏好设置';
@override
String get brandMark => 'NONTONKI · 演示';
String get brandMark => 'FRUITSHORTS';
@override
String get shareFooter => 'Nontonki 演示';
String get shareFooter => 'FruitShorts';
@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 => '暂不';
}
+53 -3
View File
@@ -1,6 +1,6 @@
{
"@@locale": "zh",
"appName": "Nontonki",
"appName": "FruitShorts",
"demo": "演示",
"homeHeadline": "即刻观看",
"homeSubtitle": "免费短剧预览。",
@@ -91,6 +91,8 @@
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
"playbackError": "此视频暂时无法播放。",
"catalogError": "剧目列表加载失败。",
"detailError": "剧集信息加载失败。",
"noMediaNotice": "该剧集还没有可播放的片源。",
"noStories": "暂无剧目。",
"retry": "重试",
"backToSeries": "返回剧集详情",
@@ -124,6 +126,54 @@
"genreDramaMystery": "剧情 · 悬疑",
"continueWatchingHeading": "继续观看",
"preferencesHeading": "偏好设置",
"brandMark": "NONTONKI · 演示",
"shareFooter": "Nontonki 演示"
"brandMark": "FRUITSHORTS",
"shareFooter": "FruitShorts",
"shortsTab": "短剧",
"myListTab": "片单",
"watchFull": "看全集",
"swipeForNext": "上滑看下一部",
"clearScreen": "清屏",
"exitClearScreen": "退出清屏",
"logIn": "登录",
"logInHint": "登录后保存金币和片单",
"rewards": "奖励",
"forYou": "为你推荐",
"hot": "热门",
"newest": "最新",
"favorite": "收藏",
"myAccount": "我的账户",
"coins": "金币",
"topUpWallet": "充值",
"vipMembership": "会员",
"notActive": "未开通",
"joinVip": "开通会员",
"earnRewards": "赚奖励",
"freeBonus": "领免费奖励",
"watchHistory": "观看历史",
"settings": "设置",
"signOut": "退出登录",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
"mockNotice": "当前是示例数据。",
"topBadge": "TOP 1",
"signIn": "登录",
"signUp": "注册",
"authSignInTitle": "欢迎回来",
"authSignUpTitle": "创建账号",
"authSubtitle": "用邮箱和密码登录。",
"authEmailLabel": "邮箱",
"authPasswordLabel": "密码",
"authPasswordHint": "至少 8 位,需包含字母和数字",
"authEmailRequired": "请输入邮箱",
"authEmailInvalid": "邮箱格式不正确",
"authPasswordRequired": "请输入密码",
"authPasswordLength": "密码长度需在 8-64 位之间",
"authPasswordWeak": "密码需同时包含字母和数字",
"authNetworkError": "网络异常,请稍后重试",
"authSwitchToSignUp": "还没有账号?去注册",
"authSwitchToSignIn": "已有账号?去登录",
"authGateTitle": "登录后可继续",
"authGateMessage": "收藏、评论等操作需要登录。",
"authGateAction": "去登录",
"cancel": "暂不"
}
+6
View File
@@ -0,0 +1,6 @@
import 'auth_logic.dart';
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
class AuthBinding {
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
}
+50
View File
@@ -0,0 +1,50 @@
/// 登录 / 注册页的表单状态与提交逻辑。
///
/// 校验规则与后端一致(邮箱格式、密码 8-64 位且含字母数字),本地先拦一次是为了少一次网络往返、
/// 也让用户立刻看到哪个字段不合格;后端仍会再校验一遍(前端校验不作为安全边界)。
class AuthLogic {
bool signUpMode;
String email = '';
String password = '';
bool busy = false;
String? errorMessage;
AuthLogic({this.signUpMode = false, this.errorMessage});
void toggleMode() {
signUpMode = !signUpMode;
errorMessage = null;
}
/// 返回 null 表示校验通过,否则返回错误文案(由视图用 l10n 映射)
AuthFieldError? validate() {
final mail = email.trim();
if (mail.isEmpty) return AuthFieldError.emailRequired;
if (!_emailPattern.hasMatch(mail)) return AuthFieldError.emailInvalid;
if (password.isEmpty) return AuthFieldError.passwordRequired;
if (password.length < 8 || password.length > 64) {
return AuthFieldError.passwordLength;
}
if (!_hasLetter.hasMatch(password) || !_hasDigit.hasMatch(password)) {
return AuthFieldError.passwordWeak;
}
return null;
}
String get normalizedEmail => email.trim().toLowerCase();
static final _emailPattern = RegExp(
r'^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?'
r'(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,}$',
);
static final _hasLetter = RegExp('[A-Za-z]');
static final _hasDigit = RegExp('[0-9]');
}
enum AuthFieldError {
emailRequired,
emailInvalid,
passwordRequired,
passwordLength,
passwordWeak,
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import 'auth_logic.dart';
/// 登录 / 注册页:邮箱 + 密码。
///
/// 风格沿用 FruitShorts 现有页面(深色卡片 + 圆角 + 主色蓝),不另起一套视觉。
class AuthPage extends StatefulWidget {
const AuthPage({super.key, this.signUpMode = false});
final bool signUpMode;
@override
State<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
late AuthLogic _logic;
@override
void initState() {
super.initState();
_logic = AuthLogic(signUpMode: widget.signUpMode);
}
String _errorText(AppLocalizations l10n, AuthFieldError error) {
switch (error) {
case AuthFieldError.emailRequired:
return l10n.authEmailRequired;
case AuthFieldError.emailInvalid:
return l10n.authEmailInvalid;
case AuthFieldError.passwordRequired:
return l10n.authPasswordRequired;
case AuthFieldError.passwordLength:
return l10n.authPasswordLength;
case AuthFieldError.passwordWeak:
return l10n.authPasswordWeak;
}
}
Future<void> _submit() async {
final l10n = AppLocalizations.of(context)!;
final invalid = _logic.validate();
if (invalid != null) {
setState(() => _logic.errorMessage = _errorText(l10n, invalid));
return;
}
setState(() {
_logic.busy = true;
_logic.errorMessage = null;
});
final auth = context.read<AuthController>();
try {
if (_logic.signUpMode) {
await auth.register(
email: _logic.normalizedEmail,
password: _logic.password,
);
} else {
await auth.signIn(
email: _logic.normalizedEmail,
password: _logic.password,
);
}
if (!mounted) return;
Navigator.of(context).maybePop();
} on ApiException catch (error) {
// 后端文案已经是可直接展示的中文/印尼语提示,错误码不外露
setState(() => _logic.errorMessage = error.message);
} catch (_) {
setState(() => _logic.errorMessage = l10n.authNetworkError);
} finally {
if (mounted) setState(() => _logic.busy = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
elevation: 0,
foregroundColor: Colors.white,
title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
children: [
Text(
_logic.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
l10n.authSubtitle,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 22),
_field(
label: l10n.authEmailLabel,
hint: 'name@example.com',
keyboardType: TextInputType.emailAddress,
onChanged: (value) => _logic.email = value,
autofocus: true,
),
const SizedBox(height: 14),
_field(
label: l10n.authPasswordLabel,
hint: l10n.authPasswordHint,
obscure: true,
onChanged: (value) => _logic.password = value,
),
if (_logic.errorMessage != null) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: Color(0xFFFF6B6B), size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
_logic.errorMessage!,
style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 13),
),
),
],
),
],
const SizedBox(height: 22),
SizedBox(
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
onPressed: _logic.busy ? null : _submit,
child: _logic.busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
_logic.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(height: 14),
TextButton(
onPressed: _logic.busy
? null
: () => setState(() => _logic.toggleMode()),
child: Text(
_logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp,
style: const TextStyle(color: AppColors.blueSoft, fontSize: 13),
),
),
],
),
),
);
}
Widget _field({
required String label,
required String hint,
required ValueChanged<String> onChanged,
bool obscure = false,
bool autofocus = false,
TextInputType? keyboardType,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
TextField(
autofocus: autofocus,
obscureText: obscure,
keyboardType: keyboardType,
onChanged: onChanged,
style: const TextStyle(color: Colors.white, fontSize: 15),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFF6C7280), fontSize: 14),
filled: true,
fillColor: AppColors.card,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
],
);
}
}
+16 -8
View File
@@ -18,6 +18,7 @@ class DiscoverLogic extends ChangeNotifier {
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
int category = 0;
bool searchOpen = false;
bool _disposed = false;
void notify() {
@@ -25,11 +26,7 @@ class DiscoverLogic extends ChangeNotifier {
}
void refreshPosition() {
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
position = history.resumePositionFor(demoProfileId, drama);
}
void selectCategory(int index) {
@@ -39,6 +36,14 @@ class DiscoverLogic extends ChangeNotifier {
void onSearchChanged() => notify();
bool get showSearch => searchOpen || search.text.isNotEmpty;
void openSearch() {
searchOpen = true;
notify();
searchFocus.requestFocus();
}
void syncDrama({
required bool active,
required bool wasActive,
@@ -54,6 +59,8 @@ class DiscoverLogic extends ChangeNotifier {
notify();
}
if (active && searchRequest != previousSearchRequest) {
searchOpen = true;
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus();
});
@@ -113,8 +120,9 @@ class DiscoverLogic extends ChangeNotifier {
void showFilters(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.featured,
l10n.suspense,
l10n.forYou,
l10n.hot,
l10n.newest,
l10n.romance,
l10n.actionGenre,
];
@@ -131,7 +139,7 @@ class DiscoverLogic extends ChangeNotifier {
ListTile(
title: Text(labels[index]),
trailing: index == category
? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D))
? const Icon(Icons.check_rounded, color: Color(0xFF2F7BFF))
: null,
onTap: () {
selectCategory(index);
+373 -453
View File
@@ -1,12 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
import 'widgets/cover_carousel.dart';
class DiscoverPage extends StatefulWidget {
const DiscoverPage({
@@ -18,6 +23,10 @@ class DiscoverPage extends StatefulWidget {
this.embedded = false,
this.active = true,
this.searchRequest = 0,
this.signedIn = false,
this.onSignIn,
this.onCoins,
this.onRewards,
this.onAction,
this.dramas,
this.onDramaSelected,
@@ -30,6 +39,10 @@ class DiscoverPage extends StatefulWidget {
final bool embedded;
final bool active;
final int searchRequest;
final bool signedIn;
final VoidCallback? onSignIn;
final VoidCallback? onCoins;
final VoidCallback? onRewards;
final ValueChanged<DiscoverPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
@@ -48,6 +61,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
drama: widget.drama,
history: widget.history,
);
if (widget.focusSearch) logic.searchOpen = true;
}
@override
@@ -80,50 +94,64 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cards = _cardsFor(l10n, logic.category, logic.search.text);
final catalog = context.watch<CatalogController>();
final allDramas = widget.dramas ?? [logic.drama];
final cards = _cardsFor(
allDramas,
catalog.sections,
logic.category,
'',
);
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_topBar(l10n),
_header(l10n),
Expanded(
child: ListView(
controller: logic.scroll,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
padding: const EdgeInsets.only(bottom: 24),
children: [
_searchBar(l10n),
const SizedBox(height: 12),
_categories(l10n),
const SizedBox(height: 15),
_continueCard(l10n),
const SizedBox(height: 15),
_quickActions(l10n),
const SizedBox(height: 20),
_sectionTitle(l10n),
const SizedBox(height: 13),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: cards.length,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 14,
childAspectRatio: 0.59,
),
itemBuilder: (context, index) =>
_posterCard(l10n, cards[index]),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _carousel(l10n, catalog.banners, allDramas),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_categories(l10n),
const SizedBox(height: 18),
_continueHeader(l10n),
const SizedBox(height: 12),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
else
SizedBox(
height: 196,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final card in cards) ...[
_continueTile(l10n, card),
const SizedBox(width: 12),
],
],
),
),
],
),
),
],
),
),
@@ -142,412 +170,233 @@ class _DiscoverPageState extends State<DiscoverPage> {
);
}
Widget _topBar(AppLocalizations l10n) => SizedBox(
height: 51,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
Widget _header(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
child: Row(
children: [
Container(
width: 29,
height: 29,
decoration: BoxDecoration(
color: _orange,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
),
const SizedBox(width: 7),
Text(
l10n.discoverTab,
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: () => logic.searchFocus.requestFocus(),
icon: const Icon(Icons.search_rounded, size: 22),
),
const SizedBox(width: 4),
InkWell(
onTap: () => logic.act(
context,
DiscoverPageAction.my,
embedded: widget.embedded,
onAction: widget.onAction,
),
child: const CircleAvatar(
radius: 14,
backgroundColor: Color(0xFF34353B),
child: Icon(Icons.person_rounded, size: 19),
),
),
],
),
),
);
Widget _searchBar(AppLocalizations l10n) => Container(
height: 43,
decoration: BoxDecoration(
color: const Color(0xFF25252A),
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
const SizedBox(width: 11),
const Icon(Icons.search_rounded, color: Color(0xFFF2EEEF), size: 20),
const SizedBox(width: 7),
Expanded(
child: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
autofocus: widget.focusSearch,
onChanged: (_) => logic.onSearchChanged(),
style: const TextStyle(fontSize: 12),
decoration: InputDecoration(
hintText: l10n.searchDiscover,
hintStyle: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
border: InputBorder.none,
isDense: true,
),
),
),
IconButton(
tooltip: l10n.filter,
onPressed: () => logic.showFilters(context),
icon: const Icon(Icons.tune_rounded, size: 20),
),
],
),
);
Widget _categories(AppLocalizations l10n) {
final labels = [
l10n.featured,
l10n.suspense,
l10n.romance,
l10n.actionGenre,
];
return SizedBox(
height: 30,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: labels.length,
separatorBuilder: (_, _) => const SizedBox(width: 7),
itemBuilder: (context, index) => ChoiceChip(
label: Text(labels[index]),
selected: logic.category == index,
onSelected: (_) => logic.selectCategory(index),
showCheckmark: false,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 7),
backgroundColor: const Color(0xFF29272B),
selectedColor: _orange,
side: BorderSide.none,
labelStyle: TextStyle(
color: logic.category == index ? Colors.white : const Color(0xFFDDD5D7),
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
);
}
Widget _continueCard(AppLocalizations l10n) => FutureBuilder<Duration?>(
future: logic.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final total = widget.duration > Duration.zero
? widget.duration
: const Duration(seconds: 12);
final progress = (position.inMilliseconds / total.inMilliseconds).clamp(
0.0,
1.0,
);
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF1C1D21),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
width: 55,
height: 69,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(widget.drama.posterAsset, fit: BoxFit.cover),
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 12,
child: ColoredBox(color: Color(0xFF1C1D21)),
),
],
),
),
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatchingHeading,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFA490),
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
localizedDramaTitle(l10n, widget.drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
l10n.episodeProgress(1, widget.drama.episodes.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 11,
),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: progress,
minHeight: 3,
backgroundColor: const Color(0xFF474248),
color: _orange,
),
),
],
),
),
const SizedBox(width: 9),
FilledButton.icon(
onPressed: () => logic.act(
context,
DiscoverPageAction.resume,
embedded: widget.embedded,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFBD5C),
foregroundColor: const Color(0xFF2A160B),
padding: const EdgeInsets.symmetric(horizontal: 10),
minimumSize: const Size(0, 32),
textStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 14),
label: Text(l10n.resume),
),
],
),
);
},
);
Widget _quickActions(AppLocalizations l10n) {
final actions = [
(Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)),
(Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)),
(
Icons.auto_awesome_rounded,
l10n.newReleases,
() => logic.catalogInfo(context),
),
(
Icons.bookmark_outline_rounded,
l10n.watchlist,
() {
logic.act(
context,
DiscoverPageAction.watchlist,
embedded: widget.embedded,
onAction: widget.onAction,
);
},
),
];
return Row(
children: [
for (var index = 0; index < actions.length; index++) ...[
const _BrandMark(),
const SizedBox(width: 8),
Expanded(
child: InkWell(
onTap: actions[index].$3,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 77,
padding: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: const Color(0xFF202126),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFF333036),
child: Icon(
actions[index].$1,
size: 19,
color: index == 1
? const Color(0xFFFFC15E)
: const Color(0xFFFFAB96),
),
),
const SizedBox(height: 7),
Text(
actions[index].$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
child: Text(
l10n.appName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
if (index < actions.length - 1) const SizedBox(width: 7),
const SizedBox(width: 8),
if (widget.signedIn) ...[
_HeaderPill(
icon: Icons.monetization_on_rounded,
label: r'$ 0',
onTap: widget.onCoins,
),
const SizedBox(width: 8),
_HeaderPill(
icon: Icons.card_giftcard_rounded,
label: l10n.rewards,
onTap: widget.onRewards,
),
] else
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
],
),
);
}
Widget _carousel(
AppLocalizations l10n,
List<FeedBanner> banners,
List<Drama> allDramas,
) {
final items = banners.where((banner) => banner.imageUrl.isNotEmpty).toList();
if (items.isEmpty) return const SizedBox.shrink();
return CoverCarousel(
items: [
for (final banner in items)
CoverItem(
title: _bannerTitle(banner, allDramas),
subtitle: '',
image: banner.imageUrl,
onOpen: () => _openBanner(banner, allDramas),
),
],
);
}
Widget _sectionTitle(AppLocalizations l10n) => Row(
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
final dramaId = banner.dramaId;
if (dramaId == null) return '';
for (final drama in allDramas) {
if (drama.id == dramaId) return drama.title;
}
return '';
}
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
final dramaId = banner.dramaId;
if (dramaId == null) return;
for (final drama in allDramas) {
if (drama.id == dramaId) {
logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
);
return;
}
}
}
Widget _continueHeader(AppLocalizations l10n) => Row(
children: [
Container(width: 3, height: 20, color: _orange),
const SizedBox(width: 7),
Expanded(
child: Text(
l10n.trendingMicroDramas,
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.2,
),
),
),
TextButton(
onPressed: () => logic.selectCategory(0),
child: Text(
l10n.seeAll,
style: const TextStyle(color: Color(0xFFFFB3A1)),
const SizedBox(width: 8),
Text(
l10n.seeAll,
style: const TextStyle(
color: AppColors.blue,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
);
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
final playable =
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null;
return InkWell(
onTap: () => logic.openCard(
context,
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]),
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
borderRadius: BorderRadius.circular(11),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(card.image, fit: BoxFit.cover),
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 37,
child: ColoredBox(color: Color(0xFF101116)),
Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) {
final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]);
return SizedBox(
width: 132,
child: InkWell(
onTap: () => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(card.image),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0xE6000000)],
stops: [0.45, 1],
),
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: playable ? _orange : const Color(0xDD37343B),
borderRadius: BorderRadius.circular(7),
),
child: Text(
playable ? l10n.free : l10n.artworkOnly,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
),
),
),
if (drama == null)
const ColoredBox(
color: Color(0x99000000),
child: Center(
child: Icon(Icons.image_outlined, color: Colors.white),
),
Positioned(
bottom: 10,
left: 8,
right: 8,
child: Text(
playable ? l10n.freeTestMedia : l10n.videoPending,
),
Positioned(
left: 10,
right: 10,
bottom: 10,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFC789),
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
],
const SizedBox(height: 3),
Text(
drama == null ? l10n.videoPending : card.genre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xE6FFFFFF),
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
],
),
),
],
),
),
),
);
}
Widget _categories(AppLocalizations l10n) {
final labels = [
l10n.forYou,
l10n.hot,
l10n.newest,
l10n.romance,
l10n.actionGenre,
];
return SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: labels.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final selected = logic.category == index;
return GestureDetector(
onTap: () => logic.selectCategory(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: selected ? AppColors.blue : const Color(0xFF1C1E24),
borderRadius: BorderRadius.circular(16),
),
child: Text(
labels[index],
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : AppColors.muted,
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
height: 1.1,
),
),
),
),
const SizedBox(height: 6),
Text(
card.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(
card.genre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11),
),
],
);
},
),
);
}
@@ -637,60 +486,131 @@ class _ShowcaseCard {
required this.title,
required this.genre,
required this.image,
required this.category,
this.dramaId,
});
final String title;
final String genre;
final String image;
final int category;
final String? dramaId;
}
/// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。
List<_ShowcaseCard> _cardsFor(
AppLocalizations l10n,
List<Drama> dramas,
List<FeedSection> sections,
int category,
String query,
) {
final cards = [
_ShowcaseCard(
title: l10n.dramaSignalTitle,
genre: l10n.genreSuspenseMystery,
image: 'assets/images/stitch_signal_poster_clean.png',
category: 1,
dramaId: 'the-signal-demo',
),
_ShowcaseCard(
title: l10n.dramaCityTitle,
genre: l10n.genreUrbanRomance,
image: 'assets/images/stitch_city_poster_clean.png',
category: 2,
dramaId: 'city-lights-demo',
),
_ShowcaseCard(
title: l10n.dramaVengeanceTitle,
genre: l10n.genreActionThriller,
image: 'assets/images/stitch_vengeance_poster_clean.png',
category: 3,
dramaId: 'vengeance-demo',
),
_ShowcaseCard(
title: l10n.dramaEchoesTitle,
genre: l10n.genreDramaMystery,
image: 'assets/images/stitch_rain_scene.jpg',
category: 1,
),
];
final source = switch (category) {
0 => dramas,
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
};
final normalized = query.trim().toLowerCase();
return cards.where((card) {
final categoryMatches = category == 0 || card.category == category;
final queryMatches =
normalized.isEmpty ||
card.title.toLowerCase().contains(normalized) ||
card.genre.toLowerCase().contains(normalized);
return categoryMatches && queryMatches;
}).toList();
return [
for (final drama in source)
if (normalized.isEmpty ||
drama.title.toLowerCase().contains(normalized) ||
drama.genre.toLowerCase().contains(normalized) ||
drama.category.toLowerCase().contains(normalized))
_ShowcaseCard(
title: drama.title,
genre: drama.genre.isEmpty ? drama.category : drama.genre,
image: drama.posterUrl,
dramaId: drama.id,
),
];
}
const _orange = Color(0xFFFF5B3D);
const _orange = AppColors.blue;
const _coin = Color(0xFFF5C451);
class _BrandMark extends StatelessWidget {
const _BrandMark();
@override
Widget build(BuildContext context) {
return Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 20,
),
);
}
}
class _HeaderPill extends StatelessWidget {
const _HeaderPill({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF1C1E24),
shape: const StadiumBorder(side: BorderSide(color: Color(0x66F5C451))),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: _coin),
const SizedBox(width: 4),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
],
),
),
),
);
}
}
class _LoginButton extends StatelessWidget {
const _LoginButton({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: AppColors.blue,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
child: Text(label, maxLines: 1),
);
}
}
@@ -0,0 +1,206 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/widgets/remote_image.dart';
class CoverItem {
const CoverItem({
required this.title,
required this.subtitle,
required this.image,
required this.onOpen,
});
final String title;
final String subtitle;
final String image;
final VoidCallback onOpen;
}
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
class CoverCarousel extends StatefulWidget {
const CoverCarousel({super.key, required this.items});
final List<CoverItem> items;
@override
State<CoverCarousel> createState() => _CoverCarouselState();
}
class _CoverCarouselState extends State<CoverCarousel>
with SingleTickerProviderStateMixin {
late final AnimationController _snap;
double _page = 0;
double _from = 0;
double _to = 0;
int? _pointer;
double _drag = 0;
double _dragPage = 0;
@override
void initState() {
super.initState();
_snap = AnimationController(vsync: this)..addListener(_onSnap);
}
@override
void dispose() {
_snap.dispose();
super.dispose();
}
double get _last => math.max(0, widget.items.length - 1).toDouble();
void _onSnap() {
final t = Curves.easeOutCubic.transform(_snap.value);
setState(() => _page = _from + (_to - _from) * t);
}
void _turnTo(double target) {
_from = _page;
_to = target.clamp(0, _last);
_snap.duration = const Duration(milliseconds: 420);
_snap.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = math.min(width * 0.52, 210).toDouble();
return SizedBox(
width: double.infinity,
height: height,
child: _stage(width),
);
},
);
}
Widget _stage(double width) {
return Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) {
_snap.stop();
_pointer = event.pointer;
_drag = 0;
_dragPage = _page;
},
onPointerMove: (event) {
if (event.pointer != _pointer) return;
_drag += event.delta.dx;
setState(() {
_page = (_dragPage - _drag / width).clamp(0.0, _last);
});
},
onPointerUp: (event) {
if (event.pointer != _pointer) return;
_pointer = null;
_turnTo(_page.roundToDouble());
},
onPointerCancel: (event) {
if (event.pointer != _pointer) return;
_pointer = null;
_turnTo(_page.roundToDouble());
},
child: ClipRect(
child: Stack(
fit: StackFit.expand,
children: [for (final index in _visible) _face(index, width)],
),
),
);
}
List<int> get _visible {
final first = _page.floor();
final indexes = <int>[first, first + 1];
indexes.retainWhere((index) => index >= 0 && index < widget.items.length);
indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs()));
return indexes;
}
Widget _face(int index, double width) {
final delta = index - _page;
if (delta <= -1 || delta >= 1) return const SizedBox.shrink();
final item = widget.items[index];
final angle = delta * math.pi / 2;
// Push the face out to the cube surface, spin the whole cube on its
// vertical center, then bring that center back to the banner.
final face = Matrix4.identity()
..translateByDouble(0, 0, -width / 2, 1)
..rotateY(angle)
..translateByDouble(0, 0, width / 2, 1);
final transform = Matrix4.identity()
..setEntry(3, 2, 0.0018)
..multiply(face);
return Positioned.fill(
child: Transform(
alignment: Alignment.center,
filterQuality: FilterQuality.medium,
transform: transform,
child: GestureDetector(
onTap: () {
if ((_page - index).abs() < 0.08) item.onOpen();
},
child: _poster(item),
),
),
);
}
Widget _poster(CoverItem item) {
return Stack(
fit: StackFit.expand,
children: [
RemoteImage(item.image),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)],
stops: [0, 0.5, 1],
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 22,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.15,
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
),
),
const SizedBox(height: 6),
Text(
item.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xF2FFFFFF),
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.2,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
],
),
),
],
);
}
}
+32 -1
View File
@@ -1,14 +1,21 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../player/player_view.dart';
class DramaDetailLogic extends ChangeNotifier {
DramaDetailLogic({required this.drama, required this.history});
final Drama drama;
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
Drama drama;
final WatchHistoryRepository history;
bool loading = false;
bool failed = false;
/// 接口给出的失败原因;为空时界面回退到本地文案。
String? failureMessage;
int? continueIndex;
bool _disposed = false;
@@ -16,6 +23,30 @@ class DramaDetailLogic extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
/// 列表卡片只带一集可播预览,进详情时补一次完整剧集列表。
Future<void> load(Future<Drama> Function() fetch) async {
if (drama.detailLoaded) {
await loadContinue();
return;
}
loading = true;
failed = false;
failureMessage = null;
notify();
try {
drama = await fetch();
await loadContinue();
} on ApiException catch (exception) {
failed = true;
failureMessage = exception.message;
} catch (_) {
failed = true;
} finally {
loading = false;
notify();
}
}
Future<void> loadContinue() async {
try {
final id = await history.lastEpisodeId(demoProfileId, drama.id);
+45 -5
View File
@@ -1,7 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
@@ -21,7 +26,15 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
void initState() {
super.initState();
logic = DramaDetailBinding().create(drama: widget.drama);
logic.loadContinue();
_load();
}
void _load() {
unawaited(
logic.load(
() => context.read<CatalogController>().requireDetail(widget.drama),
),
);
}
@override
@@ -40,6 +53,34 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (logic.loading) {
return const Scaffold(
backgroundColor: Color(0xFF111111),
body: Center(child: CircularProgressIndicator()),
);
}
if (logic.failed) {
return Scaffold(
backgroundColor: const Color(0xFF111111),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
logic.failureMessage ?? l10n.detailError,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: Text(l10n.retry)),
],
),
),
),
);
}
final drama = logic.drama;
return Scaffold(
body: CustomScrollView(
@@ -50,7 +91,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
flexibleSpace: FlexibleSpaceBar(
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
background: RemoteImage(drama.posterUrl),
),
),
SliverPadding(
@@ -61,7 +102,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
l10n.technicalDemoCount(drama.episodes.length),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
letterSpacing: 1.4,
color: const Color(0xFF9278EA),
color: const Color(0xFF2F7BFF),
fontWeight: FontWeight.bold,
),
),
@@ -112,12 +153,11 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
minTileHeight: 76,
leading: CircleAvatar(
backgroundColor: const Color(
0xFFFF6B45,
0xFF2F7BFF,
).withValues(alpha: 0.18),
child: Text('${episode.number}'),
),
title: Text(l10n.episodeLabel(episode.number)),
subtitle: Text(l10n.freeTestMedia),
trailing: const Icon(Icons.play_circle_outline_rounded),
onTap: () => logic.openPlayer(context, index),
),
-30
View File
@@ -1,30 +0,0 @@
import '../../core/data/models/drama.dart';
enum HomeFeedCategory { following, animated, liveAction, recommended }
/// Maps catalog entries into home feeds without owning playback or UI state.
class HomeFeedSections {
HomeFeedSections(this.dramas, {this.followedDramaIds = const {}});
final List<Drama> dramas;
final Set<String> followedDramaIds;
List<int> indicesFor(HomeFeedCategory category) => [
for (var index = 0; index < dramas.length; index++)
if (dramas[index].episodes.isNotEmpty &&
switch (category) {
HomeFeedCategory.following => followedDramaIds.contains(
dramas[index].id,
),
HomeFeedCategory.animated =>
dramas[index].format == DramaFormat.animated,
HomeFeedCategory.liveAction =>
dramas[index].format == DramaFormat.liveAction,
HomeFeedCategory.recommended => true,
})
index,
];
Drama dramaAt(HomeFeedCategory category, int categoryIndex) =>
dramas[indicesFor(category)[categoryIndex]];
}
+38 -9
View File
@@ -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<Drama> dramas) : currentDrama = dramas.first;
final scaffoldKey = GlobalKey<ScaffoldState>();
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<void>(
MaterialPageRoute(
builder: (_) => DramaDetailPage(drama: currentDrama),
),
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)),
);
} finally {
if (context.mounted) {
@@ -80,6 +82,34 @@ class HomeLogic extends ChangeNotifier {
);
}
Future<void> playDrama(BuildContext context, Drama drama) async {
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
var playable = drama;
try {
playable = await context.read<CatalogController>().requireDetail(drama);
} catch (_) {
// 取详情失败就用卡片自带的那一集起播。
}
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(drama: playable, initialIndex: 0),
),
);
}
void openProfile() {
profile = true;
scaffoldKey.currentState?.closeDrawer();
select(2, mySection: 0);
}
void openLibrary({int section = 1}) {
profile = false;
scaffoldKey.currentState?.closeDrawer();
select(2, mySection: section);
}
void discoverAction(BuildContext context, DiscoverPageAction action) {
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();
}
}
+74 -29
View File
@@ -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<AuthController>().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<AuthController>().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,
),
),
],
@@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
/// Converts horizontal drags into a previous/next category request.
/// Placement decides where the gesture is active; the seek bar sits outside it.
class CategorySwipeRegion extends StatefulWidget {
const CategorySwipeRegion({
super.key,
required this.onShift,
required this.child,
});
final ValueChanged<int> onShift;
final Widget child;
@override
State<CategorySwipeRegion> createState() => _CategorySwipeRegionState();
}
class _CategorySwipeRegionState extends State<CategorySwipeRegion> {
double _distance = 0;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) => _distance = 0,
onHorizontalDragUpdate: (details) => _distance += details.delta.dx,
onHorizontalDragEnd: (details) {
final velocity = details.primaryVelocity ?? 0;
if (_distance.abs() < 44 && velocity.abs() < 300) return;
final direction = _distance.abs() >= 44 ? _distance : velocity;
widget.onShift(direction < 0 ? 1 : -1);
},
child: widget.child,
);
}
@@ -1,170 +0,0 @@
import 'package:flutter/material.dart';
import '../../../l10n/app_localizations.dart';
import '../home_feed_sections.dart';
import 'category_swipe_region.dart';
/// Reused by playable and empty home feeds.
class HomeFeedHeader extends StatelessWidget {
const HomeFeedHeader({
super.key,
required this.l10n,
required this.category,
required this.onCategorySelected,
required this.onCategoryShift,
required this.onSearch,
required this.onMy,
this.onMenu,
this.onMute,
this.muted = false,
});
final AppLocalizations l10n;
final HomeFeedCategory category;
final ValueChanged<HomeFeedCategory> onCategorySelected;
final ValueChanged<int> onCategoryShift;
final VoidCallback onSearch;
final VoidCallback onMy;
final VoidCallback? onMenu;
final VoidCallback? onMute;
final bool muted;
String _label(HomeFeedCategory category) => switch (category) {
HomeFeedCategory.following => l10n.followingTab,
HomeFeedCategory.animated => l10n.animatedTab,
HomeFeedCategory.liveAction => l10n.liveActionTab,
HomeFeedCategory.recommended => l10n.recommendedTab,
};
@override
Widget build(BuildContext context) => IconTheme(
data: const IconThemeData(color: Colors.white),
child: DefaultTextStyle.merge(
style: const TextStyle(color: Colors.white),
child: _header(context),
),
);
Widget _header(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
if (onMenu != null)
IconButton(
key: const Key('home-open-menu'),
tooltip: l10n.openQuickMenu,
onPressed: onMenu,
icon: const Icon(Icons.menu_rounded),
),
Container(
width: 29,
height: 29,
decoration: BoxDecoration(
color: const Color(0xFFFF5C37),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
),
const SizedBox(width: 8),
Text(
l10n.homeTab,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: onSearch,
icon: const Icon(Icons.search_rounded),
),
IconButton(
tooltip: l10n.myTab,
onPressed: onMy,
icon: const CircleAvatar(
radius: 15,
backgroundColor: Color(0xFF3C3A3A),
child: Icon(
Icons.person_rounded,
color: Colors.white,
size: 19,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
child: Row(
children: [
Expanded(
child: CategorySwipeRegion(
key: const Key('home-category-swipe'),
onShift: onCategoryShift,
child: Row(
children: [
for (final item in HomeFeedCategory.values)
Expanded(
child: InkWell(
key: Key('home-category-${item.name}'),
onTap: () => onCategorySelected(item),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 9),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_label(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: category == item
? const Color(0xFFFFC2B0)
: const Color(0xFFF7F4F2),
shadows: const [
Shadow(blurRadius: 8, color: Colors.black),
],
fontSize: 12,
fontWeight: category == item
? FontWeight.w700
: FontWeight.w500,
),
),
const SizedBox(height: 4),
SizedBox(
width: 18,
height: 2,
child: DecoratedBox(
decoration: BoxDecoration(
color: category == item
? const Color(0xFFFF5C37)
: Colors.transparent,
),
),
),
],
),
),
),
),
],
),
),
),
if (onMute != null)
IconButton(
tooltip: muted ? l10n.unmute : l10n.mute,
onPressed: onMute,
icon: Icon(
muted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
size: 21,
),
),
],
),
),
],
);
}
+5 -14
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../../../core/config/locale_controller.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
@@ -27,7 +28,7 @@ class HomeQuickDrawer extends StatelessWidget {
static const _panel = Color(0xFF222225);
static const _card = Color(0xFF303033);
static const _orange = Color(0xFFFF5C37);
static const _orange = Color(0xFF2F7BFF);
@override
Widget build(BuildContext context) {
@@ -88,11 +89,10 @@ class HomeQuickDrawer extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
drama.posterAsset,
child: RemoteImage(
drama.posterUrl,
width: 74,
height: 100,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
@@ -111,18 +111,9 @@ class HomeQuickDrawer extends StatelessWidget {
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodes.length),
l10n.episodeCount(drama.episodeCount),
style: const TextStyle(color: Color(0xFFE7E2E0)),
),
const SizedBox(height: 5),
Text(
l10n.freeTestMedia,
maxLines: 2,
style: const TextStyle(
color: Color(0xFFFFB99E),
fontSize: 11,
),
),
],
),
),
+1 -5
View File
@@ -29,11 +29,7 @@ class MineLogic extends ChangeNotifier {
}
void refreshPosition() {
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
position = history.resumePositionFor(demoProfileId, drama);
}
void selectSection(int index) {
+274 -216
View File
@@ -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<MyPageAction>? onAction;
final bool signedIn;
final VoidCallback? onSignIn;
final VoidCallback? onSignOut;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
@@ -86,7 +99,7 @@ class _MyPageState extends State<MyPage> {
bottom: !widget.embedded,
child: Column(
children: [
_topBar(l10n),
if (!widget.profile) _topBar(l10n),
Expanded(
child: FutureBuilder<Duration?>(
future: logic.position,
@@ -101,15 +114,13 @@ class _MyPageState extends State<MyPage> {
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<MyPage> {
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<MyPage> {
),
);
Widget _profile(AppLocalizations l10n) => Row(
children: [
const CircleAvatar(
radius: 30,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 34),
List<Widget> _accountPage(AppLocalizations l10n) => [
if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n),
if (widget.signedIn) ...[const SizedBox(height: 18), _wallet(l10n)],
const SizedBox(height: 14),
_menuCard(l10n),
const SizedBox(height: 18),
Text(
l10n.preferencesHeading,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 1.1,
),
const SizedBox(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<AuthController>().user;
final name = (user?.nickname.isNotEmpty ?? false) ? user!.nickname : l10n.mockUserName;
final uidText = (user?.uid.isNotEmpty ?? false) ? 'ID: ${user!.uid}' : l10n.mockUserId;
return Row(
children: [
const CircleAvatar(
radius: 28,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 30),
),
const SizedBox(width: 12),
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<MyPage> {
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<MyPage> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFA18D),
color: AppColors.blueSoft,
fontSize: 10,
),
),
@@ -507,12 +618,7 @@ class _MyPageState extends State<MyPage> {
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<MyPage> {
),
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<MyPage> {
),
);
Widget _preferences(AppLocalizations l10n) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.preferencesHeading,
style: const TextStyle(
color: Color(0xFFCEBBB9),
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
const SizedBox(height: 12),
Material(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: const Icon(
Icons.translate_rounded,
color: Color(0xFFFFBC65),
),
title: Text(l10n.appLanguage),
subtitle: Text(l10n.language),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Localizations.localeOf(context).languageCode == 'zh'
? l10n.simplifiedChinese
: l10n.english,
style: const TextStyle(color: Color(0xFFFFB2A1), fontSize: 11),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, size: 18),
],
),
onTap: () => logic.showLanguage(context),
),
),
],
);
Widget _bottomNav(AppLocalizations l10n) => Container(
height: 63,
decoration: const BoxDecoration(
@@ -629,12 +692,7 @@ class _MyPageState extends State<MyPage> {
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;
+164 -95
View File
@@ -2,11 +2,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/player_port.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/platform_share_service.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
@@ -16,7 +18,6 @@ import '../../l10n/app_localizations.dart';
import '../discover/discover_logic.dart';
import '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart';
import '../home/home_feed_sections.dart';
import '../mine/mine_logic.dart';
import '../mine/mine_view.dart';
import 'player_view.dart';
@@ -30,10 +31,7 @@ class PlayerLogic extends ChangeNotifier {
late PageController pages;
late PlaybackQueue queue;
late HomeFeedSections homeSections;
late List<int> visibleIndices;
HomeFeedCategory category = HomeFeedCategory.recommended;
int categoryRequest = 0;
late PlaybackSession session;
VideoPlayerPort? trackedPlayer;
Episode? trackedEpisode;
@@ -47,6 +45,8 @@ class PlayerLogic extends ChangeNotifier {
bool fullscreen = false;
bool pip = false;
bool pipPreparing = false;
bool screenCleared = false;
final Map<String, int> pickedEpisode = {};
bool Function() isMounted = () => false;
bool homeMode = false;
@@ -67,6 +67,24 @@ class PlayerLogic extends ChangeNotifier {
bool get immersive => fullscreen || pip || pipPreparing;
bool get chromeHidden => immersive || screenCleared;
Episode displayEpisode(Drama drama, Episode fallback) {
final number = pickedEpisode[drama.id];
if (number == null) return fallback;
for (final episode in drama.episodes) {
if (episode.number == number) return episode;
}
return fallback;
}
void setScreenCleared(bool value) {
if (screenCleared == value) return;
screenCleared = value;
notify();
notifyImmersive();
}
void notify() {
if (!_disposed) notifyListeners();
}
@@ -97,17 +115,13 @@ class PlayerLogic extends ChangeNotifier {
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
: PlaybackQueue.home(feedDramas!);
homeSections = HomeFeedSections([
for (final entry in queue.entries) entry.drama,
]);
visibleIndices = homeMode
? homeSections.indicesFor(category)
: List<int>.generate(queue.length, (index) => index);
visibleIndices = List<int>.generate(queue.length, (index) => index);
pages = PageController(initialPage: initialIndex);
session = PlaybackSession(
episodes: [for (final entry in queue.entries) entry.episode],
factory: factory,
canPlay: (episode) => episode.isFree,
// 后端只在可播放时才下发地址(免费集或已解锁),拿不到地址即视为锁定
canPlay: (episode) => episode.videoUrl.isNotEmpty,
prepare: preparePlayer,
activate: activatePlayer,
coordinator: coordinator,
@@ -135,8 +149,10 @@ class PlayerLogic extends ChangeNotifier {
if (isMounted() && backRequest == request) {
if (fullscreen) {
unawaited(setFullscreen(false));
} else if (screenCleared) {
setScreenCleared(false);
} else {
shiftCategory(-1);
onMenu?.call();
}
}
});
@@ -170,7 +186,7 @@ class PlayerLogic extends ChangeNotifier {
super.dispose();
}
void notifyImmersive() => onImmersiveChanged?.call(immersive);
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
Future<void> setFullscreen(bool value) async {
if (fullscreen == value || !isMounted()) return;
@@ -192,7 +208,7 @@ class PlayerLogic extends ChangeNotifier {
} catch (_) {
if (value && isMounted()) {
fullscreen = false;
notify();
notify();
notifyImmersive();
}
}
@@ -226,7 +242,7 @@ class PlayerLogic extends ChangeNotifier {
session.setAppResumed(true);
} else {
pipPreparing = false;
notify();
notify();
notifyImmersive();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
@@ -327,7 +343,7 @@ class PlayerLogic extends ChangeNotifier {
curve: Curves.easeOutCubic,
);
} catch (_) {
// A category change can detach the old page controller mid-animation.
// The page can leave the tree before the animation finishes.
}
}
@@ -371,49 +387,6 @@ class PlayerLogic extends ChangeNotifier {
: null;
}
void shiftCategory(int offset) {
if (!homeMode) return;
final next = category.index + offset;
if (next < 0) {
onMenu?.call();
return;
}
if (next >= HomeFeedCategory.values.length) return;
unawaited(selectCategory(HomeFeedCategory.values[next]));
}
Future<void> selectCategory(HomeFeedCategory next) async {
if (!homeMode || next == category) return;
final request = ++categoryRequest;
++pageRequest;
session.setRouteVisible(false);
cancelScrub();
await saveCurrentProgress();
if (!isMounted() || request != categoryRequest) return;
final oldPages = pages;
final indices = homeSections.indicesFor(next);
category = next;
visibleIndices = indices;
pages = PageController();
notify();
WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose());
if (indices.isEmpty) return;
onDramaChanged?.call(queue.entryAt(indices.first).drama);
await session.select(
indices.first,
preloadIndex: nextVisibleIndex(indices.first),
);
if (isMounted() && request == categoryRequest) {
if (stoppedAtFeedEnd) {
stoppedAtFeedEnd = false;
session.setUserWantsPlay(true);
}
session.setRouteVisible(active);
}
}
void beginScrub(VideoPlayerPort player) {
if (session.active != player) return;
scrubbingPlayer = player;
@@ -426,7 +399,11 @@ class PlayerLogic extends ChangeNotifier {
session.setOverlayOpen(false);
}
Future<void> seekTo(BuildContext context, VideoPlayerPort player, Duration position) async {
Future<void> seekTo(
BuildContext context,
VideoPlayerPort player,
Duration position,
) async {
if (scrubbingPlayer != player || session.active != player) return;
try {
await session.playbackSettled;
@@ -464,12 +441,31 @@ class PlayerLogic extends ChangeNotifier {
if (isMounted()) session.setRouteVisible(active);
}
Future<void> openSeriesAt(
BuildContext context,
Drama drama,
int index,
) async {
session.setRouteVisible(false);
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(drama: drama, initialIndex: index),
),
);
if (isMounted()) session.setRouteVisible(active);
}
Future<void> showEpisodes(BuildContext context) async {
if (feedDramas != null) {
await openDetail(context);
return;
}
final l10n = AppLocalizations.of(context)!;
final entry = queue.entryAt(session.currentIndex);
final drama = await _withFullEpisodes(context, entry.drama);
if (!context.mounted) return;
final homeFeed = feedDramas != null;
final episodes = drama.episodes;
final currentNumber = entry.episode.number;
session.setOverlayOpen(true);
await session.playbackSettled;
if (!context.mounted) {
@@ -479,49 +475,57 @@ class PlayerLogic extends ChangeNotifier {
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF202024),
backgroundColor: const Color(0xFF16171C),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.6,
height: MediaQuery.sizeOf(sheetContext).height * 0.46,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text(
l10n.episodes,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFB7B3B8),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
Expanded(
child: ListView.builder(
itemCount: queue.length,
itemBuilder: (context, index) {
final episode = queue.entryAt(index).episode;
return ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF3A3030),
child: Text('${episode.number}'),
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.45,
),
title: Text(l10n.episodeLabel(episode.number)),
subtitle: Text(l10n.freeTestMedia),
trailing: const Icon(Icons.play_arrow_rounded),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(
pages.animateToPage(
index,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
},
);
},
itemCount: episodes.length,
itemBuilder: (_, index) => _episodeCell(
context,
sheetContext,
drama,
episodes[index],
index,
homeFeed,
currentNumber,
),
),
),
],
@@ -534,6 +538,68 @@ class PlayerLogic extends ChangeNotifier {
}
}
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
/// 取不到(或页面外没有目录来源)就退回卡片本身。
Future<Drama> _withFullEpisodes(BuildContext context, Drama drama) async {
if (drama.detailLoaded) return drama;
final CatalogController catalog;
try {
catalog = context.read<CatalogController>();
} catch (_) {
return drama;
}
try {
return await catalog.requireDetail(drama);
} catch (_) {
return drama;
}
}
Widget _episodeCell(
BuildContext pageContext,
BuildContext sheetContext,
Drama drama,
Episode episode,
int index,
bool homeFeed,
int currentNumber,
) {
final selected = episode.number == currentNumber;
return Material(
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFF2A2C33),
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.of(sheetContext).pop();
if (homeFeed) {
if (episode.number == currentNumber) return;
unawaited(openSeriesAt(pageContext, drama, index));
return;
}
if (index == session.currentIndex) return;
unawaited(
pages.animateToPage(
index,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
},
child: Center(
child: Text(
'${episode.number}',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
Future<void> showMy(BuildContext context, {int initialSection = 0}) async {
if (embedded) {
onMy?.call();
@@ -574,7 +640,10 @@ class PlayerLogic extends ChangeNotifier {
await showDiscover(context, focusSearch: true);
}
Future<void> showDiscover(BuildContext context, {bool focusSearch = false}) async {
Future<void> showDiscover(
BuildContext context, {
bool focusSearch = false,
}) async {
if (embedded) {
onDiscover?.call();
return;
@@ -601,7 +670,8 @@ class PlayerLogic extends ChangeNotifier {
await openDetail(context);
} else if (action == DiscoverPageAction.my ||
action == DiscoverPageAction.watchlist) {
await showMy(context,
await showMy(
context,
initialSection: action == DiscoverPageAction.watchlist ? 1 : 0,
);
} else {
@@ -661,5 +731,4 @@ class PlayerLogic extends ChangeNotifier {
session.setOverlayOpen(false);
}
}
}
File diff suppressed because it is too large. Load diff
+19 -23
View File
@@ -22,7 +22,6 @@ class SeekProgressBar extends StatefulWidget {
this.onScrubStart,
this.onSeek,
this.showTimes = true,
this.compactIdle = false,
});
final ValueListenable<VideoPlayerValue>? valueListenable;
@@ -30,7 +29,6 @@ class SeekProgressBar extends StatefulWidget {
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showTimes;
final bool compactIdle;
@override
State<SeekProgressBar> createState() => _SeekProgressBarState();
@@ -99,20 +97,32 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.showTimes && scrubbing)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${_format(previewPosition)} / ${_format(video.duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
SizedBox(
height: widget.compactIdle && !scrubbing ? 20 : 36,
height: scrubbing ? 48 : 44,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: widget.compactIdle && !scrubbing ? 2 : 3,
activeTrackColor: const Color(0xFFFF5C37),
trackHeight: scrubbing ? 4 : 3,
activeTrackColor: const Color(0xFF2F7BFF),
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6,
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11,
enabledThumbRadius: scrubbing ? 8 : 6,
),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
),
child: Semantics(
label: widget.semanticsLabel,
@@ -134,20 +144,6 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
),
),
),
if (widget.showTimes && (!widget.compactIdle || scrubbing))
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(previewPosition),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
Text(
_format(video.duration),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
],
),
],
);
}
+40
View File
@@ -89,6 +89,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
dio:
dependency: "direct main"
description:
name: dio
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.11.1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.2"
fake_async:
dependency: transitive
description:
@@ -157,6 +173,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.15.7"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
@@ -237,6 +261,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.0"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
nested:
dependency: transitive
description:
@@ -418,6 +450,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
+8 -11
View File
@@ -1,5 +1,5 @@
name: nontonki
description: "Nontonki short-drama playback prototype."
name: fruitshorts
description: "FruitShorts short-drama playback app (Indonesia first)."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -34,6 +34,8 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# 网络层用 dio(统一拦截器/超时/错误映射);纯 Dart 无原生插件,仍然不引代码生成与图片缓存库
dio: ^5.7.0
provider: ^6.1.5+1
video_player: ^2.14.0
shared_preferences: ^2.5.5
@@ -59,13 +61,8 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
generate: true
assets:
- assets/media/demo_clip.mp4
- assets/images/demo_poster.jpg
- assets/images/stitch_signal_poster_clean.png
- assets/images/stitch_city_poster_clean.png
- assets/images/stitch_vengeance_poster_clean.png
- assets/images/stitch_rain_scene.jpg
# 封面与视频全部由后端下发(/files/**),本地不再打包演示素材:
# 原先 assets/media 下的演示视频有 316MB,会把安装包撑到无法接受的大小。
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
@@ -106,7 +103,7 @@ flutter:
flutter_launcher_icons:
android: true
ios: true
image_path: assets/icon/nontonki.png
image_path: assets/icon/fruitshorts.png
adaptive_icon_background: "#D74A7D"
adaptive_icon_foreground: assets/icon/nontonki.png
adaptive_icon_foreground: assets/icon/fruitshorts.png
remove_alpha_ios: true
+168
View File
@@ -0,0 +1,168 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import '../support/fake_http_adapter.dart';
/// 用假的 dio adapter 覆盖四条关键路径:注册成功、401 → 刷新 → 重试、刷新失败 → 清会话、登出。
///
/// 网络层从 http 换成 dio 后,这里的**断言口径一条没变**,只是注入方式从
/// `http.testing.MockClient` 换成 `FakeHttpAdapter`。
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
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 FakeHttpAdapter adapter,
}) {
final store = AuthSessionStore();
return ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
accessTokenReader: () => auth().accessToken,
tokenRefresher: () => auth().refresh(),
onUnauthorized: () => auth().clear(),
deviceIdReader: store.deviceId,
);
}
test('should_store_session_and_expose_signed_in_state_when_register_succeeds', () async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
expect(options.uri.path, '/app/auth/register');
expect(options.headers['X-Device-Id'], isNotEmpty);
expect(options.headers.containsKey('Authorization'), isFalse);
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
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('should_refresh_once_and_retry_with_new_token_when_response_code_is_401', () async {
var calls = 0;
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
calls++;
if (options.uri.path == '/app/auth/refresh') {
return FakeHttpResponse(tokenBody(accessToken: 'a2', refreshToken: 'r2'));
}
if (options.uri.path != '/app/me') {
// 注册等其它请求:直接发第一版令牌
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
}
final token = options.headers['Authorization'];
if (token == 'Bearer a1') {
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
);
}
expect(token, 'Bearer a2');
return FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {'hit': true},
}),
);
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
calls = 0;
final api = clientWith(() => auth, adapter: adapter);
final data = await api.get('/app/me');
expect((data as Map)['hit'], isTrue);
expect(calls, 3); // 原请求(401) + 刷新 + 重试
expect(auth.accessToken, 'a2');
});
test('should_clear_session_when_refresh_itself_fails', () async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
if (options.uri.path == '/app/auth/refresh') {
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '登录已过期', 'data': null}),
);
}
if (options.uri.path == '/app/auth/register') {
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
}
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
);
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
final api = clientWith(() => auth, adapter: adapter);
await expectLater(api.get('/app/me'), throwsA(isA<ApiException>()));
expect(auth.isSignedIn, isFalse);
expect(await AuthSessionStore().read(), isNull);
});
test('should_clear_local_session_when_sign_out_hits_server_error', () async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
if (options.uri.path == '/app/auth/logout') {
return FakeHttpResponse('{}', 500);
}
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
await auth.signOut();
expect(auth.isSignedIn, isFalse);
expect(await AuthSessionStore().read(), isNull);
});
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import '../support/fake_http_adapter.dart';
/// 登录/注册页的可读证据:校验提示 + 登录/注册切换 + 未登录时不会去发请求。
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
Future<void> pumpAuthPage(WidgetTester tester, {bool signUp = false}) async {
final auth = AuthController(
AuthApi(
// 这个测试不联网:请求一律 500,正好用于验证"本地校验没过就不发请求"
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
),
),
AuthSessionStore(),
);
await tester.pumpWidget(
ChangeNotifierProvider<AuthController>.value(
value: auth,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AuthPage(signUpMode: signUp),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (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('should_reject_weak_password_locally_when_only_letters_are_typed', (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('should_flip_between_sign_in_and_sign_up_when_switch_link_is_tapped', (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);
});
}
+117 -25
View File
@@ -1,39 +1,131 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
import 'package:nontonki/core/data/models/drama.dart';
import 'package:nontonki/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
void main() {
test(
'empty catalog is distinct from a failed load, and retry recovers',
() async {
final repo = ScriptedCatalogRepository();
final controller = CatalogController(repo);
await controller.load();
expect(controller.dramas, isEmpty);
expect(controller.error, isNull);
test('should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds', () async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
repo.fail = true;
await controller.load();
expect(controller.error, isNotNull);
await controller.load();
expect(controller.dramas, isEmpty);
expect(controller.error, isNull);
repo.fail = false;
repo.items = await DemoCatalogRepository().fetchDramas();
await controller.load();
expect(controller.error, isNull);
expect(controller.dramas, hasLength(3));
expect(controller.dramas.first.episodes.length, 3);
},
);
repository.fail = true;
await controller.load();
expect(controller.error, isNotNull);
repository.fail = false;
repository.feed = HomeFeed(
sections: [
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
],
);
await controller.load();
expect(controller.error, isNull);
expect(controller.dramas, hasLength(1));
expect(controller.sections.single.title, '热门推荐');
});
test('should_request_drama_detail_once_when_it_is_loaded_on_demand', () async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
repository.feed = HomeFeed(
sections: [
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
],
);
await controller.load();
final card = controller.dramas.single;
expect(card.episodes, isEmpty);
final detail = await controller.requireDetail(card);
expect(detail.episodes, hasLength(2));
expect(repository.detailCalls, 1);
// 第二次已经是详情,直接复用,不再请求
await controller.requireDetail(detail);
expect(repository.detailCalls, 1);
expect(controller.dramas.single.episodes, hasLength(2));
});
test('should_write_play_url_for_free_episode_and_keep_locked_episode_empty_when_ticket_is_issued', () async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
repository.tickets['10'] = const PlayTicket(
episodeId: '10',
accessible: true,
accessType: PlayTicket.free,
playUrl: '/files/episode/10.mp4',
);
const free = Episode(id: '10', number: 1, title: '免费集', isFree: true);
final resolved = await controller.resolvePlay(free);
expect(resolved.videoUrl, '/files/episode/10.mp4');
const locked = Episode(
id: '11',
number: 2,
title: '锁定集',
isFree: false,
locked: true,
);
final lockedResolved = await controller.resolvePlay(locked);
expect(lockedResolved.videoUrl, isEmpty);
});
}
Drama _card(String id) => Drama(
id: id,
title: 'Drama $id',
synopsis: '简介 $id',
posterUrl: '/files/$id.jpg',
);
Drama _detail(String id) => Drama(
id: id,
title: 'Drama $id',
synopsis: '简介 $id',
posterUrl: '/files/$id.jpg',
detailLoaded: true,
episodes: const [
Episode(id: '1', number: 1, title: '第一集', isFree: true),
Episode(id: '2', number: 2, title: '第二集', isFree: false),
],
);
class ScriptedCatalogRepository implements CatalogRepository {
bool fail = false;
List<Drama> items = const [];
HomeFeed feed = const HomeFeed();
int detailCalls = 0;
final Map<String, PlayTicket> tickets = {};
@override
Future<List<Drama>> fetchDramas() async {
Future<HomeFeed> fetchHomeFeed() async {
if (fail) throw StateError('offline');
return items;
return feed;
}
@override
Future<HomeFeed> fetchDiscoverFeed() async => feed;
@override
Future<Drama> fetchDrama(String dramaId) async {
detailCalls++;
return _detail(dramaId);
}
@override
Future<PlayTicket> resolvePlay(String episodeId) async {
return tickets[episodeId] ??
PlayTicket(
episodeId: episodeId,
accessible: false,
accessType: PlayTicket.noMedia,
);
}
}
-26
View File
@@ -1,26 +0,0 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('all mock entries reuse one bundled local demo video', () async {
final dramas = await DemoCatalogRepository().fetchDramas();
expect(dramas, hasLength(3));
expect(dramas.every((drama) => drama.episodes.length == 3), isTrue);
final paths = {
for (final drama in dramas)
for (final episode in drama.episodes) episode.assetPath,
};
expect(paths, {'assets/media/demo_clip.mp4'});
expect(
dramas.every(
(drama) => drama.episodes.every((episode) => episode.isFree),
),
isTrue,
);
final bytes = await rootBundle.load(paths.single);
expect(bytes.lengthInBytes, greaterThan(1000000));
});
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
void main() {
test('should_carry_one_preview_and_total_count_when_list_card_is_parsed', () {
final drama = Drama.fromCardJson(const {
'id': 5,
'title': '闪婚后',
'synopsis': 'synopsis',
'cover': '/files/cover.jpg',
'category': '都市情感',
'genre': '都市情感',
'totalEpisodes': 77,
'isHot': true,
'viewCount': 3100000,
'preview': {
'id': 9,
'number': 1,
'title': '第 1 集',
'isFree': true,
'playUrl': '/files/episode/9.mp4',
},
});
expect(drama.detailLoaded, isFalse);
expect(drama.episodes, hasLength(1));
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
expect(drama.episodeCount, 77);
expect(drama.isHot, isTrue);
});
test('should_have_no_episodes_when_list_card_has_no_playable_preview', () {
final drama = Drama.fromCardJson(const {
'id': 6,
'title': '替嫁新娘',
'totalEpisodes': 12,
});
expect(drama.episodes, isEmpty);
expect(drama.episodeCount, 12);
});
test('should_count_shipped_episodes_when_detail_response_is_parsed', () {
final drama = Drama.fromDetailJson(const {
'id': 5,
'title': '闪婚后',
'synopsis': 'synopsis',
'cover': '/files/cover.jpg',
'totalEpisodes': 77,
'episodes': [
{'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true},
{'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false},
],
});
expect(drama.detailLoaded, isTrue);
expect(drama.episodeCount, 77);
expect(drama.episodes.first.id, '9');
});
}
-22
View File
@@ -1,22 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/pages/home/home_feed_sections.dart';
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
void main() {
test(
'home categories filter demo dramas without changing their media',
() async {
final dramas = await DemoCatalogRepository().fetchDramas();
final sections = HomeFeedSections(dramas);
expect(sections.indicesFor(HomeFeedCategory.recommended), [0, 1, 2]);
expect(sections.indicesFor(HomeFeedCategory.animated), [0]);
expect(sections.indicesFor(HomeFeedCategory.liveAction), [1, 2]);
expect(sections.indicesFor(HomeFeedCategory.following), isEmpty);
expect(
sections.dramaAt(HomeFeedCategory.liveAction, 0).title,
'City Lights & Velvet Nights',
);
},
);
}
+48
View File
@@ -0,0 +1,48 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
/// id-ID 文案接入的可读证据:设备语言是 `id` 时真的拿到印尼语、`id` 在支持列表里、
/// 并且品牌文案里不再有 demo 标记。
void main() {
test('should_return_indonesian_copy_when_locale_is_id', () async {
final l10n = await AppLocalizations.delegate.load(const Locale('id'));
expect(l10n.homeTab, 'Beranda');
expect(l10n.watchNow, 'Tonton gratis');
expect(l10n.watchlist, 'Daftar Tonton');
expect(l10n.coins, 'Koin');
expect(l10n.signIn, 'Masuk');
});
test('should_include_id_when_supported_locales_are_read', () {
final codes = AppLocalizations.supportedLocales
.map((locale) => locale.languageCode)
.toList();
expect(codes, containsAll(<String>['en', 'zh', 'id']));
});
test('should_drop_demo_marker_when_brand_copy_is_loaded', () async {
for (final locale in const [Locale('en'), Locale('zh'), Locale('id')]) {
final l10n = await AppLocalizations.delegate.load(locale);
expect(l10n.appName, 'FruitShorts', reason: '$locale appName');
expect(l10n.brandMark, 'FRUITSHORTS', reason: '$locale brandMark');
expect(l10n.shareFooter, 'FruitShorts', reason: '$locale shareFooter');
expect(l10n.brandMark.toLowerCase(), isNot(contains('demo')));
expect(l10n.shareFooter.toLowerCase(), isNot(contains('demo')));
}
});
test('should_keep_placeholders_when_indonesian_copy_renders', () async {
final l10n = await AppLocalizations.delegate.load(const Locale('id'));
expect(l10n.episodeLabel(3), contains('3'));
expect(l10n.episodeProgress(2, 10), contains('2'));
expect(l10n.episodeProgress(2, 10), contains('10'));
});
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:shared_preferences/shared_preferences.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';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
test('should_keep_device_locale_when_no_choice_is_stored', () async {
final controller = LocaleController();
var notifications = 0;
controller.addListener(() => notifications++);
await controller.load();
expect(controller.locale, isNull);
expect(notifications, 1);
});
test('should_restore_chosen_language_when_app_restarts', () async {
final first = LocaleController();
await first.select('zh');
expect(first.locale, const Locale('zh'));
final reopened = LocaleController();
await reopened.load();
expect(reopened.locale, const Locale('zh'));
});
test('should_clear_stored_chinese_preference_when_english_is_selected', () async {
await LocaleController().select('zh');
final reopened = LocaleController();
await reopened.load();
await reopened.select('en');
final third = LocaleController();
await third.load();
expect(third.locale, const Locale('en'));
});
test('should_clear_locale_when_code_is_unsupported', () async {
final controller = LocaleController();
await controller.select('zh');
expect(controller.locale, const Locale('zh'));
await controller.select('tlh');
expect(controller.locale, isNull);
});
test('should_resolve_to_device_locale_when_stored_code_is_unsupported', () async {
await SharedPreferencesAsync().setString('ui.locale', 'fr');
final controller = LocaleController();
await controller.load();
expect(controller.locale, isNull);
});
test('should_survive_load_and_select_when_preference_store_is_broken', () async {
SharedPreferencesAsyncPlatform.instance = _BrokenPreferences();
final controller = LocaleController();
await controller.load();
expect(controller.locale, isNull);
await controller.select('zh');
expect(controller.locale, const Locale('zh'));
});
}
/// Stands in for a platform whose preference store is unavailable.
base class _BrokenPreferences extends InMemorySharedPreferencesAsync {
_BrokenPreferences() : super.empty();
@override
Future<String?> getString(String key, SharedPreferencesOptions options) =>
Future<String?>.error(StateError('preferences unavailable'));
@override
Future<bool> setString(
String key,
String value,
SharedPreferencesOptions options,
) => Future<bool>.error(StateError('preferences unavailable'));
}
@@ -0,0 +1,132 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('test/pip_channel');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
AndroidPictureInPicture pip() => AndroidPictureInPicture(channel: channel);
/// Simulates a native call arriving on the channel.
Future<void> sendToDart(String method, Object? arguments) => messenger
.handlePlatformMessage(
channel.name,
const StandardMethodCodec().encodeMethodCall(
MethodCall(method, arguments),
),
(_) {},
);
tearDown(() {
debugDefaultTargetPlatformOverride = null;
channel.setMethodCallHandler(null);
});
group('platform support', () {
test('should_support_only_android_when_target_is_checked', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(pip().isPlatformSupported, isTrue);
for (final platform in <TargetPlatform>[
TargetPlatform.iOS,
TargetPlatform.macOS,
TargetPlatform.linux,
TargetPlatform.windows,
TargetPlatform.fuchsia,
]) {
debugDefaultTargetPlatformOverride = platform;
expect(
pip().isPlatformSupported,
isFalse,
reason: '$platform must not claim picture in picture support',
);
}
});
});
group('enter', () {
test('should_refuse_without_touching_channel_when_target_is_not_android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
expect(await pip().enter(), isFalse);
expect(calls, isEmpty);
});
test('should_count_as_refused_when_native_answer_is_missing', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(channel, (call) async => null);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
expect(await pip().enter(), isFalse);
});
test('should_report_entered_when_native_call_succeeds', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return true;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
expect(await pip().enter(), isTrue);
expect(calls.single.method, 'enter');
expect(calls.single.arguments, isNull);
});
});
group('state listener', () {
test('should_deliver_pip_state_changes_in_order_when_native_events_arrive', () async {
final received = <bool>[];
pip().setStateListener(received.add);
await sendToDart('onPipChanged', true);
await sendToDart('onPipChanged', false);
expect(received, [true, false]);
});
test('should_treat_only_literal_true_as_enabled_when_native_value_arrives', () async {
final received = <bool>[];
pip().setStateListener(received.add);
await sendToDart('onPipChanged', null);
await sendToDart('onPipChanged', 'yes');
await sendToDart('onPipChanged', 1);
expect(received, [false, false, false]);
});
test('should_ignore_unrelated_native_methods_when_events_arrive', () async {
final received = <bool>[];
pip().setStateListener(received.add);
await sendToDart('somethingElse', true);
await sendToDart('onPipChanged', true);
expect(received, [true]);
});
test('should_silence_later_callbacks_when_listener_is_cleared', () async {
final received = <bool>[];
final observer = pip()..setStateListener(received.add);
await sendToDart('onPipChanged', true);
observer.setStateListener(null);
await sendToDart('onPipChanged', true);
expect(received, [true]);
});
});
}
+107
View File
@@ -0,0 +1,107 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import '../support/fake_http_adapter.dart';
/// 网络层换 dio 之后,把「对外契约」逐条钉住:解包、错误码映射、请求头、查询参数、跳过重试。
void main() {
String envelope(Object? data, {int code = 0, String message = 'ok'}) {
return jsonEncode({'code': code, 'message': message, 'data': data});
}
test('should_return_data_field_when_envelope_code_is_zero', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope({'id': 7})),
);
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
final data = await client.get('/app/dramas/7');
expect((data as Map)['id'], 7);
});
test('should_throw_api_exception_with_server_message_when_envelope_code_is_not_zero', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope(null, code: 40001, message: '邮箱格式不正确'),
),
);
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await expectLater(
client.post('/app/auth/register', body: {'target': 'bad'}),
throwsA(
isA<ApiException>()
.having((e) => e.code, 'code', 40001)
.having((e) => e.message, 'message', '邮箱格式不正确'),
),
);
});
test('should_map_to_api_exception_with_http_status_when_body_has_no_envelope', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse('oops', 502));
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await expectLater(
client.get('/app/home/feed'),
throwsA(
isA<ApiException>().having((e) => e.code, 'code', 502),
),
);
});
test('should_send_bearer_token_and_device_header_when_both_are_available', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
accessTokenReader: () => 'token-1',
deviceIdReader: () async => 'device-9',
);
await client.get('/app/me');
final request = adapter.requests.single;
expect(request.headers['Authorization'], 'Bearer token-1');
expect(request.headers['X-Device-Id'], 'device-9');
expect(request.headers['Accept'], 'application/json');
});
test('should_put_query_map_into_request_uri_when_query_is_given', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await client.get('/app/search', query: {'keyword': '短剧', 'page': '2'});
final uri = adapter.requests.single.uri;
expect(uri.path, '/app/search');
expect(uri.queryParameters['keyword'], '短剧');
expect(uri.queryParameters['page'], '2');
});
test('should_skip_refresh_when_call_opts_out_of_auth_retry', () async {
var refreshed = false;
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')),
);
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
tokenRefresher: () async {
refreshed = true;
return true;
},
);
await expectLater(
client.post('/app/auth/refresh', body: {'refreshToken': 'r1'}, skipAuthRetry: true),
throwsA(isA<ApiException>().having((e) => e.code, 'code', 401)),
);
expect(refreshed, isFalse);
expect(adapter.requests, hasLength(1));
});
}
+103
View File
@@ -0,0 +1,103 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/json_numbers.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段以字符串下发。
/// 这里钉住客户端在"收到字符串形式的 id / 计数"时照样能解析,不抛异常。
void main() {
group('jsonToInt', () {
test('should_parse_int_when_value_is_number', () {
expect(jsonToInt(12345), 12345);
expect(jsonToInt(12.9), 12);
});
test('should_parse_int_when_value_is_numeric_string', () {
expect(jsonToInt('2111012753262120962'), 2111012753262120962);
expect(jsonToInt(' 42 '), 42);
});
test('should_fall_back_when_value_is_missing_or_invalid', () {
expect(jsonToInt(null), 0);
expect(jsonToInt(''), 0);
expect(jsonToInt('abc'), 0);
expect(jsonToInt(null, fallback: 7), 7);
});
});
group('drama model', () {
test('should_parse_string_ids_when_backend_sends_ids_as_string', () {
final drama = Drama.fromCardJson({
'id': '2111012753262120962',
'title': '闪婚后,傅先生每天都在撒糖',
'totalEpisodes': 72,
'viewCount': '3100000',
'isHot': true,
});
expect(drama.id, '2111012753262120962');
expect(drama.title, '闪婚后,傅先生每天都在撒糖');
expect(drama.totalEpisodes, 72);
// viewCount 后端是 Long → 字符串
expect(drama.viewCount, 3100000);
});
test('should_parse_view_count_when_backend_sends_number', () {
final drama = Drama.fromCardJson({'id': 5, 'title': 'T', 'viewCount': 1200});
expect(drama.id, '5');
expect(drama.viewCount, 1200);
});
test('should_parse_detail_payload_when_ids_are_strings', () {
final drama = Drama.fromDetailJson({
'id': '2111012753262120962',
'title': 'T',
'totalEpisodes': 12,
'freeEpisodes': 3,
'viewCount': '99',
'episodes': [
{'id': '2111012753262120963', 'number': 1, 'title': 'E1', 'isFree': true},
],
});
expect(drama.id, '2111012753262120962');
expect(drama.freeEpisodes, 3);
expect(drama.viewCount, 99);
expect(drama.episodes.first.id, '2111012753262120963');
expect(drama.episodes.first.number, 1);
});
});
group('auth session', () {
test('should_parse_expires_in_when_backend_sends_string', () {
final session = AuthSession.fromJson({
'accessToken': 'a',
'refreshToken': 'r',
'expiresIn': '7200',
'user': {'uid': 'u-1', 'nickname': 'n'},
});
expect(session.expiresIn, 7200);
expect(session.user.uid, 'u-1');
});
});
group('play ticket', () {
test('should_parse_episode_id_when_backend_sends_string', () {
final ticket = PlayTicket.fromJson({
'episodeId': '2111012753262120963',
'accessible': false,
'accessType': PlayTicket.coinRequired,
'unlockCoin': 30,
});
expect(ticket.episodeId, '2111012753262120963');
expect(ticket.unlockCoin, 30);
expect(ticket.needsUnlock, isTrue);
});
});
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CoverCarousel(
items: [
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
CoverItem(
title: title,
subtitle: 'Mock',
image: '/files/poster.png',
onOpen: _noop,
),
],
),
),
),
);
await tester.pump();
expect(find.text('The Signal'), findsOneWidget);
expect(find.text('Mock'), findsOneWidget);
final box = tester.getRect(find.byType(CoverCarousel));
final gesture = await tester.startGesture(Offset(box.right - 40, box.center.dy));
await gesture.moveBy(const Offset(-500, 0));
await tester.pump();
final turned = tester.widgetList<Transform>(find.byType(Transform)).any(
(transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15,
);
expect(turned, isTrue);
await gesture.moveBy(const Offset(-200, 0));
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 450));
expect(find.text('City Lights'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
void _noop() {}
+346
View File
@@ -0,0 +1,346 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart';
import '../support/fake_watch_history.dart';
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
videoUrl: 'https://example.test/clip.mp4',
isFree: true,
),
],
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('should_resolve_resume_position_of_episode_one_when_constructed', () async {
final history = FakeWatchHistory()..saved = const Duration(seconds: 4);
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
expect(await logic.position, const Duration(seconds: 4));
expect(history.reads, ['demo-local-v1|signal|signal-1']);
expect(logic.category, 0);
expect(logic.searchOpen, isFalse);
logic.dispose();
});
test('should_reread_resume_position_when_drama_is_swapped', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
logic.drama = _drama('city');
logic.refreshPosition();
await logic.position;
expect(history.reads, [
'demo-local-v1|signal|signal-1',
'demo-local-v1|city|city-1',
]);
logic.dispose();
});
test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async {
final history = FakeWatchHistory()
..saved = const Duration(seconds: 9)
..lastEpisode = 'signal-2';
final logic = DiscoverLogic(
history: history,
drama: _drama('signal', numbers: const []),
);
expect(await logic.position, const Duration(seconds: 9));
expect(history.reads, ['demo-local-v1|signal|signal-2']);
logic.dispose();
});
test('should_have_no_resume_position_when_list_card_has_no_history', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(
history: history,
drama: _drama('signal', numbers: const []),
);
expect(await logic.position, isNull);
expect(history.reads, isEmpty);
logic.dispose();
});
test('should_store_index_and_notify_once_when_category_is_selected', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
var notifications = 0;
logic.addListener(() => notifications++);
logic.selectCategory(3);
expect(logic.category, 3);
expect(notifications, 1);
logic.dispose();
});
test('should_show_search_when_flag_is_on_or_text_is_typed', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
expect(logic.showSearch, isFalse);
logic.search.text = 'vengeance';
logic.onSearchChanged();
expect(logic.showSearch, isTrue);
logic.search.clear();
expect(logic.showSearch, isFalse);
logic.searchOpen = true;
expect(logic.showSearch, isTrue);
logic.dispose();
});
test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
final catalog = [_drama('signal'), _drama('city')];
expect(logic.dramaFor('city', catalog)?.id, 'city');
expect(logic.dramaFor('missing', catalog), isNull);
expect(logic.dramaFor(null, catalog), isNull);
expect(logic.dramaFor('city', const []), isNull);
logic.dispose();
});
test('should_adopt_drama_only_when_page_is_active', () {
final history = FakeWatchHistory();
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
void sync(
Drama next, {
required bool active,
required bool wasActive,
required bool dramaChanged,
int searchRequest = 0,
int previousSearchRequest = 0,
}) => logic.syncDrama(
active: active,
wasActive: wasActive,
nextDrama: next,
dramaChanged: dramaChanged,
searchRequest: searchRequest,
previousSearchRequest: previousSearchRequest,
isActive: () => active,
);
// An inactive page keeps showing its own drama.
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'signal');
// Becoming active adopts the incoming drama.
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
expect(logic.drama.id, 'city');
// Staying active with a changed drama re-reads history.
history.reads.clear();
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']);
// Staying active with no change leaves both drama and history alone.
history.reads.clear();
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
expect(logic.drama.id, 'vengeance');
expect(history.reads, isEmpty);
logic.dispose();
});
testWidgets('should_open_and_focus_field_when_search_is_requested_and_page_is_active', (
tester,
) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
),
),
),
);
logic.syncDrama(
active: true,
wasActive: true,
nextDrama: logic.drama,
dramaChanged: false,
searchRequest: 1,
previousSearchRequest: 0,
isActive: () => true,
);
expect(logic.searchOpen, isTrue);
await tester.pumpAndSettle();
expect(logic.searchFocus.hasFocus, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
logic.dispose();
});
testWidgets('should_keep_search_field_closed_when_page_is_hidden', (
tester,
) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
),
),
),
);
// The search request branch is gated on `active`, so a page that is not on
// screen must neither reveal the field nor pull focus away from the visible tab.
logic.syncDrama(
active: false,
wasActive: false,
nextDrama: logic.drama,
dramaChanged: false,
searchRequest: 1,
previousSearchRequest: 0,
isActive: () => false,
);
expect(logic.searchOpen, isFalse);
await tester.pumpAndSettle();
expect(logic.searchFocus.hasFocus, isFalse);
await tester.pumpWidget(const SizedBox.shrink());
logic.dispose();
});
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
tester,
) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
final forwarded = <DiscoverPageAction>[];
final popped = Completer<DiscoverPageAction?>();
late BuildContext pageContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context)
.push<DiscoverPageAction>(
MaterialPageRoute<DiscoverPageAction>(
builder: (inner) {
pageContext = inner;
return const SizedBox.shrink();
},
),
)
.then(popped.complete),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
logic.act(
pageContext,
DiscoverPageAction.home,
embedded: true,
onAction: forwarded.add,
);
expect(forwarded, [DiscoverPageAction.home]);
expect(Navigator.of(pageContext).canPop(), isTrue);
logic.act(pageContext, DiscoverPageAction.watchlist, embedded: false);
await tester.pumpAndSettle();
await expectLater(popped.future, completion(DiscoverPageAction.watchlist));
logic.dispose();
});
testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', (
tester,
) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (inner) {
context = inner;
return const Scaffold(body: SizedBox.shrink());
},
),
),
);
logic.openCard(context, null, embedded: false);
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(context)!;
expect(find.text(l10n.artworkOnlyNotice), findsOneWidget);
logic.dispose();
});
testWidgets('should_forward_chosen_drama_when_card_is_embedded', (tester) async {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
late BuildContext context;
Drama? selected;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (inner) {
context = inner;
return const SizedBox.shrink();
},
),
),
);
logic.openCard(
context,
_drama('city'),
embedded: true,
onDramaSelected: (drama) => selected = drama,
);
expect(selected?.id, 'city');
logic.dispose();
});
test('should_do_nothing_when_notifying_after_dispose', () {
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
var notifications = 0;
logic.addListener(() => notifications++);
logic.dispose();
logic.notify();
expect(notifications, 0);
});
}
@@ -0,0 +1,98 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
import '../support/fake_watch_history.dart';
Drama _card(String id) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/$id.jpg',
totalEpisodes: 3,
episodes: [
Episode(
id: '$id-1',
number: 1,
title: '第 1 集',
isFree: true,
videoUrl: '/files/$id-1.mp4',
),
],
);
Drama _detail(String id) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/$id.jpg',
totalEpisodes: 3,
detailLoaded: true,
episodes: [
for (var number = 1; number <= 3; number++)
Episode(
id: '$id-$number',
number: number,
title: '第 $number 集',
isFree: true,
videoUrl: '/files/$id-$number.mp4',
),
],
);
void main() {
test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async {
var calls = 0;
final logic = DramaDetailLogic(
drama: _card('signal'),
history: FakeWatchHistory(),
);
await logic.load(() async {
calls++;
return _detail('signal');
});
expect(calls, 1);
expect(logic.loading, isFalse);
expect(logic.failed, isFalse);
expect(logic.drama.episodes, hasLength(3));
logic.dispose();
});
test('should_keep_card_and_report_reason_when_load_fails', () async {
final logic = DramaDetailLogic(
drama: _card('signal'),
history: FakeWatchHistory(),
);
await logic.load(() async => throw const ApiException(500, '服务异常'));
expect(logic.failed, isTrue);
expect(logic.failureMessage, '服务异常');
expect(logic.drama.episodes, hasLength(1));
await logic.load(() async => throw StateError('offline'));
expect(logic.failed, isTrue);
expect(logic.failureMessage, isNull);
logic.dispose();
});
test('should_skip_request_when_detail_is_already_loaded', () async {
var calls = 0;
final logic = DramaDetailLogic(
drama: _detail('signal'),
history: FakeWatchHistory(),
);
await logic.load(() async {
calls++;
return _detail('signal');
});
expect(calls, 0);
expect(logic.drama.episodes, hasLength(3));
logic.dispose();
});
}
@@ -1,11 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/services/engagement_controller.dart';
import 'package:nontonki/core/data/repositories/engagement_repository.dart';
import 'package:fruitshorts/core/data/services/engagement_controller.dart';
import 'package:fruitshorts/core/data/repositories/engagement_repository.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';
void main() {
test('saved, liked and comments are kept by drama id', () async {
test('should_keep_saved_liked_and_comments_by_drama_id_when_toggled', () async {
final repository = InMemoryEngagementRepository();
final first = EngagementController(repository);
await first.load();
@@ -28,7 +28,7 @@ void main() {
expect(reopened.isSaved('signal'), isFalse);
});
test('local engagement survives a new repository instance', () async {
test('should_survive_new_repository_instance_when_engagement_is_local', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final first = EngagementController(SharedPreferencesEngagementRepository());
@@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/engagement_snapshot.dart';
import 'package:fruitshorts/core/data/repositories/engagement_repository.dart';
import 'package:shared_preferences/shared_preferences.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';
/// Mirrors the private key used by [SharedPreferencesEngagementRepository].
const _key = 'engagement.v1.demo-local-v1';
void main() {
late SharedPreferencesAsync preferences;
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
preferences = SharedPreferencesAsync();
});
SharedPreferencesEngagementRepository repository() =>
SharedPreferencesEngagementRepository(preferences: preferences);
Future<EngagementSnapshot> loadRaw(String raw) async {
await preferences.setString(_key, raw);
return repository().load();
}
test('should_read_empty_snapshot_when_first_launch', () async {
final snapshot = await repository().load();
expect(snapshot.savedDramaIds, isEmpty);
expect(snapshot.likedDramaIds, isEmpty);
expect(snapshot.commentsByDrama, isEmpty);
});
test('should_survive_save_and_reload_round_trip_when_snapshot_is_stored', () async {
await repository().save(
const EngagementSnapshot(
savedDramaIds: {'signal', 'city'},
likedDramaIds: {'city'},
commentsByDrama: {
'signal': ['first note', 'second note'],
},
),
);
final loaded = await repository().load();
expect(loaded.savedDramaIds, {'signal', 'city'});
expect(loaded.likedDramaIds, {'city'});
expect(loaded.commentsByDrama, {
'signal': ['first note', 'second note'],
});
});
test('should_round_trip_as_empty_collections_when_snapshot_is_empty', () async {
await repository().save(const EngagementSnapshot());
final loaded = await repository().load();
expect(loaded.savedDramaIds, isEmpty);
expect(loaded.likedDramaIds, isEmpty);
expect(loaded.commentsByDrama, isEmpty);
});
test('should_fall_back_to_empty_snapshot_when_payload_is_corrupt_or_not_an_object', () async {
for (final raw in <String>[
'',
'not json at all',
'[]',
'"a bare string"',
'42',
'null',
]) {
final snapshot = await loadRaw(raw);
expect(snapshot.savedDramaIds, isEmpty, reason: raw);
expect(snapshot.likedDramaIds, isEmpty, reason: raw);
expect(snapshot.commentsByDrama, isEmpty, reason: raw);
}
});
test('should_drop_mistyped_fields_when_payload_has_wrong_types', () async {
final snapshot = await loadRaw(
jsonEncode({
'saved': 'signal',
'liked': {'city': true},
'comments': {
'signal': 'a plain string',
'city': ['kept', 7, null, true],
'town': <Object>[],
},
}),
);
expect(snapshot.savedDramaIds, isEmpty);
expect(snapshot.likedDramaIds, isEmpty);
expect(snapshot.commentsByDrama, {
'city': ['kept'],
'town': <String>[],
});
});
test('should_fall_back_per_field_when_payload_keys_are_missing', () async {
final snapshot = await loadRaw(jsonEncode({'saved': ['signal']}));
expect(snapshot.savedDramaIds, {'signal'});
expect(snapshot.likedDramaIds, isEmpty);
expect(snapshot.commentsByDrama, isEmpty);
});
test('should_write_only_json_primitives_when_saving', () async {
await repository().save(
const EngagementSnapshot(
savedDramaIds: {'signal'},
likedDramaIds: {'city'},
commentsByDrama: {
'signal': ['note'],
},
),
);
final raw = await preferences.getString(_key);
expect(raw, isNotNull);
final decoded = jsonDecode(raw!) as Map<String, dynamic>;
expect(decoded['saved'], ['signal']);
expect(decoded['liked'], ['city']);
expect(decoded['comments'], {
'signal': ['note'],
});
});
}
@@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/formatting/localized_content.dart';
import 'package:fruitshorts/l10n/app_localizations_en.dart';
import 'package:fruitshorts/l10n/app_localizations_zh.dart';
Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}) =>
Drama(
id: id,
title: title,
synopsis: synopsis,
posterUrl: '/files/poster.png',
episodes: const [],
);
void main() {
final en = AppLocalizationsEn();
final zh = AppLocalizationsZh();
test('should_map_each_seeded_drama_to_its_own_localized_copy_when_looked_up', () {
final signal = _drama(id: 'the-signal-demo');
final city = _drama(id: 'city-lights-demo');
final vengeance = _drama(id: 'vengeance-demo');
expect(localizedDramaTitle(en, signal), en.dramaSignalTitle);
expect(localizedDramaTitle(en, city), en.dramaCityTitle);
expect(localizedDramaTitle(en, vengeance), en.dramaVengeanceTitle);
expect(localizedDramaSynopsis(en, signal), en.dramaSignalSynopsis);
expect(localizedDramaSynopsis(en, city), en.dramaCitySynopsis);
expect(localizedDramaSynopsis(en, vengeance), en.dramaVengeanceSynopsis);
});
test('should_keep_two_dramas_apart_when_localized_copy_is_mapped', () {
final titles = {
localizedDramaTitle(en, _drama(id: 'the-signal-demo')),
localizedDramaTitle(en, _drama(id: 'city-lights-demo')),
localizedDramaTitle(en, _drama(id: 'vengeance-demo')),
};
final synopses = {
localizedDramaSynopsis(en, _drama(id: 'the-signal-demo')),
localizedDramaSynopsis(en, _drama(id: 'city-lights-demo')),
localizedDramaSynopsis(en, _drama(id: 'vengeance-demo')),
};
expect(titles, hasLength(3));
expect(synopses, hasLength(3));
});
test('should_translate_localized_copy_when_content_is_looked_up', () {
final signal = _drama(id: 'the-signal-demo');
expect(localizedDramaTitle(zh, signal), zh.dramaSignalTitle);
expect(localizedDramaSynopsis(zh, signal), zh.dramaSignalSynopsis);
expect(localizedDramaTitle(zh, signal), isNot(en.dramaSignalTitle));
});
test('should_keep_catalog_text_in_every_locale_when_drama_is_unknown', () {
final custom = _drama(
id: 'custom-demo',
title: 'Custom Title',
synopsis: 'Custom synopsis',
);
expect(localizedDramaTitle(en, custom), 'Custom Title');
expect(localizedDramaTitle(zh, custom), 'Custom Title');
expect(localizedDramaSynopsis(en, custom), 'Custom synopsis');
expect(localizedDramaSynopsis(zh, custom), 'Custom synopsis');
});
test('should_ignore_raw_catalog_text_when_drama_is_seeded', () {
final signal = _drama(
id: 'the-signal-demo',
title: 'stale title',
synopsis: 'stale synopsis',
);
expect(localizedDramaTitle(en, signal), isNot('stale title'));
expect(localizedDramaSynopsis(en, signal), isNot('stale synopsis'));
});
test('should_use_named_titles_when_episode_number_is_one_to_four', () {
expect(localizedEpisodeTitle(en, 1), en.episodeOneTitle);
expect(localizedEpisodeTitle(en, 2), en.episodeTwoTitle);
expect(localizedEpisodeTitle(en, 3), en.episodeThreeTitle);
expect(localizedEpisodeTitle(en, 4), en.episodeFourTitle);
});
test('should_fall_back_to_numbered_label_when_episode_number_is_later', () {
for (final number in <int>[5, 12, 100]) {
expect(localizedEpisodeTitle(en, number), en.episodeLabel(number));
expect(localizedEpisodeTitle(en, number), contains('$number'));
}
expect(localizedEpisodeTitle(zh, 12), zh.episodeLabel(12));
});
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart';
import 'package:fruitshorts/pages/home/home_logic.dart';
import 'package:fruitshorts/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('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () {
final logic = HomeLogic(dramas);
expect(logic.currentDrama.id, 'signal');
expect(logic.tab, 1);
expect(logic.mySection, 0);
expect(logic.drawerOpen, isFalse);
expect(logic.profile, isFalse);
expect(logic.videoImmersive, isFalse);
logic.dispose();
});
test('should_not_build_shell_when_catalog_is_empty', () {
expect(() => HomeLogic(const []), throwsStateError);
});
test('should_not_raise_request_counter_when_only_tab_switches', () {
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('should_raise_only_its_own_counter_when_each_select_flag_is_used', () {
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('should_default_to_second_section_when_watchlist_shortcut_is_used', () {
final logic = HomeLogic(dramas);
logic.select(2, watchlist: true);
expect(logic.tab, 2);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 1);
logic.dispose();
});
test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () {
final logic = HomeLogic(dramas);
logic.openProfile();
expect(logic.profile, isTrue);
expect(logic.tab, 2);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
logic.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('should_return_home_and_open_drawer_when_system_back_is_pressed', () {
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('should_notify_only_on_actual_change_when_drawer_state_is_set', () {
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('should_update_state_when_drama_and_immersive_callbacks_fire', () {
final logic = HomeLogic(dramas);
logic.onDramaChanged(dramas[1]);
expect(logic.currentDrama.id, 'city');
logic.onImmersiveChanged(true);
expect(logic.videoImmersive, isTrue);
logic.dispose();
});
test('should_map_onto_root_tabs_when_my_page_actions_fire', () {
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('should_do_nothing_when_notifying_after_dispose', () {
final logic = HomeLogic(dramas);
var notifications = 0;
logic.addListener(() => notifications++);
logic.dispose();
logic.notify();
expect(notifications, 0);
});
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (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();
});
}
+34
View File
@@ -0,0 +1,34 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
/// 联调测试:直连本机客户端接口服务(8081)验证 **POST + JSON body + 错误码映射**。
///
/// 特意用"不存在的邮箱"登录:既能走完整的 POST 链路,又不会在库里留下测试数据。
/// 服务没启动时自动跳过,不影响日常 `flutter test`。
void main() {
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
final api = AuthApi(client);
tearDownAll(client.close);
test('should_surface_business_error_when_sign_in_uses_unknown_email', () async {
try {
await api.login(
email: 'no-such-user-for-dio-check@example.com',
password: 'abcd1234',
deviceId: 'dio-check-device',
);
fail('不存在的邮箱不应该登录成功');
} on ApiException catch (error) {
// 关键点:错误码来自响应体(不是 HTTP 状态码),说明统一解包生效
expect(error.code, isNot(0));
expect(error.message, isNotEmpty);
} catch (_) {
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
}
});
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
/// 联调测试:直连本机客户端接口服务(8081)。
/// 服务没启动时自动跳过,不影响日常 `flutter test`。
void main() {
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
final api = CatalogApi(client);
tearDownAll(client.close);
Future<bool> serverUp() async {
try {
await api.homeFeed();
return true;
} catch (_) {
return false;
}
}
test('should_parse_banner_and_sections_when_home_feed_is_fetched', () async {
if (!await serverUp()) {
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
return;
}
final feed = await api.homeFeed();
expect(feed.sections, isNotEmpty);
expect(feed.dramas, isNotEmpty);
final card = feed.dramas.first;
expect(card.id, isNotEmpty);
expect(card.title, isNotEmpty);
expect(card.posterUrl, startsWith('/files/'));
});
test('should_order_episodes_by_number_and_attach_url_when_detail_is_fetched', () async {
if (!await serverUp()) {
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
return;
}
final feed = await api.homeFeed();
final card = feed.dramas.first;
final detail = await api.dramaDetail(card.id);
expect(detail.id, card.id);
expect(detail.episodes, isNotEmpty);
final numbers = detail.episodes.map((episode) => episode.number).toList();
expect(numbers, orderedEquals([...numbers]..sort()));
for (final episode in detail.episodes) {
if (episode.locked) {
expect(episode.videoUrl, isEmpty);
} else {
expect(episode.videoUrl, startsWith('/files/'));
}
}
});
test('should_return_play_url_when_episode_is_playable', () async {
if (!await serverUp()) {
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
return;
}
final feed = await api.homeFeed();
final detail = await api.dramaDetail(feed.dramas.first.id);
final playable = detail.episodes
.where((episode) => episode.videoUrl.isNotEmpty)
.toList();
if (playable.isEmpty) {
return;
}
final ticket = await api.playTicket(playable.first.id);
expect(ticket.accessible, isTrue);
expect(ticket.playUrl, startsWith('/files/'));
});
}
+256
View File
@@ -0,0 +1,256 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/mine/mine_logic.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 '../support/fake_watch_history.dart';
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
videoUrl: 'https://example.test/clip.mp4',
isFree: true,
),
],
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('should_keep_requested_section_and_read_episode_one_when_constructed', () async {
final history = FakeWatchHistory()..saved = const Duration(seconds: 7);
final logic = MineLogic(
history: history,
drama: _drama('signal'),
initialSection: 1,
);
expect(logic.section, 1);
expect(logic.filter, 0);
expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['demo-local-v1|signal|signal-1']);
logic.dispose();
});
test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async {
final history = FakeWatchHistory()
..saved = const Duration(seconds: 7)
..lastEpisode = 'signal-3';
final logic = MineLogic(
history: history,
drama: _drama('signal', numbers: const []),
initialSection: 1,
);
expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['demo-local-v1|signal|signal-3']);
logic.dispose();
});
test('should_notify_when_section_and_filter_are_selected', () {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
var notifications = 0;
logic.addListener(() => notifications++);
logic.selectSection(1);
expect(logic.section, 1);
expect(notifications, 1);
logic.selectFilter(2);
expect(logic.filter, 2);
expect(notifications, 2);
logic.dispose();
});
test('should_adopt_drama_only_when_page_is_active', () {
final history = FakeWatchHistory();
final logic = MineLogic(
history: history,
drama: _drama('signal'),
initialSection: 0,
);
void sync(
Drama next, {
required bool active,
required bool wasActive,
required bool dramaChanged,
int sectionRequest = 0,
int previousSectionRequest = 0,
int initialSection = 0,
}) => logic.syncPage(
active: active,
wasActive: wasActive,
nextDrama: next,
dramaChanged: dramaChanged,
sectionRequest: sectionRequest,
previousSectionRequest: previousSectionRequest,
initialSection: initialSection,
);
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'signal');
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
expect(logic.drama.id, 'city');
history.reads.clear();
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']);
history.reads.clear();
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
expect(logic.drama.id, 'vengeance');
expect(history.reads, isEmpty);
logic.dispose();
});
test('should_reset_section_when_section_request_arrives_while_page_is_inactive', () {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
logic.syncPage(
active: false,
wasActive: false,
nextDrama: logic.drama,
dramaChanged: false,
sectionRequest: 1,
previousSectionRequest: 0,
initialSection: 1,
);
expect(logic.section, 1);
logic.dispose();
});
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
tester,
) async {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
final forwarded = <MyPageAction>[];
final popped = Completer<MyPageAction?>();
late BuildContext pageContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context)
.push<MyPageAction>(
MaterialPageRoute<MyPageAction>(
builder: (inner) {
pageContext = inner;
return const SizedBox.shrink();
},
),
)
.then(popped.complete),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
logic.act(
pageContext,
MyPageAction.discover,
embedded: true,
onAction: forwarded.add,
);
expect(forwarded, [MyPageAction.discover]);
expect(Navigator.of(pageContext).canPop(), isTrue);
logic.act(pageContext, MyPageAction.resume, embedded: false);
await tester.pumpAndSettle();
await expectLater(popped.future, completion(MyPageAction.resume));
logic.dispose();
});
testWidgets('should_switch_app_locale_when_language_sheet_is_used', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final locale = LocaleController();
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
late BuildContext context;
await tester.pumpWidget(
ChangeNotifierProvider<LocaleController>.value(
value: locale,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (inner) {
context = inner;
return const Scaffold(body: SizedBox.shrink());
},
),
),
),
);
unawaited(logic.showLanguage(context));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(context)!;
expect(find.text(l10n.english), findsOneWidget);
expect(find.text(l10n.simplifiedChinese), findsOneWidget);
await tester.tap(find.text(l10n.simplifiedChinese));
await tester.pumpAndSettle();
expect(locale.locale, const Locale('zh'));
logic.dispose();
});
test('should_do_nothing_when_notifying_after_dispose', () {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
var notifications = 0;
logic.addListener(() => notifications++);
logic.dispose();
logic.notify();
expect(notifications, 0);
});
}
+14 -14
View File
@@ -1,9 +1,9 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/models/drama.dart';
import 'package:nontonki/core/data/services/playback_session.dart';
import 'package:nontonki/core/data/models/player_port.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/models/player_port.dart';
void main() {
final episodes = List.generate(
@@ -12,12 +12,12 @@ void main() {
id: 'ep-$i',
number: i + 1,
title: 'Episode $i',
assetPath: 'test-$i.mp4',
videoUrl: 'https://example.test/$i.mp4',
isFree: i < 4,
),
);
test('warm playback starts before old native disposal finishes', () async {
test('should_start_warm_playback_when_old_native_disposal_is_still_running', () async {
final release = Completer<void>();
final factory = _Factory(disposeGates: {'ep-0': release});
final session = PlaybackSession(
@@ -41,7 +41,7 @@ void main() {
await session.close();
});
test('failed native release blocks another route from allocating', () async {
test('should_block_another_route_from_allocating_when_native_release_fails', () async {
final factory = _Factory(disposeFailures: {'ep-0'});
final coordinator = PlaybackCoordinator();
PlaybackSession createSession() => PlaybackSession(
@@ -68,7 +68,7 @@ void main() {
});
test(
'next is prepared silently and promoted without another initialize',
'should_prepare_next_silently_and_promote_it_when_swiped',
() async {
final factory = _Factory();
final prepared = <String>[];
@@ -103,7 +103,7 @@ void main() {
},
);
test('in-flight preload is reused when swiped before ready', () async {
test('should_reuse_in_flight_preload_when_swiped_before_ready', () async {
final gate = Completer<void>();
final factory = _Factory(gates: {'ep-1': gate});
final session = PlaybackSession(
@@ -127,7 +127,7 @@ void main() {
await session.close();
});
test('rapid reverse and skipped selection discard stale warm work', () async {
test('should_discard_stale_warm_work_when_reversing_rapidly_or_skipping_selection', () async {
final gate = Completer<void>();
final factory = _Factory(gates: {'ep-1': gate});
final session = PlaybackSession(
@@ -153,7 +153,7 @@ void main() {
});
test(
'category adjacency and locked next never preload the wrong episode',
'should_never_preload_wrong_episode_when_next_is_locked_or_category_based',
() async {
final factory = _Factory();
final session = PlaybackSession(
@@ -172,7 +172,7 @@ void main() {
},
);
test('preload failure is isolated and selecting it retries once', () async {
test('should_isolate_preload_failure_and_retry_once_when_selected', () async {
final factory = _Factory(failOnce: {'ep-1'});
final session = PlaybackSession(
episodes: episodes,
@@ -190,7 +190,7 @@ void main() {
await session.close();
});
test('completion in background never starts either player', () async {
test('should_start_no_player_when_completion_happens_in_background', () async {
final gate = Completer<void>();
final factory = _Factory(gates: {'ep-1': gate});
final session = PlaybackSession(
@@ -215,7 +215,7 @@ void main() {
await session.close();
});
test('closing during preloading disposes pending resources', () async {
test('should_dispose_pending_resources_when_closing_during_preload', () async {
final gate = Completer<void>();
final factory = _Factory(gates: {'ep-1': gate});
final session = PlaybackSession(
@@ -233,7 +233,7 @@ void main() {
});
test(
'home and series share a two-player budget across route handoff',
'should_share_two_player_budget_when_handing_off_between_home_and_series',
() async {
final factory = _Factory();
final coordinator = PlaybackCoordinator();
+71 -21
View File
@@ -1,26 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
import 'package:nontonki/core/data/services/playback_queue.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/services/playback_queue.dart';
void main() {
test(
'home feed advances between dramas, series player between episodes',
() async {
final dramas = await DemoCatalogRepository().fetchDramas();
final home = PlaybackQueue.home(dramas);
expect(home.length, 3);
expect(home.entryAt(0).drama.id, dramas[0].id);
expect(home.entryAt(1).drama.id, dramas[1].id);
expect(home.entryAt(1).episode.number, 1);
expect(home.nextIndex(0), 1);
expect(home.nextIndex(2), isNull);
test('should_advance_between_dramas_on_home_and_between_episodes_in_series_when_queue_is_built', () {
final dramas = [_drama('a', 3), _drama('b', 2), _drama('c', 4)];
final series = PlaybackQueue.series(dramas[0]);
expect(series.length, 3);
expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id);
expect(series.entryAt(1).episode.number, 2);
expect(series.nextIndex(0), 1);
expect(series.nextIndex(2), isNull);
},
);
final home = PlaybackQueue.home(dramas);
expect(home.length, 3);
expect(home.entryAt(0).drama.id, 'a');
expect(home.entryAt(1).drama.id, 'b');
expect(home.entryAt(1).episode.number, 1);
expect(home.nextIndex(0), 1);
expect(home.nextIndex(2), isNull);
final series = PlaybackQueue.series(dramas[0]);
expect(series.length, 3);
expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id);
expect(series.entryAt(1).episode.number, 2);
expect(series.nextIndex(0), 1);
expect(series.nextIndex(2), isNull);
});
test('should_leave_dramas_out_when_they_have_no_episodes', () {
final home = PlaybackQueue.home([
_drama('a', 2),
Drama(
id: 'empty',
title: '没有剧集',
synopsis: '',
posterUrl: '/files/empty.jpg',
),
]);
expect(home.length, 1);
expect(home.entryAt(0).drama.id, 'a');
});
test('should_play_preview_episode_when_list_card_has_one', () {
final home = PlaybackQueue.home([
Drama.fromCardJson(const {
'id': 5,
'title': '闪婚后',
'cover': '/files/cover.jpg',
'totalEpisodes': 77,
'preview': {
'id': 9,
'number': 1,
'title': '第 1 集',
'isFree': true,
'playUrl': '/files/episode/9.mp4',
},
}),
]);
expect(home.length, 1);
expect(home.entryAt(0).episode.videoUrl, '/files/episode/9.mp4');
});
}
Drama _drama(String id, int episodeCount) => Drama(
id: id,
title: 'Drama $id',
synopsis: '简介 $id',
posterUrl: '/files/$id.jpg',
episodes: [
for (var index = 1; index <= episodeCount; index++)
Episode(
id: '$id-$index',
number: index,
title: '第 $index 集',
isFree: true,
videoUrl: 'https://example.test/$id-$index.mp4',
),
],
);
+50 -9
View File
@@ -1,9 +1,9 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/models/drama.dart';
import 'package:nontonki/core/data/services/playback_session.dart';
import 'package:nontonki/core/data/models/player_port.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/models/player_port.dart';
void main() {
final episodes = List.generate(
@@ -12,12 +12,12 @@ void main() {
id: 'ep-${i + 1}',
number: i + 1,
title: 'Episode ${i + 1}',
assetPath: 'assets/media/episode_0${i + 1}.mp4',
videoUrl: 'https://example.test/episode_0${i + 1}.mp4',
isFree: i < 2,
),
);
test('locked episode never creates or initializes a player', () async {
test('should_create_no_player_when_episode_is_locked', () async {
final factory = FakeFactory();
final session = PlaybackSession(
episodes: episodes,
@@ -30,7 +30,7 @@ void main() {
await session.close();
});
test('rapid selection drops stale initialize and keeps one player', () async {
test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async {
final factory = FakeFactory(holdFirstInitialize: true);
final session = PlaybackSession(
episodes: episodes,
@@ -52,7 +52,7 @@ void main() {
});
test(
'background and overlay pause without forgetting playback intent',
'should_pause_without_forgetting_intent_when_backgrounding_or_overlay_shows',
() async {
final factory = FakeFactory();
final session = PlaybackSession(
@@ -80,7 +80,7 @@ void main() {
},
);
test('resume preparation finishes before video starts', () async {
test('should_finish_resume_preparation_before_video_starts_when_resuming', () async {
final factory = FakeFactory();
final prepared = Completer<void>();
final session = PlaybackSession(
@@ -99,7 +99,48 @@ void main() {
await session.close();
});
test('automatic advance respects visibility and playback intent', () async {
test('should_resume_playback_when_swiping_after_pause', () async {
final factory = FakeFactory();
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (episode) => episode.isFree,
);
await session.select(0);
session.setUserWantsPlay(false);
await Future<void>.delayed(Duration.zero);
expect(session.userWantsPlay, isFalse);
await session.select(1);
expect(session.userWantsPlay, isTrue);
await Future<void>.delayed(Duration.zero);
final playing = session.active;
expect(playing, isA<FakePlayer>());
expect((playing! as FakePlayer).playCalls, greaterThan(0));
await session.close();
});
test('should_keep_user_pause_when_reselecting_the_same_episode', () async {
final factory = FakeFactory();
final session = PlaybackSession(
episodes: episodes,
factory: factory.create,
canPlay: (episode) => episode.isFree,
);
await session.select(0);
session.setUserWantsPlay(false);
await Future<void>.delayed(Duration.zero);
// 页面重新可见等场景会重复选中同一集,这时不能把暂停改成播放。
await session.select(0);
expect(session.userWantsPlay, isFalse);
expect(session.canAutoAdvance, isFalse);
await session.close();
});
test('should_respect_visibility_and_intent_when_advancing_automatically', () async {
final factory = FakeFactory();
final session = PlaybackSession(
episodes: episodes,
+7 -8
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/pages/player/widgets/seek_progress_bar.dart';
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
import 'package:video_player/video_player.dart';
void main() {
test('seek target stays inside playable duration', () {
test('should_keep_seek_target_inside_playable_duration_when_dragging', () {
const duration = Duration(seconds: 12);
expect(seekPositionForFraction(0, duration), Duration.zero);
expect(
@@ -14,7 +14,7 @@ void main() {
expect(seekPositionForFraction(1, duration), lessThan(duration));
});
testWidgets('horizontal slider drag previews and seeks on release', (
testWidgets('should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', (
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>(
@@ -61,7 +61,7 @@ void main() {
expect(target!, lessThan(const Duration(seconds: 12)));
});
testWidgets('compact seek bar shows time only while dragging', (
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>(
@@ -78,21 +78,20 @@ void main() {
body: SeekProgressBar(
valueListenable: video,
semanticsLabel: 'Seek video',
compactIdle: true,
onSeek: (_) async {},
),
),
),
);
expect(find.text('00:03'), findsNothing);
expect(find.textContaining('00:12'), findsNothing);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Slider)),
);
await gesture.moveBy(const Offset(40, 0));
await tester.pump();
expect(find.text('00:12'), findsOneWidget);
expect(find.textContaining('/ 00:12'), findsOneWidget);
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('00:12'), findsNothing);
expect(find.textContaining('00:12'), findsNothing);
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/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('should_prefix_service_base_when_play_url_is_relative', () {
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('should_keep_url_untouched_when_play_url_is_absolute', () {
final uri = VideoPlayerPort.resolveUri(
_episode('https://cdn.example.test/clip.mp4'),
);
expect(uri.toString(), 'https://cdn.example.test/clip.mp4');
});
}
+79 -3
View File
@@ -1,10 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.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';
void main() {
test('resume position clamps invalid and completed values', () {
test('should_clamp_invalid_and_completed_values_when_resolving_resume_position', () {
const duration = Duration(seconds: 8);
expect(
clampResumePosition(const Duration(seconds: -1), duration),
@@ -24,7 +25,7 @@ void main() {
);
});
test('watch history is scoped by profile, drama and episode', () async {
test('should_scope_history_by_profile_drama_and_episode_when_saving', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
@@ -51,4 +52,79 @@ void main() {
expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2');
expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull);
});
test('should_prefer_first_loaded_episode_when_resuming', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
await history.save(
'profile-a',
'drama-a',
'episode-1',
const Duration(seconds: 3),
);
await history.save(
'profile-a',
'drama-a',
'episode-2',
const Duration(seconds: 5),
);
final drama = Drama(
id: 'drama-a',
title: 'title',
synopsis: 'synopsis',
posterUrl: '',
episodes: const [
Episode(id: 'episode-1', number: 1, title: 'E1', isFree: true),
Episode(id: 'episode-2', number: 2, title: 'E2', isFree: true),
],
);
expect(
await history.resumePositionFor('profile-a', drama),
const Duration(seconds: 3),
);
});
test('should_fall_back_to_last_watched_episode_when_resuming_a_list_card', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
await history.save(
'profile-a',
'drama-a',
'episode-2',
const Duration(seconds: 5),
);
const card = Drama(
id: 'drama-a',
title: 'title',
synopsis: 'synopsis',
posterUrl: '',
totalEpisodes: 12,
);
expect(
await history.resumePositionFor('profile-a', card),
const Duration(seconds: 5),
);
});
test('should_have_no_resume_position_when_list_card_has_no_history', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final history = SharedPreferencesWatchHistoryRepository();
const card = Drama(
id: 'drama-b',
title: 'title',
synopsis: 'synopsis',
posterUrl: '',
totalEpisodes: 12,
);
expect(await history.resumePositionFor('profile-a', card), isNull);
});
}
+479
View File
@@ -0,0 +1,479 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/player_port.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import '../support/fake_watch_history.dart';
Drama _drama(
String id, {
List<int> numbers = const [1, 2, 3],
bool isFree = true,
}) => Drama(
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
// 与后端一致:只有可播放的剧集才带地址,锁定集为空
videoUrl: isFree ? 'https://example.test/clip.mp4' : '',
isFree: isFree,
),
],
);
AndroidPictureInPicture _pip() =>
AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip'));
PlayerLogic _logic(FakeWatchHistory history) =>
PlayerLogic(history: history, pictureInPicture: _pip());
/// Drains the session's serialized work so a test never ends mid-disposal.
///
/// `start` kicks off selection without awaiting it, so the first read of
/// `playbackSettled` can still be the untouched initial future. Pumping the
/// event queue between reads closes that race.
Future<void> _drain(PlayerLogic logic) async {
for (var attempt = 0; attempt < 4; attempt++) {
await pumpEventQueue();
await logic.session.playbackSettled;
await logic.session.preloadSettled;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('displayEpisode', () {
test('should_fall_back_until_episode_number_is_picked_for_that_drama_when_resolving', () {
final logic = _logic(FakeWatchHistory());
final drama = _drama('signal');
final fallback = drama.episodes.first;
expect(logic.displayEpisode(drama, fallback).number, 1);
logic.pickedEpisode['signal'] = 3;
expect(logic.displayEpisode(drama, fallback).number, 3);
// Picks are scoped to their own drama.
final city = _drama('city');
expect(logic.displayEpisode(city, city.episodes.first).number, 1);
});
test('should_stop_overriding_fallback_when_picked_number_is_unknown', () {
final logic = _logic(FakeWatchHistory());
final drama = _drama('signal');
final fallback = drama.episodes.first;
logic.pickedEpisode['signal'] = 99;
expect(logic.displayEpisode(drama, fallback).number, 1);
});
});
group('nextVisibleIndex', () {
test('should_walk_visible_list_and_stop_at_the_end_when_advancing', () {
final logic = _logic(FakeWatchHistory());
logic.visibleIndices = [0, 2, 4];
expect(logic.nextVisibleIndex(0), 2);
expect(logic.nextVisibleIndex(2), 4);
expect(logic.nextVisibleIndex(4), isNull);
});
test('should_have_no_successor_when_index_is_outside_visible_list', () {
final logic = _logic(FakeWatchHistory());
logic.visibleIndices = [0, 2, 4];
expect(logic.nextVisibleIndex(1), isNull);
expect(logic.nextVisibleIndex(9), isNull);
expect(logic.nextVisibleIndex(-1), isNull);
});
test('should_have_no_successor_when_visible_list_is_empty', () {
final logic = _logic(FakeWatchHistory());
logic.visibleIndices = [];
expect(logic.nextVisibleIndex(0), isNull);
});
});
group('surface flags', () {
test('should_hide_chrome_until_restored_when_screen_is_cleared', () {
final logic = _logic(FakeWatchHistory());
final immersive = <bool>[];
logic.onImmersiveChanged = immersive.add;
var notifications = 0;
logic.addListener(() => notifications++);
expect(logic.chromeHidden, isFalse);
expect(logic.immersive, isFalse);
logic.setScreenCleared(true);
expect(logic.chromeHidden, isTrue);
expect(notifications, 1);
expect(immersive, [true]);
// Repeating the same value is not a state change.
logic.setScreenCleared(true);
expect(notifications, 1);
expect(immersive, [true]);
logic.setScreenCleared(false);
expect(logic.chromeHidden, isFalse);
expect(notifications, 2);
expect(immersive, [true, false]);
});
test('should_outrank_cleared_screen_flag_when_picture_in_picture_callback_fires', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
final logic = _logic(FakeWatchHistory());
final immersive = <bool>[];
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
);
logic.onImmersiveChanged = immersive.add;
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
logic.onPipChanged(true);
expect(logic.pip, isTrue);
expect(logic.pipPreparing, isFalse);
expect(logic.immersive, isTrue);
expect(logic.chromeHidden, isTrue);
expect(immersive.last, isTrue);
logic.onPipChanged(false);
expect(logic.pip, isFalse);
expect(logic.immersive, isFalse);
logic.dispose();
await _drain(logic);
});
test('should_ignore_picture_in_picture_callback_when_unmounted', () {
final logic = _logic(FakeWatchHistory());
logic.isMounted = () => false;
logic.onPipChanged(true);
expect(logic.pip, isFalse);
});
});
group('history writes', () {
test('should_serialize_progress_writes_in_call_order_when_saving', () async {
final history = FakeWatchHistory();
final logic = _logic(history);
final drama = _drama('signal');
await logic.queueProgress(
drama,
drama.episodes[0],
const Duration(seconds: 1),
);
await logic.queueProgress(
drama,
drama.episodes[1],
const Duration(seconds: 2),
);
expect(history.writes, [
'demo-local-v1|signal|signal-1|1000',
'demo-local-v1|signal|signal-2|2000',
]);
});
test('should_keep_later_writes_when_an_earlier_write_fails', () async {
final history = FakeWatchHistory();
final logic = _logic(history);
final drama = _drama('signal');
history.failWrites = true;
await logic.queueProgress(
drama,
drama.episodes[0],
const Duration(seconds: 1),
);
history.failWrites = false;
await logic.queueProgress(
drama,
drama.episodes[1],
const Duration(seconds: 2),
);
expect(history.writes, ['demo-local-v1|signal|signal-2|2000']);
});
test('should_write_nothing_when_no_player_is_tracked', () async {
final history = FakeWatchHistory();
final logic = _logic(history);
await logic.saveCurrentProgress();
await logic.saveCurrentProgress();
expect(history.writes, isEmpty);
expect(history.reads, isEmpty);
});
});
group('start', () {
test('should_play_single_series_when_route_is_detail', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
expect(logic.queue.length, 3);
expect(logic.visibleIndices, [0, 1, 2]);
expect(logic.session.currentIndex, 0);
expect(factory.created, hasLength(2));
expect(factory.created.first.plays, 1);
logic.dispose();
await _drain(logic);
expect(factory.live, 0);
});
test('should_play_one_episode_per_feed_drama_when_in_home_mode', () async {
final factory = _RecordingFactory();
final feed = [_drama('signal'), _drama('city'), _drama('vengeance')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 0,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
expect(logic.queue.length, 3);
expect(logic.queue.entryAt(1).drama.id, 'city');
expect(logic.queue.entryAt(1).episode.number, 1);
expect(logic.visibleIndices, [0, 1, 2]);
logic.dispose();
await _drain(logic);
});
test('should_never_reach_player_factory_when_episode_is_locked', () async {
final factory = _RecordingFactory();
final drama = _drama('locked', isFree: false);
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
expect(logic.session.isLocked, isTrue);
expect(factory.created, isEmpty);
logic.dispose();
await _drain(logic);
});
test('should_preload_only_what_follows_when_starting_from_a_later_index', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(drama: drama, initialIndex: 2),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
expect(logic.session.currentIndex, 2);
// The last episode has no successor to warm.
expect(factory.created, hasLength(1));
logic.dispose();
await _drain(logic);
});
test('should_hold_no_players_when_route_is_inactive', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(drama: drama, initialIndex: 0, active: false),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
expect(factory.created, isEmpty);
expect(logic.session.loading, isFalse);
logic.dispose();
await _drain(logic);
});
});
group('lifecycle', () {
test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
final player = factory.created.first;
expect(player.plays, 1);
expect(player.pauses, 0);
logic.onLifecycle(AppLifecycleState.paused);
await _drain(logic);
expect(player.pauses, 1);
logic.onLifecycle(AppLifecycleState.resumed);
await _drain(logic);
expect(player.plays, 2);
logic.dispose();
await _drain(logic);
});
test('should_stop_playing_without_losing_intent_when_route_becomes_inactive', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
final logic = _logic(FakeWatchHistory());
final start = PlayerPage(drama: drama, initialIndex: 0);
logic.attach(start, isMounted: () => true);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
final player = factory.created.first;
expect(player.plays, 1);
logic.onPageUpdated(start);
await _drain(logic);
expect(player.plays, 1);
logic.attach(
PlayerPage(drama: drama, initialIndex: 0, active: false),
isMounted: () => true,
);
logic.onPageUpdated(start);
await _drain(logic);
expect(player.pauses, 1);
expect(logic.session.userWantsPlay, isTrue);
logic.dispose();
await _drain(logic);
});
});
group('dispose', () {
test('should_release_every_player_when_dispose_runs_twice', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
expect(factory.live, greaterThan(0));
logic.dispose();
await _drain(logic);
expect(factory.live, 0);
expect(() => logic.dispose(), returnsNormally);
});
test('should_disable_further_notifications_when_disposed', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
var notifications = 0;
logic.addListener(() => notifications++);
logic.dispose();
await _drain(logic);
logic.notify();
expect(notifications, 0);
});
});
}
/// Counts native-style allocations so leaks are visible to a test.
class _RecordingFactory {
final List<_RecordingPlayer> created = [];
int live = 0;
PlayerPort create(Episode episode) {
live++;
final player = _RecordingPlayer(onDispose: () => live--);
created.add(player);
return player;
}
}
class _RecordingPlayer implements PlayerPort {
_RecordingPlayer({required this.onDispose});
final void Function() onDispose;
int plays = 0;
int pauses = 0;
bool disposed = false;
@override
Future<void> initialize() async {}
@override
Future<void> play() async => plays++;
@override
Future<void> pause() async => pauses++;
@override
Future<void> dispose() async {
if (disposed) return;
disposed = true;
onDispose();
}
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
class FakeCatalogRepository implements CatalogRepository {
FakeCatalogRepository({List<Drama>? dramas}) : dramas = dramas ?? demoDramas;
final List<Drama> dramas;
@override
Future<HomeFeed> fetchHomeFeed() async => _feed();
@override
Future<HomeFeed> fetchDiscoverFeed() async => _feed();
@override
Future<Drama> fetchDrama(String dramaId) async {
return dramas.firstWhere((drama) => drama.id == dramaId);
}
@override
Future<PlayTicket> resolvePlay(String episodeId) async {
return PlayTicket(
episodeId: episodeId,
accessible: true,
accessType: PlayTicket.free,
playUrl: 'https://example.test/$episodeId.mp4',
);
}
HomeFeed _feed() => HomeFeed(
sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)],
);
}
final List<Drama> demoDramas = [
_drama(
'the-signal-demo',
'The Signal',
'Suspense · Mystery',
format: 1,
),
_drama(
'city-lights-demo',
'City Lights & Velvet Nights',
'Urban Romance',
format: 8,
),
_drama(
'vengeance-demo',
'Vengeance Rising',
'Action · Thriller',
format: 15,
),
];
Drama _drama(
String id,
String title,
String genre, {
required int format,
}) => Drama(
id: id,
title: title,
synopsis: '$title synopsis',
posterUrl: '/files/$id.png',
genre: genre,
detailLoaded: true,
episodes: [
for (var index = 0; index < 7; index++)
Episode(
id: '$id-${index + 1}',
number: index + 1,
title: 'Episode ${index + 1}',
isFree: true,
videoUrl: 'https://example.test/$format-$index.mp4',
),
],
);
+46
View File
@@ -0,0 +1,46 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
/// 假响应:[body] 会按 UTF-8 编码成字节,[statusCode] 默认 200。
class FakeHttpResponse {
FakeHttpResponse(this.body, [this.statusCode = 200]);
final String body;
final int statusCode;
}
/// dio 的测试替身:把请求交给回调处理,全程不联网。
///
/// 顺带记录每个收到的请求,测试可以直接断言"发了几次、带了什么头"。
class FakeHttpAdapter implements HttpClientAdapter {
FakeHttpAdapter(this.handler);
final Future<FakeHttpResponse> Function(RequestOptions options) handler;
/// 按顺序记录收到的请求,用于断言请求次数与请求头。
final List<RequestOptions> requests = [];
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
requests.add(options);
final response = await handler(options);
return ResponseBody.fromBytes(
utf8.encode(response.body),
response.statusCode,
headers: const {
'content-type': ['application/json; charset=utf-8'],
},
);
}
@override
void close({bool force = false}) {}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
/// Records every history interaction so tests can assert on the key it used.
class FakeWatchHistory implements WatchHistoryRepository {
final List<String> reads = [];
final List<String> writes = [];
/// Position returned by [positionFor] when [failReads] is false.
Duration? saved;
/// Last episode returned by [lastEpisodeId].
String? lastEpisode;
bool failReads = false;
bool failWrites = false;
@override
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
) async {
reads.add('$profileId|$dramaId|$episodeId');
if (failReads) throw StateError('history unavailable');
return saved;
}
@override
Future<String?> lastEpisodeId(String profileId, String dramaId) async {
if (failReads) throw StateError('history unavailable');
return lastEpisode;
}
@override
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
) async {
if (failWrites) throw StateError('history unavailable');
writes.add('$profileId|$dramaId|$episodeId|${position.inMilliseconds}');
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import 'package:nontonki/core/data/models/drama.dart';
import 'package:nontonki/core/data/models/player_port.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/player_port.dart';
// Widget tests exercise navigation and scheduling without a native decoder.
PlayerPort createWidgetPlayer(Episode episode) => WidgetPlayer();
+155 -196
View File
@@ -1,21 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nontonki/app.dart';
import 'package:nontonki/pages/player/player_view.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/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(
'home swipe promotes next without recreating it or showing loading',
'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped',
(tester) async {
final created = <String>[];
await tester.pumpWidget(
@@ -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));
@@ -41,32 +57,32 @@ void main() {
},
);
testWidgets('immersive home opens the series detail', (tester) async {
testWidgets('should_open_series_detail_when_immersive_home_is_tapped', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
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('should_hide_tabs_and_restore_them_when_clear_screen_is_tapped', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
@@ -75,20 +91,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('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);
});
testWidgets('home quick drawer opens current series and switches root tabs', (
testWidgets('should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used', (
tester,
) async {
tester.view.physicalSize = const Size(360, 720);
@@ -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<PlayerPage>(find.byType(PlayerPage)).active, isFalse);
expect(
tester
.widget<PlayerPage>(find.byType(PlayerPage, skipOffstage: false))
.active,
isFalse,
);
expect(find.text('Quick access'), findsOneWidget);
expect(
find.descendant(
of: find.byType(Drawer),
matching: find.text('Same demo clip · free'),
matching: find.text('7 EPISODES'),
),
findsOneWidget,
);
@@ -118,20 +143,26 @@ void main() {
expect(find.text('Watch episode 1'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).active, isTrue);
expect(
tester
.widget<PlayerPage>(find.byType(PlayerPage, skipOffstage: false))
.active,
isFalse,
);
expect(find.text('Continue Watching'), findsOneWidget);
await tester.tap(find.byTooltip('Open quick menu'));
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.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,
);
});
testWidgets('interface language changes without a caption control', (
testWidgets('should_change_interface_language_when_locale_is_picked', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
@@ -140,62 +171,53 @@ 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 {
testWidgets('should_fit_compact_android_viewport_when_home_controls_are_shown', (tester) async {
tester.view.physicalSize = const Size(360, 720);
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.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);
});
testWidgets('My page fits a compact Android viewport and returns home', (
testWidgets('should_fit_compact_android_viewport_and_return_home_when_my_page_is_shown', (
tester,
) async {
tester.view.physicalSize = const Size(360, 720);
@@ -204,21 +226,22 @@ 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(
'Discover shows three mock playable dramas and one pending poster',
'should_show_three_playable_dramas_and_one_pending_poster_when_discover_is_opened',
(tester) async {
tester.view.physicalSize = const Size(360, 720);
tester.view.devicePixelRatio = 1;
@@ -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('should_stay_on_one_route_when_bottom_tabs_are_used', (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,26 +308,26 @@ 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,
);
});
testWidgets(
'home swipe changes drama, entered player swipe changes episode',
'should_change_drama_on_home_swipe_and_episode_on_player_swipe_when_swiping',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
@@ -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,69 +347,41 @@ 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('should_hide_category_navigation_when_shorts_page_is_shown', (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', (
testWidgets('should_save_comment_like_and_share_in_order_when_right_rail_is_used', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
@@ -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,78 +432,41 @@ 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('should_open_drawer_and_stay_on_home_when_system_back_is_pressed', (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', (
testWidgets('should_list_saved_dramas_across_home_feed_when_watchlist_is_opened', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
@@ -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();