Author SHA1 Message Date
Zhou Weidong 4778ce2c75 merge: 合并 K15 首页服务器设置修复 2026-10-17 23:13:07 +08:00
Zhou Weidong c4694c6f18 fix(flutter): 增加首页服务器设置并更新真机默认地址 2026-10-17 23:12:47 +08:00
Zhou Weidong 9fe62d13cb merge: 合并 K14 文档接口统计修正 2026-10-17 23:03:52 +08:00
Zhou Weidong 213c86e93c docs(client): 修正已接接口数量说明 2026-10-17 23:03:43 +08:00
Zhou Weidong 27e5286b10 merge: 合并 K14 客户端文档 2026-10-17 23:02:47 +08:00
Zhou Weidong 5a28845b5e docs(client): 重写 README 并补充任务与文件说明 2026-10-17 23:02:28 +08:00
Zhou Weidong f95325d581 merge: 合并 K13 开发服务器地址设置 2026-10-17 22:54:25 +08:00
Zhou Weidong 907562a874 feat(flutter): 增加 debug 运行时服务器地址设置 2026-10-17 22:54:07 +08:00
Zhou Weidong b76228905c merge: 合并 K12 首页与空态视觉修订 2026-10-17 22:44:40 +08:00
Zhou Weidong 2dd4627bdc feat(flutter): 精简首页标题并重做空态视觉 2026-10-17 22:44:21 +08:00
Zhou Weidong b5a8656ff0 merge: 合并 K11 首页三列网格 2026-10-17 22:34:38 +08:00
Zhou Weidong f0db631fa2 feat(flutter): 首页网格改为三列并首位显示全部 2026-10-17 22:34:14 +08:00
Zhou Weidong 0466b4b425 merge: 合并 K10 统一空态组件 2026-10-17 17:44:58 +08:00
Zhou Weidong 6d08330388 feat(flutter): 统一空列表占位组件 2026-10-17 17:44:44 +08:00
Zhou Weidong 19babb8f4a merge: 合并 iOS shared_preferences 编译修复 2026-10-17 17:30:50 +08:00
Zhou Weidong 2c30f83ab3 chore(ios): 固定 shared_preferences_foundation 2.5.6 2026-10-17 17:30:33 +08:00
Zhou Weidong a4415c872d chore(deps): 同步依赖锁文件 2026-10-17 17:27:07 +08:00
Zhou Weidong 2c381be5e8 merge: 合并 K9 首页全部剧目网格 2026-10-17 17:24:58 +08:00
Zhou Weidong cf03178cc5 feat(flutter): 首页改为两列全部剧目网格 2026-10-17 17:24:45 +08:00
Zhou Weidong 99f64ab870 merge: 合并 K8 登录页视觉重设计 2026-10-17 17:15:09 +08:00
Zhou Weidong 45c3c645ff feat(flutter): 重设计登录页视觉 2026-10-17 17:14:56 +08:00
Zhou Weidong 6cc5bfc3be merge: 合并首页轮播与观看历史真机修复 2026-10-17 17:00:20 +08:00
Zhou Weidong 5058853af3 fix(flutter): 注册目录仓库并修正首页轮播数据源 2026-10-17 17:00:07 +08:00
Zhou Weidong 3e8703b840 merge: 合并 K7 轮播与站内通知 2026-10-17 16:50:38 +08:00
Zhou Weidong b870956665 feat(flutter): 增加轮播自动播放与站内通知 2026-10-17 16:50:25 +08:00
Zhou Weidong b7b1c50427 merge: 合并 K6 导航与观看历史缺陷修复 2026-10-17 16:41:35 +08:00
Zhou Weidong 732483465c fix(flutter): 修复导航白线与观看记录错误透传 2026-10-17 16:41:24 +08:00
Zhou Weidong ac706a42a5 merge: 合并 K6 iOS SPM 构建修复 2026-10-17 16:32:22 +08:00
Zhou Weidong cc037a2956 fix(ios): 刷新 Flutter SPM 包后再准备框架 2026-10-17 16:32:11 +08:00
Zhou Weidong 0cedaab795 merge: 合并 K6 登录导航与震动反馈 2026-10-17 16:16:07 +08:00
Zhou Weidong 8a481d5c62 feat(flutter): 增加关键操作震动反馈 2026-10-17 16:15:46 +08:00
Zhou Weidong e609e3ad8d feat(flutter): 优化登录页与底部导航 2026-10-17 16:11:01 +08:00
Zhou Weidong e52d81881c merge: 合并 K5 克莱因蓝主题与印尼语适配 2026-10-17 15:58:29 +08:00
Zhou Weidong 10d448c8ba feat(flutter): 应用克莱因蓝主题与印尼语适配 2026-10-17 15:58:17 +08:00
Zhou Weidong e1147cfcba merge: 合并 K3 举报错误透传修复 2026-10-17 15:29:27 +08:00
Zhou Weidong 1488ee8f85 fix(flutter): 透传举报本人评论错误 2026-10-17 15:29:18 +08:00
Zhou Weidong 9db6935308 merge: 合并 K3 客户端互动能力 2026-10-17 15:26:51 +08:00
Zhou Weidong d361034226 feat(flutter): 接入互动评论点赞举报 2026-10-17 15:26:42 +08:00
Zhou Weidong 45f5f25640 merge: 合并 K2 客户端分类记录收藏与下载授权 2026-10-17 14:49:36 +08:00
Zhou Weidong cc4d7c5a9d feat(flutter): 接入分类记录收藏与下载授权 2026-10-17 14:49:16 +08:00
jianghaifeng 00280ca0da merge: 同步 main 的作者标识提交到 develop
main 的 eaf8fb4 机械性地给全仓加了 // Author 头,develop 侧包名已改 fruitshorts、
并删除了 demo_media/home_feed_category/home_feed_sections 三个文件,故按 develop 口径解决冲突:
保留 fruitshorts 包名与删除结果,补回 Author 头。
2026-10-17 13:48:31 +08:00
Vicky 84a1f58a0a test(client): 新增设备侧集成测试(真机跑通后端可达性)
红线第 10 条(设备验证必须设备侧取证)配套:integration_test/app_backend_test.dart
在手机进程里真实请求 /app/home/feed 与 /app/dramas/{id},并用真 App 断言首页剧目列表能渲染。
把 API_BASE_URL 打进设备日志,便于区分地址不对与设备连不上。

注意:真机执行由用户负责(用户 20:45 指令),本文件按原样交付、不再改动。
本项目 iOS 用 SPM,无 Podfile,不需要 pod install。
2026-10-16 18:05:58 +08:00
Vicky d7eff4ca59 feat(client): 对接详情/播放/进度续看/收藏(K1 看剧闭环)+ 三态 UI
后端接口从 6 个扩到 13 个,全部走真数据:

- 新增 api:playback_api(/app/episodes/{id}/play)、watch_api(/app/watch/*)、
  library_api(/app/favorites*);票据从 CatalogApi 搬到 PlaybackApi
- 新增 repository:PlaybackRepository(含票据 TTL 缓存,默认 10 分钟,时钟可注入)、
  FavoriteRepository、WatchHistoryRepository 换成服务端模型(每剧一条续看记录,删掉 demoProfileId)
- PlayerLogic:起播前取票(拿不到给三态 UI,失败可重试);accessType 驱动锁定文案,
  不在前端猜;签名地址原样交给播放器(只补 host,不动 path/query);
  进度上报节流 5s → 10s,切集/退出/切后台各补一次,播完带 finished
- 续看定位:服务端记录里的那一集才 seek(其它集从头播),续看位置进详情/播放页时读一次
- FavoriteController:收藏以服务端为准,乐观更新 + 失败回滚;退出登录清空
- UI 三态:详情页(加载/失败可重试/空剧集 + 续看进度条 + 收藏按钮)、
  播放页(票据加载中/失败可重试/后端判定不可播)、我的页(续看失败可重试、收藏三态、最近观看)
- l10n 新增 8 个键(en/zh/id 三份):网络/进度/收藏失败、锁定提示、续看文案等

测试:flutter test 188 全过(存量 27 个测试文件未跑红);新增票据过期重取、进度节流、
续看定位、收藏回滚、签名 query 原样保留、真实后端端到端联调(live_watch_flow)
2026-10-16 17:39:05 +08:00
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
208 changed files with 23118 additions and 4373 deletions

No files matched your search

+149 -11
View File
@@ -1,17 +1,155 @@
一个使用 Flutter 开发的短剧播放 App 原型,面向海外用户,重点探索沉浸式刷剧、找剧和连续观看的移动端体验
# FruitShorts(ReelShort)客户端
## 主要功能
FruitShorts 是面向印尼市场的短剧 Flutter 客户端。当前版本已连接
`FruitServer` 的 `/app/**` 接口,支持沉浸播放、找剧、账号、观看记录、收藏、
下载授权、评论互动、站内通知和中英印尼三语界面。
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
## 技术栈
## 技术实现
- Flutter 3.47.7 / Dart 3.13.5
- Provider 状态管理与依赖注入
- dio 网络请求
- video_player 播放
- shared_preferences 与 `SharedPreferencesAsync` 本地配置
- flutter_localizations,支持 `en`、`zh`、`id`
- Android 系统画中画
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
## 目录结构
## 当前说明
```text
lib/
core/
config/ 地址、语言和运行时配置
data/ models、remote API、repository、controller
formatting/ 文案与日期格式化
platform/ 触觉等平台能力
theme/ 颜色与主题
widgets/ 跨页面复用组件
l10n/ 三语 ARB 与生成代码
pages/ 业务页面
test/ 单元、组件、页面和联调测试
integration_test/ 设备侧集成测试
android/ Android 工程
ios/ iOS 工程与 SPM 配置
assets/ 图标、图片和本地媒体文件
```
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
## 已接接口
以下接口均在 `lib/core/data/remote/` 中有实际调用,共 26 个不同路径、
31 个 HTTP 方法级调用。
| 分组 | 契约 | 接口 |
| --- | --- | --- |
| 账号 | §1.1 | `POST /app/auth/register`、`/login`、`/refresh`、`/logout` |
| 内容 | §1.3、§1.4 | `GET /app/home/feed`、`/app/discover/feed`、`/app/categories`、`/app/dramas/{id}`、`/app/episodes/{id}/play` |
| 观看记录 | §1.5 | `POST/GET /app/watch/progress`、`GET /app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress` |
| 收藏 | §1.5 | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` |
| 下载授权 | §1.6 | `GET /app/downloads/policy`、`PUT /app/downloads/settings`、`POST/GET /app/downloads`、`GET /app/downloads/{episodeId}/url`、`POST /app/downloads/{episodeId}/progress`、`DELETE /app/downloads/{episodeId}`、`DELETE /app/downloads` |
| 互动 | §1.7 | `GET/POST /app/comments`、`DELETE /app/comments/{id}`、`POST /app/likes/toggle`、`POST /app/reports` |
| 公告 | §1.9.2 | `GET /app/notices` |
客户端没有调用 `/app/config` 或创作者入驻接口。
## 运行
### 安装依赖
```bash
flutter pub get
```
### Android USB
手机开启 USB 调试并连接电脑后:
```bash
adb devices
adb reverse tcp:8081 tcp:8081
flutter run -d <device> \
--dart-define=API_BASE_URL=http://127.0.0.1:8081
```
`adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要
重新执行。
Android 模拟器访问宿主机时使用:
```bash
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8081
```
### iOS
手机与 Mac 需在同一网络,首次启动允许“本地网络”权限:
```bash
flutter run -d <udid> \
--dart-define=API_BASE_URL=http://<Mac局域网IP>:8081
```
### Debug 运行时地址
debug 版本可在「我的 → 设置 → 服务器地址」修改后端地址,保存后重启 App 生效。
地址优先级:
```text
本地运行时设置 > --dart-define(API_BASE_URL) > 平台默认地址
```
因此不带 `--dart-define` 构建的 debug APK 也可在安装后改成当前电脑 IP。
release 版本不显示入口,也不会读取本地覆盖。
## 环境注意
### iOS Swift 版本
`pubspec.yaml` 将 `shared_preferences_foundation` 固定为 `2.5.6`。该版本兼容
Xcode 16.2 / Swift 6.0.3;`2.6.0` 的生成代码使用 Swift 6.1 才支持的 trailing
comma。不要手工升级该依赖,`flutter pub get` 后应显示
`shared_preferences_foundation 2.5.6 (overridden)`。
### iOS SPM 包缺失
如果 Xcode 报 `Missing package product FlutterGeneratedPluginSwiftPackage`:
```bash
rm -rf ~/Library/Developer/Xcode/DerivedData/Runner-*
flutter clean
flutter pub get
xcodebuild -resolvePackageDependencies \
-project ios/Runner.xcodeproj \
-scheme Runner
```
随后重新打开工程构建。`Runner.xcscheme` 的 PreAction 会先执行
`flutter pub get`,再执行 Flutter prepare。
### Android libdartjni
若启动时报 `libdartjni.so` 加载失败,通常是旧安装中的原生库残留:
```bash
adb uninstall com.stormg.lumadrama
flutter run -d <device> \
--dart-define=API_BASE_URL=http://127.0.0.1:8081
```
## 门禁
```bash
dart analyze lib test
flutter test
flutter build apk --debug
```
测试文件按模块放在 `test/` 下,用例名使用
`should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。
## 当前边界
- 下载仅管理服务端授权,不落盘、不续传、不支持离线播放。
- 没有独立搜索接口,搜索能力尚未形成完整闭环。
- 站内通知没有已读/未读和红点状态。
- 支付、金币和会员权益尚未形成客户端闭环。
- Android 支持系统画中画;iOS 画中画尚未接入。
-3
View File
@@ -32,9 +32,6 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// 反编译保护(法律红线配套):开启 R8 混淆 + 资源压缩,发布前替换正式签名。
isMinifyEnabled = true
isShrinkResources = true
}
}
}
@@ -6,4 +6,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,8 +1,9 @@
<!-- Author: fruit.com.cn -->
<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
@@ -6,4 +6,6 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
<application android:usesCleartextTraffic="true"/>
</manifest>
+99
View File
@@ -0,0 +1,99 @@
# 客户端文件说明
## 目录树
```text
.
├── android/ Android 工程与 Gradle 配置
├── assets/
│ ├── icon/ 启动图标
│ ├── images/ 本地图片
│ └── media/ 本地视频文件(当前未在 pubspec 声明)
├── integration_test/ 设备侧集成测试
├── ios/
│ ├── Flutter/ Flutter 生成配置与 SPM 临时包
│ ├── Runner/ iOS Runner
│ ├── Runner.xcodeproj/ Xcode 工程与共享 Scheme
│ └── RunnerTests/ iOS 原生测试
├── lib/
│ ├── core/
│ │ ├── config/ 地址、语言、运行时服务器设置
│ │ ├── data/ models、remote、repositories、services
│ │ ├── formatting/ 多语言文案、日期和错误文本
│ │ ├── platform/ 平台能力封装
│ │ ├── theme/ 颜色与 ThemeData
│ │ └── widgets/ 通用组件
│ ├── l10n/ ARB 与本地化生成代码
│ └── pages/ 业务页面
├── test/ 单元和 Widget 测试
├── docs/ 客户端任务与文件说明
├── pubspec.yaml 依赖、资源和 Flutter 配置
└── README.md 运行、接口与环境说明
```
## 关键文件
| 路径 | 职责 |
| --- | --- |
| `lib/main.dart` | 初始化 Flutter、屏幕方向、debug 服务器覆盖并启动 App |
| `lib/app.dart` | Provider、repository、controller、ApiClient 和主题装配 |
| `lib/core/config/app_config.dart` | 地址优先级、超时、媒体 URL 和平台默认地址 |
| `lib/core/config/server_endpoint.dart` | debug 运行时地址规范化、读取、保存和清除 |
| `lib/core/config/locale_controller.dart` | 语言选择与系统语言回退 |
| `lib/core/data/models/` | 账号、剧目、评论、观看、下载、公告等数据结构 |
| `lib/core/data/remote/` | `/app/**` 接口调用;`api_client.dart` 统一鉴权和错误映射 |
| `lib/core/data/repositories/` | HTTP 与内存实现的 repository 边界 |
| `lib/core/data/services/` | 页面 controller、播放会话、分享、画中画等业务服务 |
| `lib/core/formatting/` | 本地化内容、日期与错误文本 |
| `lib/core/platform/haptics.dart` | 可注入的触觉反馈封装 |
| `lib/core/theme/app_colors.dart` | 克莱因蓝和界面颜色 token |
| `lib/core/theme/app_theme.dart` | light/dark ColorScheme 与组件主题 |
| `lib/core/widgets/empty_state.dart` | 统一空列表占位 |
| `lib/core/widgets/remote_image.dart` | 后端相对图片地址与失败占位 |
| `lib/core/widgets/auth_gate.dart` | 未登录操作拦截 |
| `lib/core/widgets/download_action.dart` | 下载授权操作 |
| `lib/pages/home/` | 根页面、四个底部 tab 和抽屉 |
| `lib/pages/player/` | 竖屏播放、选集、进度和播放器交互 |
| `lib/pages/discover/` | 首页/发现信息流、轮播、分类与三列剧目网格 |
| `lib/pages/drama_detail/` | 剧目详情与剧集入口 |
| `lib/pages/auth/` | 登录/注册页面与表单逻辑 |
| `lib/pages/mine/` | 我的页、片单、设置、语言与 debug 地址入口 |
| `lib/pages/favorites/` | 收藏列表 |
| `lib/pages/watch_records/` | 独立观看记录页 |
| `lib/pages/downloads/` | 下载授权与策略页面 |
| `lib/pages/notices/` | 站内通知分页和详情 |
| `lib/pages/interaction/` | 举报弹窗 |
| `lib/l10n/*.arb` | en/zh/id 源文案,修改后执行 `flutter gen-l10n` |
| `ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme` | iOS SPM 预构建脚本 |
## 测试结构
| 目录 | 覆盖范围 |
| --- | --- |
| `test/auth/` | 登录 controller、表单、视觉与交互 |
| `test/catalog/`、`test/category/` | 首页数据、分类和模型解析 |
| `test/comment/`、`test/interaction/` | 评论、点赞、举报和弹窗 |
| `test/config/` | 三语、系统语言和运行时服务器地址 |
| `test/core/` | ApiClient、ID 解析、触觉、分享和画中画 |
| `test/discover/`、`test/home/` | 首页、轮播、网格和导航 |
| `test/download/`、`test/library/`、`test/watch/` | 下载、收藏、观看记录 |
| `test/drama_detail/`、`test/player/`、`test/playback/` | 详情、播放器与播放队列 |
| `test/notice/` | 通知分页、空态、详情与入口 |
| `test/empty_state/`、`test/theme/` | 空态组件与主题 token |
| `test/integration/` | 依赖本机后端的联调测试,服务未启动时跳过 |
| `integration_test/app_backend_test.dart` | 设备侧后端连通性测试 |
| `test/support/` | fake repository、HTTP adapter、播放器和测试数据 |
## 生成物
以下内容由工具生成,不要手工修改:
- `lib/l10n/app_localizations*.dart`
- `*.g.dart`
- `.dart_tool/`
- `build/`
- `ios/Flutter/ephemeral/`
- `ios/Runner/GeneratedPluginRegistrant.h`
- `ios/Runner/GeneratedPluginRegistrant.m`
`pubspec.lock` 由 Dart 包管理工具维护,应提交但不手工编辑。
+38
View File
@@ -0,0 +1,38 @@
# 客户端任务清单
状态按当前 `develop` 中已合入的功能整理。契约章节以
`FruitServer/docs/api-phase1.md` 为准;任务过程记录见
`FruitServer/docs/plans/PROGRESS.md`。
## K1-K13
| 批次 | 范围 | 状态 | 关键提交或契约章节 | 备注 |
| --- | --- | --- | --- | --- |
| K1 | 详情、播放票据、观看进度、续看、收藏 | 已完成并合入 | §1.3-§1.5 | 已有三态和基础页面 |
| K2 | 分类、观看记录、收藏列表、下载授权管理 | 已完成并合入 | §1.2、§1.5、§1.6 | 下载只管理授权,不落盘 |
| K3 | 评论、回复、点赞、举报、分享 | 已完成并合入 | §1.7 | 分享使用系统分享面板 |
| K4 | 账号剩余能力 | 未开始 | §1.1 | Google 登录所需凭据和契约范围需核对 |
| K5 | 克莱因蓝主题、印尼语适配 | 已完成并合入 | 无接口变更 | 三语为 en/zh/id |
| K6 | 登录页、底部导航、触觉反馈 | 已完成并合入 | 无接口变更 | 触觉通过 `Haptics` 封装 |
| K7 | 首页轮播、站内通知 | 已完成并合入 | §1.3、§1.9.2 | 通知无已读态 |
| K8 | 登录页视觉重设计 | 已完成并合入 | 无接口变更 | 保留原有登录逻辑和校验 |
| K9 | 首页全部剧目双列网格 | 已完成并合入 | 无接口变更 | 数据源为 feed 分类结果 |
| K10 | 统一空列表占位 | 已完成并合入 | 无接口变更 | 评论弹窗使用 compact 变体 |
| K11 | 首页三列、分类首位“全部” | 已完成并合入 | 无接口变更 | 网格比例保持 2:3 |
| K12 | 首页去标题、空态视觉重做 | 已完成并合入 | 无接口变更 | 双层同心圆空态 |
| K13 | debug 运行时服务器地址 | 已完成并合入 | 无接口变更 | 保存后重启生效,release 不开放 |
## 待办
| 事项 | 状态 | 依赖或说明 |
| --- | --- | --- |
| K4 账号剩余能力 | 待办 | Google 登录需要客户端凭据、平台配置和后端契约,范围需核对 |
| B1 支付与金币 | 待办 | 客户端尚未接入 §1.8 的支付/权益闭环,商品、渠道和验证方式需核对 |
| 搜索 | 待办 | 当前没有独立搜索接口,接口契约和数据范围需核对 |
| 下载落盘 | 待办 | 需要新增文件下载、进度持久化、续传、离线播放和本地清理设计 |
## 维护规则
- 新批次完成后更新本表,不写未经确认的提交号。
- 功能状态以当前 `develop` 代码和自动化测试为准。
- “需核对”表示产品范围、接口或外部凭据尚未确认,不能按已完成处理。
+74
View File
@@ -0,0 +1,74 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
import 'package:integration_test/integration_test.dart';
/// 设备侧集成测试:**在手机进程里**真实请求开发机上的 app-api。
///
/// 跑法(红线第 10 条要求的"设备侧取证"):
/// ```
/// IP=$(ipconfig getifaddr en0)
/// flutter test integration_test -d <device-id> --dart-define=API_BASE_URL=http://$IP:8081
/// ```
/// 断言失败时会把实际使用的地址一起打出来,便于区分"地址不对"还是"设备连不上"。
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final baseUrl = AppConfig.apiBaseUrl;
testWidgets('should_fetch_home_feed_and_drama_detail_when_device_reaches_backend', (
tester,
) async {
debugPrint('[device] API_BASE_URL=$baseUrl');
final client = ApiClient(baseUrl: baseUrl);
addTearDown(client.close);
final api = CatalogApi(client);
final feed = await api.homeFeed();
expect(
feed.dramas,
isNotEmpty,
reason: '设备侧拿不到首页列表(API_BASE_URL=$baseUrl)',
);
debugPrint('[device] 首页分区=${feed.sections.length} 剧目=${feed.dramas.length}');
final card = feed.dramas.first;
expect(card.id, isNotEmpty, reason: '设备侧首页卡片 id 为空');
final detail = await api.dramaDetail(card.id);
expect(detail.id, card.id, reason: '详情返回的 id 与卡片不一致');
expect(
detail.episodes,
isNotEmpty,
reason: '设备侧拿到的详情没有剧集(dramaId=${card.id})',
);
debugPrint('[device] 详情 id=${detail.id} 剧集=${detail.episodes.length}');
});
testWidgets('should_render_home_drama_list_when_app_boots_on_device', (
tester,
) async {
// 用真 App(真网络、真目录控制器):首页底部标签栏出现即代表"剧目列表加载成功",
// 也就是用户在真机上遇到的那个「剧目列表加载失败」已经消失。
await tester.pumpWidget(const LumaDramaApp());
var loaded = false;
for (var attempt = 0; attempt < 60 && !loaded; attempt++) {
await tester.pump(const Duration(milliseconds: 500));
loaded = find.byKey(const Key('home-tabs-bar')).evaluate().isNotEmpty;
}
expect(
loaded,
isTrue,
reason: '首页 30 秒内没渲染出剧目列表(API_BASE_URL=$baseUrl)',
);
debugPrint('[device] 首页剧目列表已渲染:API_BASE_URL=$baseUrl');
});
}
-1
View File
@@ -1,2 +1 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
-1
View File
@@ -1,2 +1 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
-43
View File
@@ -1,43 +0,0 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+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;
@@ -6,11 +6,27 @@
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Bootstrap Flutter SPM Package"
scriptText = "#!/bin/sh&#10;set -e&#10;FLUTTER=&quot;&quot;&#10;if [ -n &quot;${FLUTTER_ROOT:-}&quot; ] &amp;&amp; [ -x &quot;$FLUTTER_ROOT/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;$FLUTTER_ROOT/bin/flutter&quot;&#10;elif FLUTTER=&quot;$(command -v flutter)&quot;; then&#10; :&#10;elif [ -x &quot;$HOME/flutter/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;$HOME/flutter/bin/flutter&quot;&#10;elif [ -x &quot;/opt/homebrew/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;/opt/homebrew/bin/flutter&quot;&#10;fi&#10;if [ -z &quot;$FLUTTER&quot; ]; then&#10; echo &quot;error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH.&quot; &gt;&amp;2&#10; exit 1&#10;fi&#10;if [ -z &quot;${SRCROOT:-}&quot; ]; then&#10; echo &quot;error: SRCROOT is not set; cannot locate the Flutter project.&quot; &gt;&amp;2&#10; exit 1&#10;fi&#10;cd &quot;$SRCROOT/..&quot;&#10;&quot;$FLUTTER&quot; pub get&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
scriptText = "#!/bin/sh&#10;set -e&#10;FLUTTER=&quot;&quot;&#10;if [ -n &quot;${FLUTTER_ROOT:-}&quot; ] &amp;&amp; [ -x &quot;$FLUTTER_ROOT/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;$FLUTTER_ROOT/bin/flutter&quot;&#10;elif FLUTTER=&quot;$(command -v flutter)&quot;; then&#10; :&#10;elif [ -x &quot;$HOME/flutter/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;$HOME/flutter/bin/flutter&quot;&#10;elif [ -x &quot;/opt/homebrew/bin/flutter&quot; ]; then&#10; FLUTTER=&quot;/opt/homebrew/bin/flutter&quot;&#10;fi&#10;if [ -z &quot;$FLUTTER&quot; ]; then&#10; echo &quot;error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH.&quot; &gt;&amp;2&#10; exit 1&#10;fi&#10;FLUTTER_ROOT=&quot;$(cd &quot;$(dirname &quot;$FLUTTER&quot;)/..&quot; &amp;&amp; pwd -P)&quot;&#10;if [ -z &quot;${SRCROOT:-}&quot; ]; then&#10; echo &quot;error: SRCROOT is not set; cannot locate the Flutter project.&quot; &gt;&amp;2&#10; exit 1&#10;fi&#10;cd &quot;$SRCROOT&quot;&#10;&quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
+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>
+183 -60
View File
@@ -1,63 +1,205 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/config/locale_controller.dart';
import 'core/data/repositories/auth_repository.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/remote/category_api.dart';
import 'core/data/remote/comment_api.dart';
import 'core/data/remote/download_api.dart';
import 'core/data/remote/interaction_api.dart';
import 'core/data/remote/library_api.dart';
import 'core/data/remote/notice_api.dart';
import 'core/data/remote/playback_api.dart';
import 'core/data/remote/watch_api.dart';
import 'core/data/repositories/catalog_repository.dart';
import 'core/data/repositories/engagement_repository.dart';
import 'core/data/repositories/http_catalog_repository.dart';
import 'core/data/services/api_client.dart';
import 'core/data/services/app_catalog_controller.dart';
import 'core/data/repositories/category_repository.dart';
import 'core/data/repositories/comment_repository.dart';
import 'core/data/repositories/download_repository.dart';
import 'core/data/repositories/favorite_repository.dart';
import 'core/data/repositories/interaction_repository.dart';
import 'core/data/repositories/notice_repository.dart';
import 'core/data/repositories/playback_repository.dart';
import 'core/data/repositories/watch_history_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/event_tracker.dart';
import 'core/data/services/category_controller.dart';
import 'core/data/services/download_controller.dart';
import 'core/data/services/favorite_controller.dart';
import 'core/data/services/interaction_controller.dart';
import 'core/data/services/playback_session.dart';
import 'core/data/services/token_storage.dart';
import 'core/data/services/video_player_port.dart';
import 'core/platform/haptics.dart';
import 'core/theme/app_theme.dart';
import 'l10n/app_localizations.dart';
import 'pages/login/login_view.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,
this.categoryRepository,
this.downloadRepository,
this.watchHistoryRepository,
this.favoriteRepository,
this.playbackRepository,
this.commentRepository,
this.interactionRepository,
this.noticeRepository,
this.haptics = const FlutterHaptics(),
});
final PlayerFactory playerFactory;
/// 测试注入用;生产走 HTTP 仓库。
final CatalogRepository? catalogRepository;
final CategoryRepository? categoryRepository;
final DownloadRepository? downloadRepository;
final WatchHistoryRepository? watchHistoryRepository;
final FavoriteRepository? favoriteRepository;
final PlaybackRepository? playbackRepository;
final CommentRepository? commentRepository;
final InteractionRepository? interactionRepository;
final NoticeRepository? noticeRepository;
final Haptics haptics;
@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,
);
late final CatalogRepository _catalogRepository =
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient));
late final CategoryRepository _categoryRepository =
widget.categoryRepository ??
HttpCategoryRepository(CategoryApi(_apiClient), _catalogRepository);
late final WatchHistoryRepository _watchHistory =
widget.watchHistoryRepository ??
HttpWatchHistoryRepository(
WatchApi(_apiClient),
isSignedIn: () => _auth.isSignedIn,
);
late final FavoriteRepository _favorites =
widget.favoriteRepository ??
HttpFavoriteRepository(
LibraryApi(_apiClient),
isSignedIn: () => _auth.isSignedIn,
);
late final PlaybackRepository _playback =
widget.playbackRepository ??
HttpPlaybackRepository(PlaybackApi(_apiClient));
late final FavoriteController _favoriteController = FavoriteController(
_favorites,
);
late final DownloadRepository _downloadRepository =
widget.downloadRepository ??
HttpDownloadRepository(DownloadApi(_apiClient));
late final DownloadController _downloadController = DownloadController(
_downloadRepository,
);
late final CommentRepository _commentRepository =
widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient));
late final InteractionRepository _interactionRepository =
widget.interactionRepository ??
HttpInteractionRepository(InteractionApi(_apiClient));
late final NoticeRepository _noticeRepository =
widget.noticeRepository ?? HttpNoticeRepository(NoticeApi(_apiClient));
late final InteractionController _interactionController =
InteractionController(_interactionRepository);
@override
void initState() {
super.initState();
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
_auth.load();
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
_auth.addListener(_onAuthChanged);
if (_auth.isSignedIn) unawaited(_favoriteController.load());
}
bool _wasSignedIn = false;
void _onAuthChanged() {
if (_auth.isSignedIn == _wasSignedIn) return;
_wasSignedIn = _auth.isSignedIn;
_interactionController.clear();
if (_auth.isSignedIn) {
unawaited(_favoriteController.load());
} else {
_favoriteController.clear();
_downloadController.reset();
}
}
@override
void dispose() {
_auth.removeListener(_onAuthChanged);
_favoriteController.dispose();
_downloadController.dispose();
_interactionController.dispose();
_apiClient.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider(create: (_) => PlaybackCoordinator()),
Provider<PlayerFactory>(create: (_) => playerFactory),
Provider(create: (_) => ApiClient()),
Provider(create: (ctx) => EventTracker(ctx.read<ApiClient>())),
Provider(create: (_) => TokenStorage()),
Provider(
create: (ctx) => AuthRepository(
ctx.read<ApiClient>(),
ctx.read<TokenStorage>(),
),
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
Provider<CatalogRepository>.value(value: _catalogRepository),
Provider<PlaybackRepository>.value(value: _playback),
Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites),
Provider<CommentRepository>.value(value: _commentRepository),
Provider<InteractionRepository>.value(value: _interactionRepository),
Provider<NoticeRepository>.value(value: _noticeRepository),
Provider<Haptics>.value(value: widget.haptics),
ChangeNotifierProvider(
create: (_) => CatalogController(_catalogRepository)..load(),
),
ChangeNotifierProvider(
create: (ctx) => AuthController(ctx.read<AuthRepository>()),
),
Provider(
create: (ctx) => HttpCatalogRepository(ctx.read<ApiClient>()),
),
ChangeNotifierProvider(
create: (ctx) => AppCatalogController(ctx.read<HttpCatalogRepository>()),
),
ChangeNotifierProvider(
create: (_) => CatalogController(DemoCatalogRepository())..load(),
create: (_) => CategoryController(_categoryRepository)..load(),
),
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
ChangeNotifierProvider(
create: (_) =>
EngagementController(SharedPreferencesEngagementRepository())
..load(),
),
ChangeNotifierProvider.value(value: _favoriteController),
ChangeNotifierProvider.value(value: _downloadController),
ChangeNotifierProvider.value(value: _interactionController),
ChangeNotifierProvider.value(value: _auth),
],
child: Consumer<LocaleController>(
builder: (context, locale, _) => MaterialApp(
@@ -65,32 +207,13 @@ class LumaDramaApp extends StatelessWidget {
locale: locale.locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
localeResolutionCallback: (deviceLocale, supported) {
final resolved = locale.locale ?? deviceLocale;
if (resolved == null) return supported.first;
for (final item in supported) {
if (item.languageCode == resolved.languageCode) return item;
}
return supported.first;
},
localeResolutionCallback: (deviceLocale, _) =>
resolveAppLocale(locale.locale, deviceLocale),
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFFF5B3D),
),
scaffoldBackgroundColor: const Color(0xFF0B0C10),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFFF5B3D),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF0B0C10),
),
home: const LoginPage(),
themeMode: ThemeMode.system,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: const HomePage(),
),
),
);
-15
View File
@@ -1,15 +0,0 @@
// Author: fruit.com.cn
/// 接口地址与超时配置。
class ApiConfig {
ApiConfig._();
/// 客户端 app-api 地址,可用 --dart-define=API_BASE_URL=... 覆盖。
static const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://127.0.0.1:8081',
);
static const int connectTimeoutSeconds = 10;
static const int receiveTimeoutSeconds = 15;
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
/// 客户端与后端约定的运行时配置。
///
/// 接口地址优先级:运行时设置 > `--dart-define=API_BASE_URL=...` > 平台默认值。
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。
class AppConfig {
const AppConfig._();
/// 真机默认使用的开发机局域网地址。
///
/// 这个地址跟着电脑所在的网络走,换网络后要用
/// `ipconfig getifaddr en0` 重新确认。默认值只改这一行。
static const String devHostBaseUrl = 'http://10.157.242.1:8081';
static const String _loopbackBaseUrl = 'http://127.0.0.1:8081';
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
static String _runtimeBaseUrl = '';
/// Build-time server URL supplied through `--dart-define=API_BASE_URL`.
static String get compiledBaseUrl => _overrideBaseUrl;
/// Existing platform-aware fallback used when no runtime or build override exists.
static String get defaultBaseUrl => _defaultBaseUrl;
/// Effective server URL, including the debug-only runtime override.
static String get apiBaseUrl => _runtimeBaseUrl.isNotEmpty
? _runtimeBaseUrl
: (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : 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'));
/// Applies an already normalized runtime URL. Passing null clears it.
static void applyRuntimeBaseUrl(String? value) {
_runtimeBaseUrl = value ?? '';
}
/// Clears the in-memory runtime override, mainly for tests and restarts.
static void clearRuntimeBaseUrl() {
_runtimeBaseUrl = '';
}
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';
}
}
+15
View File
@@ -3,6 +3,19 @@
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Resolves the selected or system language to one of the supported app locales.
///
/// Indonesian is the launch-market fallback when a device uses an unsupported
/// language. The legacy `in` code is treated as Indonesian as well.
Locale resolveAppLocale(Locale? selected, Locale? deviceLocale) {
final code = (selected ?? deviceLocale)?.languageCode;
return switch (code) {
'zh' => const Locale('zh'),
'en' => const Locale('en'),
_ => const Locale('id'),
};
}
class LocaleController extends ChangeNotifier {
LocaleController({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
@@ -17,6 +30,7 @@ class LocaleController extends ChangeNotifier {
_locale = switch (code) {
'en' => const Locale('en'),
'zh' => const Locale('zh'),
'id' => const Locale('id'),
_ => null,
};
notifyListeners();
@@ -29,6 +43,7 @@ class LocaleController extends ChangeNotifier {
_locale = switch (code) {
'en' => const Locale('en'),
'zh' => const Locale('zh'),
'id' => const Locale('id'),
_ => null,
};
notifyListeners();
+86
View File
@@ -0,0 +1,86 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_config.dart';
/// Debug-only runtime server endpoint persisted outside build-time defines.
abstract final class ServerEndpoint {
/// Preference key used for the runtime server address.
static const String preferenceKey = 'dev.api_base_url';
/// Returns a normalized http(s) URL, or null for invalid input.
static String? normalize(String? raw) {
final value = raw?.trim() ?? '';
final uri = Uri.tryParse(value);
if (uri == null ||
(uri.scheme != 'http' && uri.scheme != 'https') ||
uri.host.isEmpty) {
return null;
}
return value.replaceFirst(RegExp(r'/+$'), '');
}
/// Resolves runtime > dart-define > default using normalized valid values.
static String resolve({
required String? stored,
required String compiled,
required String fallback,
}) {
return normalize(stored) ??
normalize(compiled) ??
normalize(fallback) ??
fallback;
}
/// Loads the persisted override before the app creates its ApiClient.
///
/// In release builds this returns immediately without reading preferences.
static Future<void> load({
bool debug = kDebugMode,
SharedPreferencesAsync? preferences,
}) async {
if (!debug) return;
try {
final prefs = preferences ?? SharedPreferencesAsync();
final stored = await prefs.getString(preferenceKey);
AppConfig.applyRuntimeBaseUrl(
resolve(
stored: stored,
compiled: AppConfig.compiledBaseUrl,
fallback: AppConfig.defaultBaseUrl,
),
);
} catch (_) {
AppConfig.clearRuntimeBaseUrl();
}
}
/// Persists a normalized runtime endpoint.
static Future<bool> save(
String raw, {
SharedPreferencesAsync? preferences,
}) async {
final normalized = normalize(raw);
if (normalized == null) return false;
try {
await (preferences ?? SharedPreferencesAsync()).setString(
preferenceKey,
normalized,
);
return true;
} catch (_) {
return false;
}
}
/// Removes the persisted runtime endpoint.
static Future<void> clear({SharedPreferencesAsync? preferences}) async {
try {
await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey);
} catch (_) {
// Clearing an optional developer override is best effort.
}
}
}
-188
View File
@@ -1,188 +0,0 @@
// Author: fruit.com.cn
// 与后端 /app/** 内容接口对齐的数据模型。
class AppEpisode {
AppEpisode({
required this.id,
required this.number,
required this.title,
required this.durationSec,
required this.locked,
required this.unlockCoin,
this.playUrl,
});
final int id;
final int number;
final String title;
final int durationSec;
final bool locked;
final int unlockCoin;
final String? playUrl;
factory AppEpisode.fromJson(Map<String, dynamic> json) => AppEpisode(
id: json['id'] as int,
number: json['number'] as int,
title: json['title'] as String? ?? '',
durationSec: json['durationSec'] as int? ?? 0,
locked: json['locked'] as bool? ?? false,
unlockCoin: json['unlockCoin'] as int? ?? 0,
playUrl: json['playUrl'] as String?,
);
}
class AppDramaCard {
AppDramaCard({
required this.id,
required this.title,
required this.cover,
required this.synopsis,
required this.genre,
required this.category,
required this.totalEpisodes,
required this.isHot,
required this.viewCount,
this.preview,
});
final int id;
final String title;
final String cover;
final String synopsis;
final String genre;
final String category;
final int totalEpisodes;
final bool isHot;
final int viewCount;
final AppEpisode? preview;
factory AppDramaCard.fromJson(Map<String, dynamic> json) => AppDramaCard(
id: json['id'] as int,
title: json['title'] as String? ?? '',
cover: json['cover'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
genre: json['genre'] as String? ?? '',
category: json['category'] as String? ?? '',
totalEpisodes: json['totalEpisodes'] as int? ?? 0,
isHot: json['isHot'] as bool? ?? false,
viewCount: json['viewCount'] as int? ?? 0,
preview: json['preview'] == null
? null
: AppEpisode.fromJson(json['preview'] as Map<String, dynamic>),
);
}
class AppDramaDetail {
AppDramaDetail({
required this.id,
required this.title,
required this.synopsis,
required this.cover,
required this.genre,
required this.category,
required this.totalEpisodes,
required this.freeEpisodes,
required this.unlockCoin,
required this.isHot,
required this.viewCount,
required this.episodes,
});
final int id;
final String title;
final String synopsis;
final String cover;
final String genre;
final String category;
final int totalEpisodes;
final int freeEpisodes;
final int unlockCoin;
final bool isHot;
final int viewCount;
final List<AppEpisode> episodes;
factory AppDramaDetail.fromJson(Map<String, dynamic> json) => AppDramaDetail(
id: json['id'] as int,
title: json['title'] as String? ?? '',
synopsis: json['synopsis'] as String? ?? '',
cover: json['cover'] as String? ?? '',
genre: json['genre'] as String? ?? '',
category: json['category'] as String? ?? '',
totalEpisodes: json['totalEpisodes'] as int? ?? 0,
freeEpisodes: json['freeEpisodes'] as int? ?? 0,
unlockCoin: json['unlockCoin'] as int? ?? 0,
isHot: json['isHot'] as bool? ?? false,
viewCount: json['viewCount'] as int? ?? 0,
episodes: (json['episodes'] as List? ?? const [])
.map((e) => AppEpisode.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
class AppBanner {
AppBanner({
required this.id,
required this.imageUrl,
required this.linkType,
required this.linkValue,
});
final int id;
final String imageUrl;
final String linkType;
final String linkValue;
factory AppBanner.fromJson(Map<String, dynamic> json) => AppBanner(
id: json['id'] as int,
imageUrl: json['imageUrl'] as String? ?? '',
linkType: json['linkType'] as String? ?? '',
linkValue: json['linkValue'] as String? ?? '',
);
}
class AppCategory {
AppCategory({required this.id, required this.name});
final int id;
final String name;
factory AppCategory.fromJson(Map<String, dynamic> json) =>
AppCategory(id: json['id'] as int, name: json['name'] as String? ?? '');
}
class AppSection {
AppSection({
required this.code,
required this.title,
required this.dramas,
});
final String code;
final String title;
final List<AppDramaCard> dramas;
factory AppSection.fromJson(Map<String, dynamic> json) => AppSection(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
dramas: (json['dramas'] as List? ?? const [])
.map((e) => AppDramaCard.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
class AppFeed {
AppFeed({required this.banners, required this.sections});
final List<AppBanner> banners;
final List<AppSection> sections;
factory AppFeed.fromJson(Map<String, dynamic> json) => AppFeed(
banners: (json['banners'] as List? ?? const [])
.map((e) => AppBanner.fromJson(e as Map<String, dynamic>))
.toList(),
sections: (json['sections'] as List? ?? const [])
.map((e) => AppSection.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
+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(),
};
}
+132
View File
@@ -0,0 +1,132 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 评论游标分页大小,契约 §1.7 使用游标而非页码。
const int commentPageSize = 20;
/// 评论列表页。
class CommentPage {
const CommentPage({
this.comments = const [],
this.nextCursor,
this.hasMore = false,
});
final List<DramaComment> comments;
final String? nextCursor;
final bool hasMore;
/// 解析后端 `CursorResult{list,nextCursor,hasMore}`。
factory CommentPage.fromJson(Map<String, dynamic> json) {
final rawList = json['list'];
return CommentPage(
comments: [
if (rawList is List)
for (final item in rawList)
if (item is Map<String, dynamic>) DramaComment.fromJson(item),
],
nextCursor: json['nextCursor'] == null
? null
: jsonToId(json['nextCursor']),
hasMore: json['hasMore'] == true,
);
}
}
/// 服务端评论视图,字段对应 `CommentRespVO`。
class DramaComment {
const DramaComment({
required this.id,
required this.dramaId,
required this.episodeId,
required this.parentId,
required this.rootId,
required this.userId,
required this.uid,
required this.nickname,
required this.avatarUrl,
required this.content,
required this.status,
required this.auditReason,
required this.likeCount,
required this.replyCount,
required this.reportCount,
required this.createdAt,
required this.mine,
required this.liked,
});
final String id;
final String dramaId;
final String episodeId;
final String parentId;
final String rootId;
final String userId;
final String uid;
final String nickname;
final String avatarUrl;
final String content;
final String status;
final String auditReason;
final int likeCount;
final int replyCount;
final int reportCount;
final DateTime? createdAt;
final bool mine;
final bool liked;
/// 待人工审核的评论仅本人可见。
bool get isPending => status == 'pending';
/// 顶层评论没有父评论。
bool get isTopLevel => parentId.isEmpty || parentId == '0';
/// 解析 `CommentRespVO`,所有 ID 字段只走字符串解析。
factory DramaComment.fromJson(Map<String, dynamic> json) => DramaComment(
id: jsonToId(json['id']),
dramaId: jsonToId(json['dramaId']),
episodeId: jsonToId(json['episodeId'], fallback: '0'),
parentId: jsonToId(json['parentId'], fallback: '0'),
rootId: jsonToId(json['rootId'], fallback: '0'),
userId: jsonToId(json['userId']),
uid: json['uid']?.toString() ?? '',
nickname: json['nickname']?.toString() ?? '',
avatarUrl: json['avatarUrl']?.toString() ?? '',
content: json['content']?.toString() ?? '',
status: json['status']?.toString() ?? '',
auditReason: json['auditReason']?.toString() ?? '',
likeCount: jsonToInt(json['likeCount']),
replyCount: jsonToInt(json['replyCount']),
reportCount: jsonToInt(json['reportCount']),
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
mine: json['mine'] == true,
liked: json['liked'] == true,
);
/// 局部更新点赞或回复数,列表中的其他字段保持不变。
DramaComment copyWith({
int? likeCount,
int? replyCount,
bool? liked,
}) => DramaComment(
id: id,
dramaId: dramaId,
episodeId: episodeId,
parentId: parentId,
rootId: rootId,
userId: userId,
uid: uid,
nickname: nickname,
avatarUrl: avatarUrl,
content: content,
status: status,
auditReason: auditReason,
likeCount: likeCount ?? this.likeCount,
replyCount: replyCount ?? this.replyCount,
reportCount: reportCount,
createdAt: createdAt,
mine: mine,
liked: liked ?? this.liked,
);
}
@@ -0,0 +1,17 @@
// Author: fruit.com.cn
/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。
class ContentCategory {
const ContentCategory({required this.id, required this.name});
final String id;
final String name;
/// 解析 `GET /app/categories` 的单项。
factory ContentCategory.fromJson(Map<String, dynamic> json) {
return ContentCategory(
id: (json['id'] ?? '').toString(),
name: json['name'] as String? ?? '',
);
}
}
@@ -0,0 +1,67 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 下载授权与任务进度合并视图,对应 `GET /app/downloads` 的列表项。
class DownloadLicense {
const DownloadLicense({
required this.licenseId,
required this.dramaId,
required this.dramaTitle,
required this.episodeId,
required this.episodeNo,
required this.deviceId,
required this.quality,
required this.playUrl,
required this.licenseKey,
required this.status,
required this.grantedAt,
required this.expireAt,
required this.taskStatus,
required this.progressPercent,
required this.downloadedBytes,
required this.totalBytes,
required this.errorMsg,
});
final String licenseId;
final String dramaId;
final String dramaTitle;
final String episodeId;
final int episodeNo;
final String deviceId;
final String quality;
final String playUrl;
final String licenseKey;
final String status;
final DateTime? grantedAt;
final DateTime? expireAt;
final String taskStatus;
final int progressPercent;
final int downloadedBytes;
final int totalBytes;
final String errorMsg;
/// 解析授权响应;所有 ID 与 Long 字段兼容字符串下发。
factory DownloadLicense.fromJson(Map<String, dynamic> json) {
return DownloadLicense(
licenseId: (json['licenseId'] ?? '').toString(),
dramaId: (json['dramaId'] ?? '').toString(),
dramaTitle: json['dramaTitle'] as String? ?? '',
episodeId: (json['episodeId'] ?? '').toString(),
episodeNo: jsonToInt(json['episodeNo']),
deviceId: json['deviceId'] as String? ?? '',
quality: json['quality'] as String? ?? '',
playUrl: json['playUrl'] as String? ?? '',
licenseKey: json['licenseKey'] as String? ?? '',
status: json['status'] as String? ?? '',
grantedAt: DateTime.tryParse(json['grantedAt'] as String? ?? ''),
expireAt: DateTime.tryParse(json['expireAt'] as String? ?? ''),
taskStatus: json['taskStatus'] as String? ?? '',
progressPercent: jsonToInt(json['progressPercent']),
downloadedBytes: jsonToInt(json['downloadedBytes']),
totalBytes: jsonToInt(json['totalBytes']),
errorMsg: json['errorMsg'] as String? ?? '',
);
}
}
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 下载策略与当前设备配额,对应 `GET /app/downloads/policy`。
class DownloadPolicy {
const DownloadPolicy({
required this.maxPerDevice,
required this.licenseDays,
required this.wifiOnly,
required this.maxQuality,
required this.maxStorageBytes,
required this.used,
required this.remaining,
required this.expirePolicyText,
});
final int maxPerDevice;
final int licenseDays;
final bool wifiOnly;
final String maxQuality;
final int maxStorageBytes;
final int used;
final int remaining;
final String expirePolicyText;
/// 设置更新期间用于本地乐观展示。
DownloadPolicy copyWith({bool? wifiOnly, String? maxQuality}) {
return DownloadPolicy(
maxPerDevice: maxPerDevice,
licenseDays: licenseDays,
wifiOnly: wifiOnly ?? this.wifiOnly,
maxQuality: maxQuality ?? this.maxQuality,
maxStorageBytes: maxStorageBytes,
used: used,
remaining: remaining,
expirePolicyText: expirePolicyText,
);
}
/// 解析策略响应;Long 字段兼容字符串。
factory DownloadPolicy.fromJson(Map<String, dynamic> json) {
return DownloadPolicy(
maxPerDevice: jsonToInt(json['maxPerDevice']),
licenseDays: jsonToInt(json['licenseDays']),
wifiOnly: json['wifiOnly'] == true,
maxQuality: json['maxQuality'] as String? ?? '',
maxStorageBytes: jsonToInt(json['maxStorageBytes']),
used: jsonToInt(json['used']),
remaining: jsonToInt(json['remaining']),
expirePolicyText: json['expirePolicyText'] as String? ?? '',
);
}
}
+101 -8
View File
@@ -1,21 +1,55 @@
// Author: fruit.com.cn
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 {
@@ -23,15 +57,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(),
);
}
}
@@ -1,13 +0,0 @@
// Author: fruit.com.cn
class EngagementSnapshot {
const EngagementSnapshot({
this.savedDramaIds = const {},
this.likedDramaIds = const {},
this.commentsByDrama = const {},
});
final Set<String> savedDramaIds;
final Set<String> likedDramaIds;
final Map<String, List<String>> commentsByDrama;
}
+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? ?? '',
);
}
}
+70
View File
@@ -0,0 +1,70 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 互动目标类型,契约只允许短剧与评论。
enum InteractionTargetType {
drama('DRAMA'),
comment('COMMENT');
const InteractionTargetType(this.apiValue);
final String apiValue;
}
/// 客户端固定举报原因,后端当前未限制枚举值。
enum ReportReason {
spam('SPAM'),
abuse('ABUSE'),
porn('PORN'),
ad('AD'),
other('OTHER');
const ReportReason(this.apiValue);
final String apiValue;
}
/// 一次性互动请求,供仓库和测试断言使用。
class ReportRequest {
const ReportRequest({
required this.targetType,
required this.targetId,
required this.reasonCode,
this.description = '',
});
final InteractionTargetType targetType;
final String targetId;
final String reasonCode;
final String description;
}
/// 点赞切换后的服务端状态。
class LikeToggleResult {
const LikeToggleResult({required this.liked, required this.count});
final bool liked;
final int count;
/// 计数按 Long 字符串兼容解析。
factory LikeToggleResult.fromJson(Map<String, dynamic> json) =>
LikeToggleResult(
liked: json['liked'] == true,
count: jsonToInt(json['count']),
);
}
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
class OperationResult<T> {
const OperationResult._(this.data, this.errorMessage);
const OperationResult.success([T? data]) : this._(data, null);
const OperationResult.failure(this.errorMessage) : data = null;
final T? data;
final String? errorMessage;
bool get isSuccess => errorMessage == null;
}
+20
View File
@@ -0,0 +1,20 @@
// 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;
}
/// ID 字段统一转字符串;后端按契约下发字符串,同时兼容测试或旧数据的数字形式。
String jsonToId(dynamic value, {String fallback = ''}) {
if (value is String) return value;
if (value is num) return value.toString();
return fallback;
}
+53
View File
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// Server notice list page.
class NoticePage {
const NoticePage({required this.total, this.notices = const []});
final int total;
final List<AppNotice> notices;
/// Parses the anonymous `/app/notices` page shell.
factory NoticePage.fromJson(Map<String, dynamic> json) {
final rawList = json['list'];
return NoticePage(
total: jsonToInt(json['total']),
notices: [
if (rawList is List)
for (final item in rawList)
if (item is Map<String, dynamic>) AppNotice.fromJson(item),
],
);
}
}
/// System notice returned by `/app/notices`.
class AppNotice {
const AppNotice({
required this.id,
required this.title,
required this.content,
required this.type,
required this.publishAt,
this.expireAt,
});
final String id;
final String title;
final String content;
final String type;
final DateTime? publishAt;
final DateTime? expireAt;
/// Parses string IDs without narrowing them through `int`.
factory AppNotice.fromJson(Map<String, dynamic> json) => AppNotice(
id: jsonToId(json['id']),
title: json['title']?.toString() ?? '',
content: json['content']?.toString() ?? '',
type: json['type']?.toString() ?? '',
publishAt: DateTime.tryParse(json['publishAt']?.toString() ?? ''),
expireAt: DateTime.tryParse(json['expireAt']?.toString() ?? ''),
);
}
+55
View File
@@ -0,0 +1,55 @@
import 'json_numbers.dart';
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
class PlayTicket {
const PlayTicket({
required this.episodeId,
required this.accessible,
required this.accessType,
this.dramaId = '',
this.number = 0,
this.title = '',
this.playUrl = '',
this.durationSec = 0,
this.unlockCoin = 0,
this.previewSeconds = 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 String dramaId;
final int number;
final String title;
final bool accessible;
final String accessType;
/// 签名播放地址:**原样使用**(含 `?e=&s=`),客户端不拼接、不改写。
final String playUrl;
final int durationSec;
final int unlockCoin;
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
final int previewSeconds;
bool get needsUnlock =>
accessType == coinRequired || accessType == vipRequired;
factory PlayTicket.fromJson(Map<String, dynamic> json) {
return PlayTicket(
episodeId: (json['episodeId'] ?? '').toString(),
dramaId: (json['dramaId'] ?? '').toString(),
number: jsonToInt(json['number']),
title: json['title'] as String? ?? '',
accessible: json['accessible'] == true,
accessType: json['accessType'] as String? ?? noMedia,
playUrl: json['playUrl'] as String? ?? '',
durationSec: jsonToInt(json['durationSec']),
unlockCoin: jsonToInt(json['unlockCoin']),
previewSeconds: jsonToInt(json['previewSeconds']),
);
}
}
+65
View File
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。
///
/// 服务端按 `(user, drama)` 只留一条,所以它是"这部剧看到哪一集的哪一秒",
/// 不是逐集进度。ID 与毫秒都是字符串(见 api-phase1.md §0),统一走 jsonToInt 解析。
class WatchProgress {
const WatchProgress({
required this.dramaId,
required this.episodeId,
required this.position,
required this.duration,
this.finished = false,
this.playCount = 0,
this.updatedAt,
});
final String dramaId;
final String episodeId;
final Duration position;
final Duration duration;
final bool finished;
final int playCount;
final DateTime? updatedAt;
bool get hasPosition => position > Duration.zero;
/// 进度比例 0~1,时长未知时为 0,避免 UI 出现 NaN。
double get fraction {
if (duration <= Duration.zero) return 0;
return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
}
factory WatchProgress.fromJson(Map<String, dynamic> json) {
return WatchProgress(
dramaId: (json['dramaId'] ?? '').toString(),
episodeId: (json['episodeId'] ?? '').toString(),
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
playCount: jsonToInt(json['playCount']),
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
);
}
Map<String, dynamic> toJson() => {
'dramaId': dramaId,
'episodeId': episodeId,
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
'finished': finished,
'playCount': playCount,
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
};
}
/// 续看点:从哪一集的哪个位置继续看。
class ResumePoint {
const ResumePoint({required this.episodeId, required this.position});
final String episodeId;
final Duration position;
}
+44
View File
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
import 'drama.dart';
import 'watch_progress.dart';
/// 观看记录页的一条数据:进度记录与按 dramaId 补取的详情卡片。
class WatchRecord {
const WatchRecord({
required this.progress,
this.drama,
this.detailLoading = true,
this.detailFailed = false,
});
final WatchProgress progress;
final Drama? drama;
final bool detailLoading;
final bool detailFailed;
String get dramaId => progress.dramaId;
/// 详情中按 episodeId 定位集号;缺失时返回 0。
int get episodeNumber {
final episodes = drama?.episodes ?? const <Episode>[];
for (var index = 0; index < episodes.length; index++) {
if (episodes[index].id == progress.episodeId) return index + 1;
}
return 0;
}
/// 生成卡片加载中、成功或失败后的新状态。
WatchRecord copyWith({
Drama? drama,
bool? detailLoading,
bool? detailFailed,
}) {
return WatchRecord(
progress: progress,
drama: drama ?? this.drama,
detailLoading: detailLoading ?? this.detailLoading,
detailFailed: detailFailed ?? this.detailFailed,
);
}
}
+154
View File
@@ -0,0 +1,154 @@
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?> put(
String path, {
Object? body,
Map<String, String>? query,
bool skipAuthRetry = false,
}) {
return _request(
'PUT',
path,
body: body,
query: query,
skipAuthRetry: skipAuthRetry,
);
}
Future<Object?> delete(
String path, {
Object? body,
Map<String, String>? query,
}) {
return _request('DELETE', path, body: body, query: query);
}
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,
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import '../models/drama.dart';
import '../models/home_feed.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')),
);
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '返回数据格式异常');
}
}
+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
import '../models/content_category.dart';
import 'api_client.dart';
/// 内容分类接口,对应契约 `GET /app/categories`。
class CategoryApi {
const CategoryApi(this._client);
final ApiClient _client;
/// 拉取已启用分类,id 全程按字符串解析。
Future<List<ContentCategory>> list() async {
final data = await _client.get('/app/categories');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
}
}
+59
View File
@@ -0,0 +1,59 @@
// Author: fruit.com.cn
import '../models/comment.dart';
import 'api_client.dart';
/// 评论接口,对应契约 §1.7 的 `/app/comments*`。
class CommentApi {
const CommentApi(this._client);
final ApiClient _client;
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
}) async {
final data = await _client.get(
'/app/comments',
query: {
'dramaId': dramaId,
'rootId': ?rootId,
'cursor': ?cursor,
'size': '$size',
},
);
if (data is! Map<String, dynamic>) return const CommentPage();
return CommentPage.fromJson(data);
}
/// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
}) async {
final data = await _client.post(
'/app/comments',
body: {
'dramaId': dramaId,
if (episodeId != null && episodeId.isNotEmpty)
'episodeId': episodeId,
'content': content,
if (parentId != null && parentId.isNotEmpty) 'parentId': parentId,
},
);
if (data is! Map<String, dynamic>) {
throw const ApiException(500, '评论响应格式错误');
}
return DramaComment.fromJson(data);
}
/// 删除自己的评论。
Future<void> delete(String commentId) async {
await _client.delete('/app/comments/$commentId');
}
}
+101
View File
@@ -0,0 +1,101 @@
// Author: fruit.com.cn
import '../models/download_license.dart';
import '../models/download_policy.dart';
import 'api_client.dart';
/// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。
///
/// 设备号由 [ApiClient] 统一通过 `X-Device-Id` 发送,本类不另造设备参数。
class DownloadApi {
const DownloadApi(this._client);
final ApiClient _client;
/// 拉取配额和偏好策略。
Future<DownloadPolicy> policy() async {
return DownloadPolicy.fromJson(
_asMap(await _client.get('/app/downloads/policy')),
);
}
/// 更新仅 Wi-Fi 与最高清晰度偏好。
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {
await _client.put(
'/app/downloads/settings',
body: {'wifiOnly': ?wifiOnly, 'maxQuality': ?maxQuality},
);
}
/// 申请下载授权;同集重复申请由服务端保证幂等。
Future<DownloadLicense> grant(String episodeId, {String? quality}) async {
return DownloadLicense.fromJson(
_asMap(
await _client.post(
'/app/downloads',
body: {'episodeId': episodeId, 'quality': ?quality},
),
),
);
}
/// 拉取当前设备的授权列表。
Future<List<DownloadLicense>> list() async {
final data = await _client.get('/app/downloads');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) DownloadLicense.fromJson(item),
];
}
/// 续传前刷新短时效下载地址。
Future<DownloadLicense> refreshUrl(String episodeId) async {
return DownloadLicense.fromJson(
_asMap(await _client.get('/app/downloads/$episodeId/url')),
);
}
/// 上报下载进度;本批只提供方法,不启动文件落盘。
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) async {
return DownloadLicense.fromJson(
_asMap(
await _client.post(
'/app/downloads/$episodeId/progress',
body: {
'deviceId': ?deviceId,
'status': ?status,
'progressPercent': ?progressPercent,
'downloadedBytes': ?downloadedBytes,
'totalBytes': ?totalBytes,
'errorMsg': ?errorMsg,
},
),
),
);
}
/// 吊销单集授权。
Future<void> remove(String episodeId) async {
await _client.delete('/app/downloads/$episodeId');
}
/// 清空当前设备授权并返回清除条数。
Future<int> clear() async {
final data = await _client.delete('/app/downloads');
return data is int ? data : 0;
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '');
}
}
+44
View File
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
import '../models/interaction.dart';
import 'api_client.dart';
/// 点赞与举报接口,对应契约 §1.7。
class InteractionApi {
const InteractionApi(this._client);
final ApiClient _client;
/// 切换点赞,服务端返回的状态与计数为最终真相。
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
}) async {
final data = await _client.post(
'/app/likes/toggle',
body: {
'targetType': targetType.apiValue,
'targetId': targetId,
},
);
if (data is! Map<String, dynamic>) {
throw const ApiException(500, '点赞响应格式错误');
}
return LikeToggleResult.fromJson(data);
}
/// 提交举报,返回工单 ID。
Future<String> report(ReportRequest request) async {
final data = await _client.post(
'/app/reports',
body: {
'targetType': request.targetType.apiValue,
'targetId': request.targetId,
'reasonCode': request.reasonCode,
if (request.description.isNotEmpty)
'description': request.description,
},
);
return data?.toString() ?? '';
}
}
+35
View File
@@ -0,0 +1,35 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import 'api_client.dart';
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
///
/// 列表返回的是与首页一致的卡片结构,所以直接用 `Drama.fromCardJson` 解析。
class LibraryApi {
const LibraryApi(this._client);
final ApiClient _client;
/// 切换收藏,返回切换后的状态(true = 已收藏),客户端以它为唯一真相。
Future<bool> toggleFavorite(String dramaId) async {
final data = await _client.post(
'/app/favorites/toggle',
body: {'dramaId': dramaId},
);
return data == true;
}
Future<List<Drama>> favorites() async {
final data = await _client.get('/app/favorites');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
];
}
Future<void> removeFavorite(String dramaId) async {
await _client.delete('/app/favorites/$dramaId');
}
}
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import '../models/notice.dart';
import 'api_client.dart';
/// Anonymous system notice API.
class NoticeApi {
const NoticeApi(this._client);
final ApiClient _client;
/// Loads one page from `GET /app/notices`.
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) async {
final data = await _client.get(
'/app/notices',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
if (data is! Map<String, dynamic>) {
return const NoticePage(total: 0);
}
return NoticePage.fromJson(data);
}
}
+20
View File
@@ -0,0 +1,20 @@
// Author: fruit.com.cn
import '../models/play_ticket.dart';
import 'api_client.dart';
/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。
///
/// **签名地址原样使用**:后端下发的 `playUrl` 带 `?e=过期时间&s=签名`,
/// 客户端只做「相对路径补 host」,不拼接、不改写、不丢 query —— 否则签名校验直接失败。
class PlaybackApi {
const PlaybackApi(this._client);
final ApiClient _client;
Future<PlayTicket> ticket(String episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/play');
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
}
}
+64
View File
@@ -0,0 +1,64 @@
// Author: fruit.com.cn
import '../models/watch_progress.dart';
import 'api_client.dart';
/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。
class WatchApi {
const WatchApi(this._client);
final ApiClient _client;
/// 上报进度。服务端从 `episodeId` 推出所属剧集,并保证「同集只前进不后退」。
Future<WatchProgress?> saveProgress({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
String source = 'app',
}) async {
final data = await _client.post(
'/app/watch/progress',
body: {
'episodeId': episodeId,
'positionMs': position.inMilliseconds,
if (duration != null) 'durationMs': duration.inMilliseconds,
'finished': finished,
'source': source,
},
);
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 某部剧的续看位置;没有记录时后端返回空 data。
Future<WatchProgress?> progressOfDrama(String dramaId) async {
final data = await _client.get(
'/app/watch/progress',
query: {'dramaId': dramaId},
);
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
Future<List<WatchProgress>> recent({int limit = 20}) async {
final data = await _client.get(
'/app/watch/recent',
query: {'limit': '$limit'},
);
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
];
}
/// 删除某部剧的观看记录(记录页清单条用)。
Future<void> removeProgress(String dramaId) async {
await _client.delete('/app/watch/progress/$dramaId');
}
/// 清空当前账号的全部观看记录。
Future<void> clearProgress() async {
await _client.delete('/app/watch/progress');
}
}
@@ -1,91 +0,0 @@
// Author: fruit.com.cn
import 'api_client.dart';
import 'token_storage.dart';
/// 登录/刷新成功后返回的会话令牌。
class AuthSession {
AuthSession({
required this.accessToken,
required this.refreshToken,
required this.expiresIn,
required this.newUser,
});
final String accessToken;
final String refreshToken;
final int expiresIn;
final bool newUser;
factory AuthSession.fromJson(Map<String, dynamic> json) {
return AuthSession(
accessToken: json['accessToken'] as String,
refreshToken: json['refreshToken'] as String,
expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 7200,
newUser: json['newUser'] as bool? ?? false,
);
}
}
/// 账号与鉴权接口封装。
class AuthRepository {
AuthRepository(this._client, this._tokenStorage);
final ApiClient _client;
final TokenStorage _tokenStorage;
/// 发送验证码。
Future<void> sendOtp({
required String scene,
required String targetType,
required String target,
}) async {
await _client.post('/app/auth/otp', data: {
'scene': scene,
'targetType': targetType,
'target': target,
});
}
/// 验证码登录。
Future<AuthSession> loginWithCode({
required String targetType,
required String target,
required String code,
}) async {
final data = await _client.post('/app/auth/login', data: {
'targetType': targetType,
'target': target,
'code': code,
});
return AuthSession.fromJson(data as Map<String, dynamic>);
}
/// 密码登录。
Future<AuthSession> loginWithPassword({
required String targetType,
required String target,
required String password,
}) async {
final data = await _client.post('/app/auth/login', data: {
'targetType': targetType,
'target': target,
'password': password,
});
return AuthSession.fromJson(data as Map<String, dynamic>);
}
/// 保存会话令牌。
Future<void> saveSession(AuthSession session) async {
await _tokenStorage.saveTokens(
access: session.accessToken,
refresh: session.refreshToken,
);
}
/// 登出并清理本地令牌。
Future<void> logout() async {
await _client.post('/app/auth/logout');
await _tokenStorage.clear();
}
}
@@ -1,106 +1,33 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../remote/catalog_api.dart';
/// 目录数据来源:首页/发现页聚合、详情。
///
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。
abstract interface class CatalogRepository {
Future<List<Drama>> fetchDramas();
Future<HomeFeed> fetchHomeFeed();
Future<HomeFeed> fetchDiscoverFeed();
Future<Drama> fetchDrama(String dramaId);
}
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);
}
@@ -0,0 +1,30 @@
// Author: fruit.com.cn
import '../models/content_category.dart';
import '../models/home_feed.dart';
import '../remote/category_api.dart';
import 'catalog_repository.dart';
/// 发现页分类数据来源:分类清单与发现 feed。
abstract interface class CategoryRepository {
/// 拉取服务端分类。
Future<List<ContentCategory>> fetchCategories();
/// 拉取发现页分区;分类内容只从这里读取。
Future<HomeFeed> fetchDiscoverFeed();
}
/// HTTP 实现。发现 feed 复用既有目录仓库,不新增接口。
class HttpCategoryRepository implements CategoryRepository {
const HttpCategoryRepository(this._categoryApi, this._catalogRepository);
final CategoryApi _categoryApi;
final CatalogRepository _catalogRepository;
@override
Future<List<ContentCategory>> fetchCategories() => _categoryApi.list();
@override
Future<HomeFeed> fetchDiscoverFeed() =>
_catalogRepository.fetchDiscoverFeed();
}
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
import '../models/comment.dart';
import '../remote/comment_api.dart';
/// 评论数据来源抽象,页面与控制器不直接依赖 HTTP。
abstract interface class CommentRepository {
/// 拉取评论或回复的分页。
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
});
/// 发表评论或回复。
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
});
/// 删除指定评论。
Future<void> delete(String commentId);
}
/// HTTP 评论仓库。
class HttpCommentRepository implements CommentRepository {
const HttpCommentRepository(this._api);
final CommentApi _api;
@override
Future<CommentPage> page({
required String dramaId,
String? rootId,
String? cursor,
int size = commentPageSize,
}) => _api.page(
dramaId: dramaId,
rootId: rootId,
cursor: cursor,
size: size,
);
@override
Future<DramaComment> publish({
required String dramaId,
String? episodeId,
required String content,
String? parentId,
}) => _api.publish(
dramaId: dramaId,
episodeId: episodeId,
content: content,
parentId: parentId,
);
@override
Future<void> delete(String commentId) => _api.delete(commentId);
}
@@ -0,0 +1,82 @@
// Author: fruit.com.cn
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../remote/download_api.dart';
/// 下载授权数据来源,页面只依赖该抽象。
abstract interface class DownloadRepository {
Future<DownloadPolicy> fetchPolicy();
Future<List<DownloadLicense>> list();
Future<DownloadLicense> grant(String episodeId, {String? quality});
Future<void> updateSettings({bool? wifiOnly, String? maxQuality});
Future<DownloadLicense> refreshUrl(String episodeId);
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
});
Future<void> remove(String episodeId);
Future<int> clear();
}
/// HTTP 下载授权仓库。
class HttpDownloadRepository implements DownloadRepository {
const HttpDownloadRepository(this._api);
final DownloadApi _api;
@override
Future<DownloadPolicy> fetchPolicy() => _api.policy();
@override
Future<List<DownloadLicense>> list() => _api.list();
@override
Future<DownloadLicense> grant(String episodeId, {String? quality}) =>
_api.grant(episodeId, quality: quality);
@override
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) =>
_api.updateSettings(wifiOnly: wifiOnly, maxQuality: maxQuality);
@override
Future<DownloadLicense> refreshUrl(String episodeId) =>
_api.refreshUrl(episodeId);
@override
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) => _api.reportProgress(
episodeId,
deviceId: deviceId,
status: status,
progressPercent: progressPercent,
downloadedBytes: downloadedBytes,
totalBytes: totalBytes,
errorMsg: errorMsg,
);
@override
Future<void> remove(String episodeId) => _api.remove(episodeId);
@override
Future<int> clear() => _api.clear();
}
@@ -1,70 +0,0 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/engagement_snapshot.dart';
abstract interface class EngagementRepository {
Future<EngagementSnapshot> load();
Future<void> save(EngagementSnapshot snapshot);
}
class SharedPreferencesEngagementRepository implements EngagementRepository {
SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
static const _key = 'engagement.v1.demo-local-v1';
final SharedPreferencesAsync _preferences;
@override
Future<EngagementSnapshot> load() async {
final raw = await _preferences.getString(_key);
if (raw == null) return const EngagementSnapshot();
try {
final json = jsonDecode(raw);
if (json is! Map<String, dynamic>) return const EngagementSnapshot();
Set<String> ids(Object? value) =>
value is List ? value.whereType<String>().toSet() : <String>{};
final comments = <String, List<String>>{};
final rawComments = json['comments'];
if (rawComments is Map<String, dynamic>) {
for (final entry in rawComments.entries) {
if (entry.value is List) {
comments[entry.key] = (entry.value as List)
.whereType<String>()
.toList();
}
}
}
return EngagementSnapshot(
savedDramaIds: ids(json['saved']),
likedDramaIds: ids(json['liked']),
commentsByDrama: comments,
);
} catch (_) {
return const EngagementSnapshot();
}
}
@override
Future<void> save(EngagementSnapshot snapshot) => _preferences.setString(
_key,
jsonEncode({
'saved': snapshot.savedDramaIds.toList(),
'liked': snapshot.likedDramaIds.toList(),
'comments': snapshot.commentsByDrama,
}),
);
}
class InMemoryEngagementRepository implements EngagementRepository {
EngagementSnapshot snapshot = const EngagementSnapshot();
@override
Future<EngagementSnapshot> load() async => snapshot;
@override
Future<void> save(EngagementSnapshot value) async => snapshot = value;
}
@@ -0,0 +1,74 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../remote/library_api.dart';
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
abstract interface class FavoriteRepository {
/// 切换收藏,返回切换后的状态(true = 已收藏)。
Future<bool> toggle(String dramaId);
/// 收藏列表(卡片结构与首页一致)。
Future<List<Drama>> list();
Future<void> remove(String dramaId);
}
class HttpFavoriteRepository implements FavoriteRepository {
const HttpFavoriteRepository(this._api, {this.isSignedIn});
final LibraryApi _api;
/// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。
final bool Function()? isSignedIn;
bool get _signedIn => isSignedIn?.call() ?? true;
@override
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
@override
Future<List<Drama>> list() async {
if (!_signedIn) return const [];
return _api.favorites();
}
@override
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
}
/// 内存实现:单测与 widget 测试注入用。
class InMemoryFavoriteRepository implements FavoriteRepository {
InMemoryFavoriteRepository({Set<String>? saved, List<Drama>? cards})
: _saved = {...?saved},
_cards = [...?cards];
final Set<String> _saved;
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
bool failRemove = false;
int toggleCalls = 0;
int listCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
toggleCalls += 1;
if (failToggle) throw StateError('favorite unavailable');
if (!_saved.add(dramaId)) _saved.remove(dramaId);
return _saved.contains(dramaId);
}
@override
Future<List<Drama>> list() async {
listCalls++;
if (failList) throw StateError('favorite unavailable');
return _cards.where((card) => _saved.contains(card.id)).toList();
}
@override
Future<void> remove(String dramaId) async {
if (failRemove) throw StateError('favorite unavailable');
_saved.remove(dramaId);
}
}
@@ -1,33 +0,0 @@
// Author: fruit.com.cn
import '../models/app_catalog.dart';
import '../services/api_client.dart';
/// 对接后端 `/app/**` 内容接口(A3)。
class HttpCatalogRepository {
HttpCatalogRepository(this._client);
final ApiClient _client;
Future<AppFeed> fetchHomeFeed() async {
final data = await _client.get('/app/home/feed');
return AppFeed.fromJson(data as Map<String, dynamic>);
}
Future<AppFeed> fetchDiscoverFeed() async {
final data = await _client.get('/app/discover/feed');
return AppFeed.fromJson(data as Map<String, dynamic>);
}
Future<List<AppCategory>> fetchCategories() async {
final data = await _client.get('/app/categories');
return (data as List)
.map((e) => AppCategory.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<AppDramaDetail> fetchDramaDetail(int id) async {
final data = await _client.get('/app/dramas/$id');
return AppDramaDetail.fromJson(data as Map<String, dynamic>);
}
}
@@ -1,61 +1,32 @@
// Author: fruit.com.cn
import '../services/api_client.dart';
import '../models/interaction.dart';
import '../remote/interaction_api.dart';
/// 评论、点赞、举报(A6)。
class InteractionRepository {
InteractionRepository(this._client);
/// 点赞与举报数据来源抽象。
abstract interface class InteractionRepository {
/// 切换目标点赞状态。
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
});
final ApiClient _client;
/// 提交举报并返回工单 ID。
Future<String> report(ReportRequest request);
}
Future<void> postComment({
required int dramaId,
required String content,
int? rootId,
}) async {
await _client.post('/app/comments', data: {
'dramaId': dramaId,
'content': content,
if (rootId != null) 'rootId': rootId,
});
}
/// HTTP 互动仓库。
class HttpInteractionRepository implements InteractionRepository {
const HttpInteractionRepository(this._api);
Future<List<dynamic>> fetchComments({
required int dramaId,
int? cursor,
int size = 20,
}) async {
final data = await _client.get('/app/comments', query: {
'dramaId': dramaId,
if (cursor != null) 'cursor': cursor,
'size': size,
});
return data as List;
}
final InteractionApi _api;
Future<void> deleteComment(int id) async {
await _client.delete('/app/comments/$id');
}
@override
Future<LikeToggleResult> toggleLike({
required InteractionTargetType targetType,
required String targetId,
}) => _api.toggleLike(targetType: targetType, targetId: targetId);
Future<void> toggleLike({
required String targetType,
required int targetId,
}) async {
await _client.post('/app/likes/toggle', data: {
'targetType': targetType,
'targetId': targetId,
});
}
Future<void> report({
required String targetType,
required int targetId,
required String reason,
}) async {
await _client.post('/app/reports', data: {
'targetType': targetType,
'targetId': targetId,
'reason': reason,
});
}
@override
Future<String> report(ReportRequest request) => _api.report(request);
}
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
import '../models/notice.dart';
import '../remote/notice_api.dart';
/// Notice data source used by the page and controller.
abstract interface class NoticeRepository {
/// Loads one notice page.
Future<NoticePage> page({required int pageIndex, int pageSize = 20});
}
/// HTTP-backed notice repository.
class HttpNoticeRepository implements NoticeRepository {
const HttpNoticeRepository(this._api);
final NoticeApi _api;
@override
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) =>
_api.page(pageIndex: pageIndex, pageSize: pageSize);
}
@@ -1,16 +1,96 @@
// Author: fruit.com.cn
import '../services/api_client.dart';
import '../models/play_ticket.dart';
import '../remote/playback_api.dart';
/// 播放票据(A4)。
class PlaybackRepository {
PlaybackRepository(this._client);
/// 播放票据来源。
///
/// 票据里的签名地址有有效期(契约 §1.4:签名默认 15 分钟),所以**不能一直缓存**:
/// [ticketFor] 命中未过期的缓存就用缓存,过期或 [forceRefresh] 时重新向后端要一张。
abstract interface class PlaybackRepository {
Future<PlayTicket> ticketFor(String episodeId, {bool forceRefresh = false});
final ApiClient _client;
/// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。
bool isFresh(String episodeId);
}
/// 签发播放票据与签名地址,客户端原样使用 playUrl。
Future<Map<String, dynamic>> fetchPlayTicket(int episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/play');
return data as Map<String, dynamic>;
class HttpPlaybackRepository implements PlaybackRepository {
HttpPlaybackRepository(
this._api, {
this.ttl = const Duration(minutes: 10),
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now;
final PlaybackApi _api;
/// 缓存时长:比后端 15 分钟签名短 5 分钟,避免"地址刚好在播放中途失效"。
final Duration ttl;
final DateTime Function() _clock;
final Map<String, _CachedTicket> _cache = {};
@override
Future<PlayTicket> ticketFor(
String episodeId, {
bool forceRefresh = false,
}) async {
final cached = _cache[episodeId];
if (!forceRefresh && cached != null && !cached.isExpired(_clock(), ttl)) {
return cached.ticket;
}
final ticket = await _api.ticket(episodeId);
_cache[episodeId] = _CachedTicket(ticket, _clock());
return ticket;
}
@override
bool isFresh(String episodeId) {
final cached = _cache[episodeId];
if (cached == null) return false;
return !cached.isExpired(_clock(), ttl);
}
/// 供测试与"退出播放页"清理用。
void clear() => _cache.clear();
}
class _CachedTicket {
const _CachedTicket(this.ticket, this.fetchedAt);
final PlayTicket ticket;
final DateTime fetchedAt;
bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl;
}
/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。
class FakePlaybackRepository implements PlaybackRepository {
FakePlaybackRepository({this.ticket, this.error, this.fresh = true});
PlayTicket? ticket;
Object? error;
final List<String> requests = [];
final List<bool> forced = [];
/// 模拟"票据是否还有效":置 false 可验证回前台会重新取。
bool fresh;
@override
bool isFresh(String episodeId) => fresh;
@override
Future<PlayTicket> ticketFor(
String episodeId, {
bool forceRefresh = false,
}) async {
requests.add(episodeId);
forced.add(forceRefresh);
if (error != null) throw error!;
return ticket ??
PlayTicket(
episodeId: episodeId,
accessible: true,
accessType: PlayTicket.free,
playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc',
);
}
}
@@ -1,25 +0,0 @@
// Author: fruit.com.cn
import '../services/api_client.dart';
/// 钱包、金币解锁(09 收费支付)。
class WalletRepository {
WalletRepository(this._client);
final ApiClient _client;
Future<Map<String, dynamic>> fetchWallet() async {
final data = await _client.get('/app/wallet');
return data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> unlockEpisode(int episodeId) async {
final data = await _client.post('/app/episodes/$episodeId/unlock');
return data as Map<String, dynamic>;
}
Future<List<dynamic>> fetchOrders() async {
final data = await _client.get('/app/orders');
return data as List;
}
}
@@ -1,67 +1,156 @@
// Author: fruit.com.cn
import 'package:shared_preferences/shared_preferences.dart';
const demoProfileId = 'demo-local-v1';
import '../models/watch_progress.dart';
import '../remote/watch_api.dart';
/// 观看进度来源。
///
/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是
/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。
/// 本地实现已删除:进度以服务端为准,换设备也能续看。
abstract interface class WatchHistoryRepository {
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
);
Future<String?> lastEpisodeId(String profileId, String dramaId);
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
);
/// 某部剧的续看记录;没有记录返回 null(不是异常)。
Future<WatchProgress?> progressFor(String dramaId);
/// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
});
/// 最近观看(默认 20 条)。
Future<List<WatchProgress>> recent({int limit = 20});
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
/// 清空当前账号的全部观看记录。
Future<void> clear();
}
class SharedPreferencesWatchHistoryRepository
implements WatchHistoryRepository {
SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
class HttpWatchHistoryRepository implements WatchHistoryRepository {
const HttpWatchHistoryRepository(this._api, {this.isSignedIn});
final SharedPreferencesAsync _preferences;
final WatchApi _api;
String _positionKey(String profile, String drama, String episode) =>
'watch.v1.$profile.$drama.$episode.positionMs';
String _lastKey(String profile, String drama) =>
'watch.v1.$profile.$drama.lastEpisode';
/// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。
final bool Function()? isSignedIn;
bool get _signedIn => isSignedIn?.call() ?? true;
@override
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
) async {
final milliseconds = await _preferences.getInt(
_positionKey(profileId, dramaId, episodeId),
);
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
Future<WatchProgress?> progressFor(String dramaId) async {
if (!_signedIn) return null;
return _api.progressOfDrama(dramaId);
}
@override
Future<String?> lastEpisodeId(String profileId, String dramaId) =>
_preferences.getString(_lastKey(profileId, dramaId));
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (!_signedIn) return null;
return _api.saveProgress(
episodeId: episodeId,
position: position,
duration: duration,
finished: finished,
);
}
@override
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
) async {
await _preferences.setInt(
_positionKey(profileId, dramaId, episodeId),
position.inMilliseconds,
);
await _preferences.setString(_lastKey(profileId, dramaId), episodeId);
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (!_signedIn) return const [];
return _api.recent(limit: limit);
}
@override
Future<void> remove(String dramaId) async {
if (!_signedIn) return;
await _api.removeProgress(dramaId);
}
@override
Future<void> clear() async {
if (!_signedIn) return;
await _api.clearProgress();
}
}
/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。
class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
InMemoryWatchHistoryRepository({List<WatchProgress>? seed})
: _records = {
for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item,
};
final Map<String, WatchProgress> _records;
int failReads = 0;
bool failWrites = false;
final List<String> writes = [];
@override
Future<WatchProgress?> progressFor(String dramaId) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records[dramaId];
}
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async {
if (failWrites) throw StateError('progress unavailable');
writes.add('$episodeId|${position.inMilliseconds}|$finished');
final existing = _records[episodeId];
final record = WatchProgress(
dramaId: existing?.dramaId ?? '',
episodeId: episodeId,
position: position,
duration: duration ?? Duration.zero,
finished: finished,
);
return record;
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records.values.take(limit).toList();
}
@override
Future<void> remove(String dramaId) async => _records.remove(dramaId);
@override
Future<void> clear() async => _records.clear();
}
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
extension WatchProgressResume on WatchHistoryRepository {
Future<ResumePoint?> resumePointFor(String dramaId) async {
final progress = await progressFor(dramaId);
if (progress == null || progress.episodeId.isEmpty) return null;
return ResumePoint(
episodeId: progress.episodeId,
position: progress.position,
);
}
}
/// 续看位置钳位:片尾附近与异常值一律从 0 开始,避免"一进来就播完"。
Duration clampResumePosition(Duration saved, Duration duration) {
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
if (saved <= Duration.zero) return Duration.zero;
@@ -1,59 +0,0 @@
// Author: fruit.com.cn
import '../services/api_client.dart';
/// 观看记录与收藏(A5)。
class WatchRepository {
WatchRepository(this._client);
final ApiClient _client;
Future<void> reportProgress({
required int episodeId,
required int positionSec,
required int durationSec,
}) async {
await _client.post('/app/watch/progress', data: {
'episodeId': episodeId,
'positionSec': positionSec,
'durationSec': durationSec,
});
}
Future<Map<String, dynamic>> fetchProgress(int dramaId) async {
final data = await _client.get('/app/watch/progress', query: {
'dramaId': dramaId,
});
return data as Map<String, dynamic>;
}
Future<List<dynamic>> fetchRecent({int limit = 20}) async {
final data = await _client.get('/app/watch/recent', query: {'limit': limit});
return data as List;
}
Future<void> deleteProgress(int dramaId) async {
await _client.delete('/app/watch/progress/$dramaId');
}
Future<void> clearProgress() async {
await _client.delete('/app/watch/progress');
}
Future<bool> toggleFavorite(int dramaId) async {
final data = await _client.post('/app/favorites/toggle', data: {
'dramaId': dramaId,
});
final map = data as Map<String, dynamic>;
return map['favorited'] as bool? ?? false;
}
Future<List<dynamic>> fetchFavorites() async {
final data = await _client.get('/app/favorites');
return data as List;
}
Future<void> deleteFavorite(int dramaId) async {
await _client.delete('/app/favorites/$dramaId');
}
}
-136
View File
@@ -1,136 +0,0 @@
// Author: fruit.com.cn
import 'package:dio/dio.dart';
import '../../config/api_config.dart';
import 'token_storage.dart';
/// 统一业务异常:code 为后端业务码,message 可直接展示。
class ApiException implements Exception {
ApiException(this.code, this.message);
final int code;
final String message;
@override
String toString() => 'ApiException($code): $message';
}
/// 统一 HTTP 客户端:BaseUrl、Token 注入、401 自动刷新、{code,message,data} 解包。
class ApiClient {
ApiClient({Dio? dio, TokenStorage? tokenStorage})
: _dio = dio ?? Dio(),
_tokenStorage = tokenStorage ?? TokenStorage() {
_dio.options
..baseUrl = ApiConfig.baseUrl
..connectTimeout = const Duration(
seconds: ApiConfig.connectTimeoutSeconds,
)
..receiveTimeout = const Duration(
seconds: ApiConfig.receiveTimeoutSeconds,
);
_dio.interceptors.add(
InterceptorsWrapper(onRequest: _onRequest, onError: _onError),
);
}
final Dio _dio;
final TokenStorage _tokenStorage;
bool _refreshing = false;
Future<void> _onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final access = await _tokenStorage.readAccessToken();
if (access != null && access.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $access';
}
// 设备标识:第一阶段固定占位,A2 接入真实 deviceId。
options.headers['X-Device-Id'] = 'device-0001';
handler.next(options);
}
Future<void> _onError(
DioException error,
ErrorInterceptorHandler handler,
) async {
final status = error.response?.statusCode;
final path = error.requestOptions.path;
final isAuthEndpoint =
path.endsWith('/auth/refresh') || path.endsWith('/auth/login');
if (status == 401 && !isAuthEndpoint && !_refreshing) {
final refreshed = await _refresh();
if (refreshed) {
handler.resolve(await _retry(error.requestOptions));
return;
}
}
handler.next(error);
}
Future<bool> _refresh() async {
_refreshing = true;
try {
final refresh = await _tokenStorage.readRefreshToken();
if (refresh == null || refresh.isEmpty) return false;
final response = await _dio.post<dynamic>(
'/app/auth/refresh',
data: {'refreshToken': refresh},
);
final body = response.data;
if (body is Map && body['code'] == 0) {
final data = body['data'] as Map;
await _tokenStorage.saveTokens(
access: data['accessToken'] as String,
refresh: data['refreshToken'] as String,
);
return true;
}
return false;
} catch (_) {
return false;
} finally {
_refreshing = false;
}
}
Future<Response<dynamic>> _retry(RequestOptions options) {
return _dio.fetch<dynamic>(options);
}
Future<dynamic> get(String path, {Map<String, dynamic>? query}) {
return _request(() => _dio.get<dynamic>(path, queryParameters: query));
}
Future<dynamic> post(String path, {Object? data}) {
return _request(() => _dio.post<dynamic>(path, data: data));
}
Future<dynamic> put(String path, {Object? data}) {
return _request(() => _dio.put<dynamic>(path, data: data));
}
Future<dynamic> delete(String path, {Object? data}) {
return _request(() => _dio.delete<dynamic>(path, data: data));
}
Future<dynamic> _request(Future<Response<dynamic>> Function() send) async {
try {
final response = await send();
final body = response.data;
if (body is Map && body.containsKey('code')) {
final code = body['code'];
if (code == 0) return body['data'];
throw ApiException(code as int, (body['message'] as String?) ?? '');
}
return body;
} on DioException catch (e) {
throw ApiException(
e.response?.statusCode ?? -1,
e.message ?? 'network error',
);
}
}
}
@@ -1,39 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/app_catalog.dart';
import '../repositories/http_catalog_repository.dart';
/// 首页/发现页 feed 状态(A3 数据层)。
class AppCatalogController extends ChangeNotifier {
AppCatalogController(this._repository);
final HttpCatalogRepository _repository;
AppFeed? feed;
bool loading = false;
String? error;
Future<void> loadFeed() async {
loading = true;
error = null;
notifyListeners();
try {
feed = await _repository.fetchHomeFeed();
} catch (e) {
error = e.toString();
} finally {
loading = false;
notifyListeners();
}
}
Future<AppDramaDetail> loadDetail(int id) {
return _repository.fetchDramaDetail(id);
}
Future<List<AppCategory>> loadCategories() {
return _repository.fetchCategories();
}
}
+84 -46
View File
@@ -1,65 +1,103 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../repositories/auth_repository.dart';
import '../models/auth_session.dart';
import '../remote/api_client.dart';
import '../remote/auth_api.dart';
import 'auth_session_store.dart';
enum AuthStatus { unknown, authenticated, unauthenticated }
/// 登录状态与账号操作。
/// 登录态的唯一来源。
///
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
class AuthController extends ChangeNotifier {
AuthController(this._repository);
AuthController(this._api, this._store);
final AuthRepository _repository;
final AuthApi _api;
final AuthSessionStore _store;
AuthStatus _status = AuthStatus.unknown;
AuthStatus get status => _status;
AuthSession? _session;
String _deviceId = '';
bool _loaded = false;
Future<void> sendOtp({
required String scene,
required String targetType,
required String target,
}) {
return _repository.sendOtp(
scene: scene,
targetType: targetType,
target: target,
);
}
bool get isLoaded => _loaded;
bool get isSignedIn => _session != null;
AuthUser? get user => _session?.user;
String? get accessToken => _session?.accessToken;
String get deviceId => _deviceId;
Future<void> loginWithCode({
required String targetType,
required String target,
required String code,
}) async {
final session = await _repository.loginWithCode(
targetType: targetType,
target: target,
code: code,
);
await _repository.saveSession(session);
_status = AuthStatus.authenticated;
/// App 启动时调用:恢复本地令牌与设备号。
Future<void> load() async {
_deviceId = await _store.deviceId();
_session = await _store.read();
_loaded = true;
notifyListeners();
}
Future<void> loginWithPassword({
required String targetType,
required String target,
required String password,
}) async {
final session = await _repository.loginWithPassword(
targetType: targetType,
target: target,
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 _repository.saveSession(session);
_status = AuthStatus.authenticated;
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> logout() async {
await _repository.logout();
_status = AuthStatus.unauthenticated;
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);
// 设备号不清:它代表"这台设备",与登录态无关
}
}
+44 -5
View File
@@ -1,28 +1,67 @@
// Author: fruit.com.cn
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;
}
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();
}
}
@@ -0,0 +1,82 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../repositories/category_repository.dart';
/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。
class CategoryController extends ChangeNotifier {
CategoryController(this._repository);
static const String allCode = 'all';
static const String hotCode = 'hot';
static const String newCode = 'new';
final CategoryRepository _repository;
List<ContentCategory> categories = const [];
HomeFeed feed = const HomeFeed();
String selectedCode = allCode;
bool loading = false;
String? error;
bool _disposed = false;
/// 当前筛选对应的分区 code;全部分类为空字符串语义。
bool get selectedSectionMissing =>
selectedCode != allCode && _selectedSection == null;
/// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。
List<Drama> get visibleDramas {
if (selectedCode == allCode) return feed.dramas;
return _selectedSection?.dramas ?? const [];
}
FeedSection? get _selectedSection {
for (final section in feed.sections) {
if (section.code == selectedCode) return section;
}
return null;
}
/// 加载分类与发现 feed;失败保留可重试错误态。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchCategories(),
_repository.fetchDiscoverFeed(),
]);
categories = results[0] as List<ContentCategory>;
feed = results[1] as HomeFeed;
} catch (_) {
error = 'categoriesError';
} finally {
loading = false;
_notify();
}
}
/// 选择服务端分类,内容只匹配 `category-{id}`。
void selectCategory(String id) => selectCode('category-$id');
/// 选择内置或分区 code。
void selectCode(String code) {
selectedCode = code;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,378 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/comment.dart';
import '../models/interaction.dart';
import '../remote/api_client.dart';
import '../repositories/comment_repository.dart';
import '../repositories/interaction_repository.dart';
/// 评论列表、发布、回复、删除与评论点赞的状态控制器。
class CommentController extends ChangeNotifier {
CommentController({
required CommentRepository comments,
required InteractionRepository interactions,
required this.dramaId,
this.episodeId,
}) : _commentRepository = comments,
_interactionRepository = interactions;
final CommentRepository _commentRepository;
final InteractionRepository _interactionRepository;
final String dramaId;
final String? episodeId;
final List<DramaComment> _comments = [];
final Map<String, List<DramaComment>> _replies = {};
final Map<String, String?> _replyCursors = {};
final Map<String, bool> _replyHasMore = {};
final Set<String> _expandedRoots = {};
final Set<String> _loadingReplies = {};
final Set<String> _loadingMoreReplies = {};
final Set<String> _likingCommentIds = {};
final Set<String> _deletingCommentIds = {};
bool _disposed = false;
bool loading = false;
bool loadingMore = false;
bool submitting = false;
bool hasMore = false;
String? nextCursor;
String? error;
/// 当前已加载的顶层评论。
List<DramaComment> get comments => List.unmodifiable(_comments);
/// 某一楼层已加载的回复。
List<DramaComment> repliesFor(String rootId) =>
List.unmodifiable(_replies[rootId] ?? const []);
/// 楼层回复是否已展开。
bool repliesExpanded(String rootId) => _expandedRoots.contains(rootId);
/// 楼层回复是否正在首屏加载。
bool repliesLoading(String rootId) => _loadingReplies.contains(rootId);
/// 楼层回复是否还有下一页。
bool repliesHasMore(String rootId) => _replyHasMore[rootId] ?? false;
/// 评论是否正在提交点赞,用于防连点。
bool likingComment(String id) => _likingCommentIds.contains(id);
void _notify() {
if (!_disposed) notifyListeners();
}
/// 加载首屏评论,成功后替换当前顶层列表。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
size: commentPageSize,
);
_comments
..clear()
..addAll(page.comments);
nextCursor = page.nextCursor;
hasMore = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
loading = false;
_notify();
}
}
/// 使用 `nextCursor` 续拉顶层评论。
Future<void> loadMore() async {
final cursor = nextCursor;
if (loadingMore || !hasMore || cursor == null) return;
loadingMore = true;
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
cursor: cursor,
size: commentPageSize,
);
_comments.addAll(page.comments);
nextCursor = page.nextCursor;
hasMore = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
loadingMore = false;
_notify();
}
}
/// 展开并用 `rootId` 加载一层楼层回复。
Future<void> loadReplies(String rootId) async {
_expandedRoots.add(rootId);
if (_replies.containsKey(rootId) || _loadingReplies.contains(rootId)) {
_notify();
return;
}
_loadingReplies.add(rootId);
error = null;
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
rootId: rootId,
size: commentPageSize,
);
_replies[rootId] = List.of(page.comments);
_replyCursors[rootId] = page.nextCursor;
_replyHasMore[rootId] = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
_loadingReplies.remove(rootId);
_notify();
}
}
/// 收起楼层回复,不丢弃已加载数据。
void hideReplies(String rootId) {
if (_expandedRoots.remove(rootId)) _notify();
}
/// 续拉楼层回复。
Future<void> loadMoreReplies(String rootId) async {
final cursor = _replyCursors[rootId];
if (_loadingMoreReplies.contains(rootId) || cursor == null) return;
if (!(_replyHasMore[rootId] ?? false)) return;
_loadingMoreReplies.add(rootId);
_notify();
try {
final page = await _commentRepository.page(
dramaId: dramaId,
rootId: rootId,
cursor: cursor,
size: commentPageSize,
);
(_replies[rootId] ??= []).addAll(page.comments);
_replyCursors[rootId] = page.nextCursor;
_replyHasMore[rootId] = page.hasMore;
} catch (failure) {
error = _errorMessage(failure) ?? 'commentsLoadFailed';
} finally {
_loadingMoreReplies.remove(rootId);
_notify();
}
}
/// 发布评论或回复,输入框是否清空由调用方根据成功结果决定。
Future<OperationResult<DramaComment>> publish(
String rawContent, {
DramaComment? replyTo,
}) async {
final content = rawContent.trim();
if (content.isEmpty || submitting) {
return const OperationResult.failure(null);
}
submitting = true;
_notify();
try {
final comment = await _commentRepository.publish(
dramaId: dramaId,
episodeId: episodeId,
content: content,
parentId: replyTo?.id,
);
_insertPublished(comment, replyTo);
return OperationResult.success(comment);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
} finally {
submitting = false;
_notify();
}
}
/// 乐观删除自己的评论,失败时恢复到原位置。
Future<OperationResult<void>> delete(DramaComment comment) async {
if (!comment.mine || _deletingCommentIds.contains(comment.id)) {
return const OperationResult.failure(null);
}
_deletingCommentIds.add(comment.id);
final location = _removeComment(comment.id);
_notify();
try {
await _commentRepository.delete(comment.id);
return const OperationResult.success();
} catch (failure) {
_restoreComment(comment, location);
return OperationResult.failure(_errorMessage(failure));
} finally {
_deletingCommentIds.remove(comment.id);
_notify();
}
}
/// 评论点赞先乐观更新,再用服务端 `{liked,count}` 校准。
Future<OperationResult<LikeToggleResult>> toggleLike(
DramaComment comment,
) async {
if (_likingCommentIds.contains(comment.id)) {
return const OperationResult.failure(null);
}
final current = _findComment(comment.id) ?? comment;
_likingCommentIds.add(comment.id);
_replaceComment(
current.copyWith(
liked: !current.liked,
likeCount: current.liked
? (current.likeCount > 0 ? current.likeCount - 1 : 0)
: current.likeCount + 1,
),
);
_notify();
try {
final result = await _interactionRepository.toggleLike(
targetType: InteractionTargetType.comment,
targetId: comment.id,
);
_replaceComment(
current.copyWith(liked: result.liked, likeCount: result.count),
);
return OperationResult.success(result);
} catch (failure) {
_replaceComment(current);
return OperationResult.failure(_errorMessage(failure));
} finally {
_likingCommentIds.remove(comment.id);
_notify();
}
}
/// 举报评论,失败消息原样返回给界面展示。
Future<OperationResult<String>> report(
DramaComment comment, {
required ReportReason reason,
String description = '',
}) async {
try {
final reportId = await _interactionRepository.report(
ReportRequest(
targetType: InteractionTargetType.comment,
targetId: comment.id,
reasonCode: reason.apiValue,
description: description.trim(),
),
);
return OperationResult.success(reportId);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
}
}
void _insertPublished(DramaComment comment, DramaComment? replyTo) {
final rootId = comment.rootId != '0'
? comment.rootId
: replyTo?.isTopLevel == true
? replyTo!.id
: replyTo?.rootId;
if (replyTo == null || rootId == null || rootId == '0') {
if (!_comments.any((item) => item.id == comment.id)) {
_comments.insert(0, comment);
}
return;
}
final replies = _replies.putIfAbsent(rootId, () => []);
if (!replies.any((item) => item.id == comment.id)) {
replies.insert(0, comment);
}
_expandedRoots.add(rootId);
final rootIndex = _comments.indexWhere((item) => item.id == rootId);
if (rootIndex >= 0) {
final root = _comments[rootIndex];
_comments[rootIndex] = root.copyWith(replyCount: root.replyCount + 1);
}
}
_CommentLocation? _removeComment(String id) {
final topIndex = _comments.indexWhere((item) => item.id == id);
if (topIndex >= 0) {
final removed = _comments.removeAt(topIndex);
return _CommentLocation(comment: removed, topIndex: topIndex);
}
for (final entry in _replies.entries) {
final index = entry.value.indexWhere((item) => item.id == id);
if (index < 0) continue;
final removed = entry.value.removeAt(index);
return _CommentLocation(
comment: removed,
rootId: entry.key,
replyIndex: index,
);
}
return null;
}
void _restoreComment(DramaComment comment, _CommentLocation? location) {
if (location == null) return;
if (location.topIndex != null) {
final index = location.topIndex!.clamp(0, _comments.length);
_comments.insert(index, comment);
return;
}
final replies = _replies.putIfAbsent(location.rootId!, () => []);
final index = location.replyIndex!.clamp(0, replies.length);
replies.insert(index, comment);
}
DramaComment? _findComment(String id) {
for (final comment in _comments) {
if (comment.id == id) return comment;
}
for (final replies in _replies.values) {
for (final comment in replies) {
if (comment.id == id) return comment;
}
}
return null;
}
void _replaceComment(DramaComment replacement) {
final topIndex = _comments.indexWhere((item) => item.id == replacement.id);
if (topIndex >= 0) {
_comments[topIndex] = replacement;
return;
}
for (final replies in _replies.values) {
final index = replies.indexWhere((item) => item.id == replacement.id);
if (index >= 0) {
replies[index] = replacement;
return;
}
}
}
String? _errorMessage(Object failure) =>
failure is ApiException ? failure.message : null;
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class _CommentLocation {
const _CommentLocation({
required this.comment,
this.topIndex,
this.rootId,
this.replyIndex,
});
final DramaComment comment;
final int? topIndex;
final String? rootId;
final int? replyIndex;
}
@@ -0,0 +1,168 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../remote/api_exception.dart';
import '../repositories/download_repository.dart';
/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。
class DownloadActionResult {
const DownloadActionResult({required this.ok, this.license, this.message});
final bool ok;
final DownloadLicense? license;
final String? message;
}
/// 下载授权与策略状态。
class DownloadController extends ChangeNotifier {
DownloadController(this._repository);
final DownloadRepository _repository;
DownloadPolicy? policy;
List<DownloadLicense> licenses = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 同时拉取策略与当前设备授权列表。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchPolicy(),
_repository.list(),
]);
policy = results[0] as DownloadPolicy;
licenses = results[1] as List<DownloadLicense>;
} catch (_) {
error = 'downloadsError';
} finally {
loading = false;
_notify();
}
}
/// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。
Future<DownloadActionResult> grant(
String episodeId, {
String? quality,
}) async {
try {
final license = await _repository.grant(episodeId, quality: quality);
licenses = [
license,
for (final item in licenses)
if (item.episodeId != license.episodeId) item,
];
actionError = null;
_notify();
return DownloadActionResult(ok: true, license: license);
} on ApiException catch (exception) {
actionError = exception.message;
_notify();
return DownloadActionResult(ok: false, message: exception.message);
} catch (_) {
actionError = 'downloadGrantFailed';
_notify();
return const DownloadActionResult(ok: false);
}
}
/// 更新设置,先本地展示,失败恢复服务端旧值。
Future<bool> updateSettings({bool? wifiOnly, String? maxQuality}) async {
final previous = policy;
if (previous != null) {
policy = previous.copyWith(wifiOnly: wifiOnly, maxQuality: maxQuality);
_notify();
}
try {
await _repository.updateSettings(
wifiOnly: wifiOnly,
maxQuality: maxQuality,
);
actionError = null;
return true;
} catch (_) {
policy = previous;
actionError = 'downloadSettingsFailed';
return false;
} finally {
_notify();
}
}
/// 删除单条授权,失败按原位置恢复。
Future<bool> remove(String episodeId) async {
final index = licenses.indexWhere((item) => item.episodeId == episodeId);
if (index < 0) return true;
final removed = licenses[index];
licenses = [...licenses]..removeAt(index);
actionError = null;
_notify();
try {
await _repository.remove(episodeId);
return true;
} catch (_) {
final restored = [...licenses];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
licenses = restored;
actionError = 'downloadDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空授权,失败恢复原列表。
Future<bool> clear() async {
if (licenses.isEmpty) return true;
final previous = licenses;
licenses = const [];
clearing = true;
actionError = null;
_notify();
try {
await _repository.clear();
return true;
} catch (_) {
licenses = previous;
actionError = 'downloadClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
/// 退出登录时清空账号相关状态,避免跨账号串联。
void reset() {
policy = null;
licenses = const [];
loading = false;
clearing = false;
error = null;
actionError = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -1,80 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/engagement_snapshot.dart';
import '../repositories/engagement_repository.dart';
/// Local demo interactions, indexed by drama ID. No public/social backend.
class EngagementController extends ChangeNotifier {
EngagementController(this._repository);
final EngagementRepository _repository;
final Set<String> _saved = {};
final Set<String> _liked = {};
final Map<String, List<String>> _comments = {};
Future<void> _loadFuture = Future<void>.value();
Future<void> _pendingSave = Future<void>.value();
bool isSaved(String dramaId) => _saved.contains(dramaId);
bool isLiked(String dramaId) => _liked.contains(dramaId);
List<String> commentsFor(String dramaId) =>
List.unmodifiable(_comments[dramaId] ?? const []);
Future<void> load() => _loadFuture = _loadFromRepository();
Future<void> _loadFromRepository() async {
try {
final snapshot = await _repository.load();
_saved.addAll(snapshot.savedDramaIds);
_liked.addAll(snapshot.likedDramaIds);
for (final entry in snapshot.commentsByDrama.entries) {
_comments[entry.key] = List.of(entry.value);
}
notifyListeners();
} catch (_) {
// Local preferences are best effort for this demo.
}
}
Future<void> toggleSaved(String dramaId) async {
await _loadFuture;
if (!_saved.add(dramaId)) _saved.remove(dramaId);
notifyListeners();
await _persist();
}
Future<void> toggleLiked(String dramaId) async {
await _loadFuture;
if (!_liked.add(dramaId)) _liked.remove(dramaId);
notifyListeners();
await _persist();
}
Future<void> addComment(String dramaId, String text) async {
await _loadFuture;
final clean = text.trim();
if (clean.isEmpty) return;
(_comments[dramaId] ??= []).add(
clean.length > 300 ? clean.substring(0, 300) : clean,
);
notifyListeners();
await _persist();
}
Future<void> _persist() {
final snapshot = EngagementSnapshot(
savedDramaIds: Set.of(_saved),
likedDramaIds: Set.of(_liked),
commentsByDrama: {
for (final entry in _comments.entries) entry.key: List.of(entry.value),
},
);
_pendingSave = _pendingSave
.then((_) => _repository.save(snapshot))
.catchError((Object _) {
// Keep the current in-memory state if local persistence fails.
});
return _pendingSave;
}
}
-64
View File
@@ -1,64 +0,0 @@
// Author: fruit.com.cn
import 'dart:async';
import 'api_client.dart';
/// 核心埋点事件名(第一阶段口径,后续与运营对齐后可扩展)。
class AppEvents {
AppEvents._();
static const String playbackStart = 'playback_start';
static const String playbackEnd = 'playback_end';
static const String paySuccess = 'pay_success';
static const String register = 'register';
static const String click = 'click';
}
/// 埋点上报:本地缓冲、批量上报,失败静默丢弃不影响主流程。
class EventTracker {
EventTracker(this._api);
final ApiClient _api;
static const int _flushSize = 10;
static const Duration _flushInterval = Duration(seconds: 10);
final List<Map<String, Object?>> _buffer = [];
Timer? _timer;
void track(
String eventName, {
String? page,
Map<String, Object?>? properties,
}) {
_buffer.add({
'eventName': eventName,
'eventTime': DateTime.now().toIso8601String(),
'page': page,
'properties': properties,
});
if (_buffer.length >= _flushSize) {
flush();
} else {
_timer ??= Timer(_flushInterval, flush);
}
}
Future<void> flush() async {
if (_buffer.isEmpty) return;
final events = List<Map<String, Object?>>.from(_buffer);
_buffer.clear();
_timer?.cancel();
_timer = null;
try {
await _api.post('/app/events', data: {
'deviceId': 'device-0001',
'sessionId': 'session-0001',
'events': events,
});
} catch (_) {
// 埋点失败不影响主流程,静默丢弃。
}
}
}
@@ -0,0 +1,127 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../repositories/favorite_repository.dart';
/// 收藏状态:**以服务端为准**。
///
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository);
final FavoriteRepository _repository;
final Set<String> _saved = {};
List<Drama> cards = const [];
bool loading = false;
String? error;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved);
bool isSaved(String dramaId) => _saved.contains(dramaId);
void notify() {
if (!_disposed) notifyListeners();
}
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
loading = true;
error = null;
notify();
try {
final list = await _repository.list();
cards = list;
_saved
..clear()
..addAll(list.map((drama) => drama.id));
} catch (_) {
error = 'favoritesError';
} finally {
loading = false;
notify();
}
}
/// 未登录时不要调用(服务端要求登录态)。
///
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
final wasSaved = _saved.contains(dramaId);
// 乐观更新
if (wasSaved) {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
} else {
_saved.add(dramaId);
}
notify();
try {
final saved = await _repository.toggle(dramaId);
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
if (saved) {
_saved.add(dramaId);
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
}
} else {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
}
notify();
return true;
} catch (_) {
// 回滚
if (wasSaved) {
_saved.add(dramaId);
} else {
_saved.remove(dramaId);
}
notify();
return false;
}
}
/// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async {
final index = cards.indexWhere((drama) => drama.id == dramaId);
if (index < 0) return true;
final removed = cards[index];
_saved.remove(dramaId);
final next = [...cards]..removeAt(index);
cards = next;
notify();
try {
await _repository.remove(dramaId);
return true;
} catch (_) {
final restored = [...cards];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
cards = restored;
_saved.add(dramaId);
notify();
return false;
}
}
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_saved.clear();
cards = const [];
error = null;
notify();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,107 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/interaction.dart';
import '../remote/api_client.dart';
import '../repositories/interaction_repository.dart';
/// 剧集点赞与举报的会话状态控制器。
///
/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态,
/// 不展示数字,也不伪造初始值。
class InteractionController extends ChangeNotifier {
InteractionController(this._repository);
final InteractionRepository _repository;
final Set<String> _likedDramaIds = {};
final Set<String> _busyDramaIds = {};
bool _disposed = false;
/// 当前会话内该剧是否已点赞。
bool isLiked(String dramaId) => _likedDramaIds.contains(dramaId);
/// 点赞请求是否进行中,用于防连点。
bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId);
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
String dramaId,
) async {
if (_busyDramaIds.contains(dramaId)) {
return const OperationResult.failure(null);
}
final wasLiked = _likedDramaIds.contains(dramaId);
_busyDramaIds.add(dramaId);
if (wasLiked) {
_likedDramaIds.remove(dramaId);
} else {
_likedDramaIds.add(dramaId);
}
_notify();
try {
final result = await _repository.toggleLike(
targetType: InteractionTargetType.drama,
targetId: dramaId,
);
if (result.liked) {
_likedDramaIds.add(dramaId);
} else {
_likedDramaIds.remove(dramaId);
}
return OperationResult.success(result);
} catch (failure) {
if (wasLiked) {
_likedDramaIds.add(dramaId);
} else {
_likedDramaIds.remove(dramaId);
}
return OperationResult.failure(_errorMessage(failure));
} finally {
_busyDramaIds.remove(dramaId);
_notify();
}
}
/// 举报剧集,失败消息原样返回给界面。
Future<OperationResult<String>> reportDrama(
String dramaId, {
required ReportReason reason,
String description = '',
}) async {
try {
final reportId = await _repository.report(
ReportRequest(
targetType: InteractionTargetType.drama,
targetId: dramaId,
reasonCode: reason.apiValue,
description: description.trim(),
),
);
return OperationResult.success(reportId);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
}
}
/// 登录账号变化时清理会话内状态,避免跨账号串联。
void clear() {
if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return;
_likedDramaIds.clear();
_busyDramaIds.clear();
_notify();
}
String? _errorMessage(Object failure) =>
failure is ApiException ? failure.message : null;
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,118 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/notice.dart';
import '../remote/api_client.dart';
import '../repositories/notice_repository.dart';
/// Pagination and state controller for the anonymous notices page.
class NoticesController extends ChangeNotifier {
NoticesController(this._repository, {this.pageSize = 20});
final NoticeRepository _repository;
final int pageSize;
final List<AppNotice> _notices = [];
int _pageIndex = 0;
int _total = 0;
bool _disposed = false;
bool loading = false;
bool loadingMore = false;
bool refreshing = false;
String? error;
String? loadMoreError;
/// Loaded notices in server order.
List<AppNotice> get notices => List.unmodifiable(_notices);
/// Whether another page is expected from the latest total.
bool get hasMore => _notices.length < _total;
/// Loads the first page.
Future<void> load() async {
loading = true;
error = null;
loadMoreError = null;
_notify();
await _replaceFirstPage();
loading = false;
_notify();
}
/// Refreshes from page one while keeping the current page visible.
Future<void> refresh() async {
refreshing = true;
error = null;
loadMoreError = null;
_notify();
await _replaceFirstPage();
refreshing = false;
_notify();
}
/// Appends the next page when loaded count is below total.
Future<void> loadMore() async {
if (loading || loadingMore || refreshing || !hasMore) return;
loadingMore = true;
loadMoreError = null;
_notify();
try {
final page = await _repository.page(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
_appendUnique(page.notices);
_pageIndex++;
_total = page.total;
} catch (failure, stackTrace) {
debugPrint('[NoticesController] load more failed: $failure');
debugPrintStack(
stackTrace: stackTrace,
label: '[NoticesController] load more failed',
);
loadMoreError = _message(failure) ?? 'noticesLoadFailed';
} finally {
loadingMore = false;
_notify();
}
}
Future<void> _replaceFirstPage() async {
try {
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
if (_disposed) return;
_notices
..clear()
..addAll(page.notices);
_pageIndex = 1;
_total = page.total;
} catch (failure, stackTrace) {
debugPrint('[NoticesController] load failed: $failure');
debugPrintStack(
stackTrace: stackTrace,
label: '[NoticesController] load failed',
);
if (!_disposed) error = _message(failure) ?? 'noticesLoadFailed';
}
}
void _appendUnique(List<AppNotice> incoming) {
final ids = _notices.map((notice) => notice.id).toSet();
for (final notice in incoming) {
if (ids.add(notice.id)) _notices.add(notice);
}
}
String? _message(Object failure) =>
failure is ApiException ? failure.message : null;
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+13 -1
View File
@@ -130,6 +130,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;
@@ -151,14 +154,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,
-34
View File
@@ -1,34 +0,0 @@
// Author: fruit.com.cn
import 'package:shared_preferences/shared_preferences.dart';
/// 令牌本地存储。第一阶段用 shared_preferences,后续可换 secure storage。
class TokenStorage {
static const _accessKey = 'auth_access_token';
static const _refreshKey = 'auth_refresh_token';
Future<void> saveTokens({
required String access,
required String refresh,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accessKey, access);
await prefs.setString(_refreshKey, refresh);
}
Future<String?> readAccessToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_accessKey);
}
Future<String?> readRefreshToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_refreshKey);
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_accessKey);
await prefs.remove(_refreshKey);
}
}
+20 -5
View File
@@ -2,6 +2,7 @@
import 'package:video_player/video_player.dart';
import '../../config/app_config.dart';
import '../models/drama.dart';
import '../models/player_port.dart';
@@ -9,11 +10,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;
@@ -0,0 +1,224 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../models/watch_progress.dart';
import '../models/watch_record.dart';
import '../remote/api_client.dart';
import '../repositories/catalog_repository.dart';
import '../repositories/watch_history_repository.dart';
/// 独立观看记录页状态。
///
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
class WatchRecordsController extends ChangeNotifier {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int limit = defaultLimit,
}) => WatchRecordsController._(history, catalog, limit);
WatchRecordsController._(this._history, this._catalog, this.limit);
static const int defaultLimit = 20;
static const int detailConcurrency = 4;
final WatchHistoryRepository _history;
final CatalogRepository _catalog;
final int limit;
final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
Future<void> load() async {
loading = true;
error = null;
actionError = null;
_notify();
try {
final recent = await _history.recent(limit: limit);
if (_disposed) return;
records = _uniqueRecords(recent);
_notify();
await _hydrate(records.map((record) => record.dramaId).toList());
} catch (failure, stackTrace) {
debugPrint(
'[WatchRecordsController] recent failed '
'${failure.runtimeType}: $failure',
);
debugPrintStack(
stackTrace: stackTrace,
label: '[WatchRecordsController] recent failed',
);
if (!_disposed) {
error = failure is ApiException ? failure.message : 'progressError';
}
} finally {
if (!_disposed) {
loading = false;
_notify();
}
}
}
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
_replaceAt(
index,
records[index].copyWith(detailLoading: true, detailFailed: false),
);
_notify();
await _loadDetail(dramaId);
_notify();
}
/// 删除单条记录,先更新 UI,失败按原位置回滚。
Future<bool> remove(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
actionError = null;
_notify();
try {
await _history.remove(dramaId);
return true;
} catch (_) {
final next = [...records];
next.insert(index > next.length ? next.length : index, removed);
records = next;
actionError = 'recordDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
final previous = records;
records = const [];
clearing = true;
actionError = null;
_notify();
try {
await _history.clear();
return true;
} catch (_) {
records = previous;
actionError = 'recordClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
final seen = <String>{};
return [
for (final progress in recent)
if (seen.add(progress.dramaId)) WatchRecord(progress: progress),
];
}
Future<void> _hydrate(List<String> dramaIds) async {
final queue = [
for (final id in dramaIds)
if (!_detailCache.containsKey(id)) id,
];
final workerCount = queue.length < detailConcurrency
? queue.length
: detailConcurrency;
Future<void> worker() async {
while (queue.isNotEmpty && !_disposed) {
await _loadDetail(queue.removeAt(0));
}
}
await Future.wait([
for (var index = 0; index < workerCount; index++) worker(),
]);
}
Future<void> _loadDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
final cached = _detailCache[dramaId];
if (cached != null) {
_replaceAt(
index,
records[index].copyWith(
drama: cached,
detailLoading: false,
detailFailed: false,
),
);
return;
}
try {
final detail = await _catalog.fetchDrama(dramaId);
if (_disposed) return;
_detailCache[dramaId] = detail;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(
drama: detail,
detailLoading: false,
detailFailed: false,
),
);
} catch (failure, stackTrace) {
debugPrint(
'[WatchRecordsController] detail failed dramaId=$dramaId '
'${failure.runtimeType}: $failure',
);
debugPrintStack(
stackTrace: stackTrace,
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
);
if (_disposed) return;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(detailLoading: false, detailFailed: true),
);
}
}
int _recordIndex(String dramaId) {
for (var index = 0; index < records.length; index++) {
if (records[index].dramaId == dramaId) return index;
}
return -1;
}
void _replaceAt(int index, WatchRecord record) {
final next = [...records];
next[index] = record;
records = next;
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
import '../../l10n/app_localizations.dart';
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
String errorText(AppLocalizations l10n, String? code, String fallback) {
if (code == null || code.isEmpty) return fallback;
return switch (code) {
'networkError' => l10n.networkError,
'progressError' => l10n.progressError,
'favoritesError' => l10n.favoritesError,
'ticketError' => l10n.playbackError,
'commentsLoadFailed' => l10n.commentsLoadFailed,
'noticesLoadFailed' => l10n.noticesLoadFailed,
final value => value,
};
}
@@ -0,0 +1,15 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
/// Formats a timestamp with the active Material locale and 12/24-hour setting.
String formatLocalizedDateTime(BuildContext context, DateTime value) {
final localizations = MaterialLocalizations.of(context);
final local = value.toLocal();
final date = localizations.formatCompactDate(local);
final time = localizations.formatTimeOfDay(
TimeOfDay.fromDateTime(local),
alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context),
);
return '$date $time';
}
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
import 'package:flutter/services.dart';
/// User-action haptic feedback boundary.
abstract interface class Haptics {
/// Feedback for a selection change.
Future<void> selectionClick();
/// Feedback for a primary action.
Future<void> lightImpact();
/// Feedback for a destructive action.
Future<void> mediumImpact();
}
/// Default implementation backed by Flutter's platform haptics.
class FlutterHaptics implements Haptics {
const FlutterHaptics();
@override
Future<void> selectionClick() =>
_ignoreUnavailable(HapticFeedback.selectionClick);
@override
Future<void> lightImpact() => _ignoreUnavailable(HapticFeedback.lightImpact);
@override
Future<void> mediumImpact() =>
_ignoreUnavailable(HapticFeedback.mediumImpact);
Future<void> _ignoreUnavailable(Future<void> Function() feedback) async {
try {
await feedback();
} catch (_) {
// Haptics are optional and may be unavailable on the target device.
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
/// Shared colors. Blue is the primary action color.
abstract final class AppColors {
/// Klein Blue primary used by actions and selected states.
static const blue = Color(0xFF002FA7);
/// Pressed/active shade for blue surfaces.
static const bluePressed = Color(0xFF00227A);
/// High-contrast blue for text and icons on dark surfaces.
static const blueSoft = Color(0xFF5B7BE0);
/// Text and icons placed directly on [blue].
static const onPrimary = Color(0xFFFFFFFF);
static const ink = Color(0xFF0B0C10);
static const bar = Color(0xFF101114);
static const card = Color(0xFF17191F);
static const muted = Color(0xFFB7BDC9);
static const outlineDark = Color(0xFF3A3C45);
static const outlineVariantDark = Color(0xFF242329);
static const outlineLight = Color(0xFF8A8E99);
static const outlineVariantLight = Color(0xFFD6D9E2);
}
+110
View File
@@ -0,0 +1,110 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// App-wide light and dark themes built from the approved Klein Blue palette.
abstract final class AppTheme {
static const _lightBackground = Color(0xFFF8F7FC);
static const _darkBackground = Color(0xFF101019);
/// Light theme for system light mode.
static ThemeData light() => _build(
brightness: Brightness.light,
surface: _lightBackground,
accent: AppColors.blue,
);
/// Dark theme for system dark mode.
static ThemeData dark() => _build(
brightness: Brightness.dark,
surface: _darkBackground,
accent: AppColors.blueSoft,
);
static ThemeData _build({
required Brightness brightness,
required Color surface,
required Color accent,
}) {
final dark = brightness == Brightness.dark;
final scheme = dark
? const ColorScheme.dark(
primary: AppColors.blue,
onPrimary: AppColors.onPrimary,
secondary: AppColors.blueSoft,
onSecondary: AppColors.ink,
primaryContainer: AppColors.bluePressed,
onPrimaryContainer: AppColors.onPrimary,
secondaryContainer: AppColors.bluePressed,
onSecondaryContainer: AppColors.onPrimary,
surface: _darkBackground,
onSurface: Colors.white,
outline: AppColors.outlineDark,
outlineVariant: AppColors.outlineVariantDark,
surfaceTint: AppColors.blueSoft,
inversePrimary: AppColors.blueSoft,
)
: ColorScheme.light(
primary: AppColors.blue,
onPrimary: AppColors.onPrimary,
secondary: AppColors.blue,
onSecondary: AppColors.onPrimary,
primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
onPrimaryContainer: AppColors.ink,
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
onSecondaryContainer: AppColors.ink,
surface: _lightBackground,
onSurface: AppColors.ink,
outline: AppColors.outlineLight,
outlineVariant: AppColors.outlineVariantLight,
surfaceTint: AppColors.blue,
inversePrimary: AppColors.blueSoft,
);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: surface,
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
sliderTheme: SliderThemeData(
activeTrackColor: accent,
thumbColor: AppColors.onPrimary,
),
tabBarTheme: TabBarThemeData(labelColor: accent, indicatorColor: accent),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: accent),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(_buttonColor),
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? AppColors.onPrimary
: null,
),
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? accent : null,
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.bluePressed,
contentTextStyle: const TextStyle(color: AppColors.onPrimary),
),
);
}
static Color _buttonColor(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed) ||
states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return AppColors.bluePressed;
}
return AppColors.blue;
}
}
+122
View File
@@ -0,0 +1,122 @@
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;
}
/// 页面级登录引导,用于记录、收藏和下载等必须登录的独立页面。
class AuthGatePanel extends StatelessWidget {
const AuthGatePanel({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
key: const Key('auth-gate-panel'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.authGateTitle,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
l10n.authGateMessage,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 18),
FilledButton(
onPressed: () => openAuthPage(context),
child: Text(l10n.authGateAction),
),
],
),
),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../data/services/download_controller.dart';
import '../platform/haptics.dart';
import 'auth_gate.dart';
/// 详情页与播放页共用的下载授权入口。
///
/// 未登录先走登录闸;业务拒绝时直接展示后端 message。
Future<void> requestEpisodeDownload(
BuildContext context,
String episodeId,
) async {
if (!await ensureSignedIn(context)) return;
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await context.read<DownloadController>().grant(episodeId);
if (!context.mounted) return;
final l10n = AppLocalizations.of(context)!;
final hasBackendMessage = result.message?.trim().isNotEmpty ?? false;
final message = result.ok
? l10n.downloadGranted
: hasBackendMessage
? result.message!
: l10n.downloadGrantFailed;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
+153
View File
@@ -0,0 +1,153 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
const double _regularArtSize = 84;
const double _compactArtSize = 60;
const double _regularInnerSize = 56;
const double _compactInnerSize = 40;
const double _regularIconSize = 27;
const double _compactIconSize = 21;
const double _maxContentWidth = 260;
const double _actionMinHeight = 44;
const double _outerArtOpacity = 0.055;
const double _outerBorderOpacity = 0.11;
const double _innerArtOpacity = 0.11;
/// 统一空列表占位:柔和图标、标题、可选说明与操作。
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
this.compact = false,
});
final IconData icon;
final String title;
final String? subtitle;
final String? actionLabel;
final VoidCallback? onAction;
final bool compact;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final dark = Theme.of(context).brightness == Brightness.dark;
final iconColor = dark ? AppColors.blueSoft : AppColors.blue;
final artSize = compact ? _compactArtSize : _regularArtSize;
final innerSize = compact ? _compactInnerSize : _regularInnerSize;
final iconSize = compact ? _compactIconSize : _regularIconSize;
final hasAction = actionLabel != null && onAction != null;
return Center(
child: ConstrainedBox(
key: const Key('empty-state-content'),
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
child: Padding(
key: const Key('empty-state-padding'),
padding: EdgeInsets.symmetric(
horizontal: compact ? 16 : 24,
vertical: compact ? 16 : 28,
),
child: Semantics(
container: true,
label: title,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ExcludeSemantics(
child: Container(
key: const Key('empty-state-art'),
width: artSize,
height: artSize,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.blue.withValues(alpha: _outerArtOpacity),
border: Border.all(
color: AppColors.blue.withValues(
alpha: _outerBorderOpacity,
),
),
),
child: Container(
key: const Key('empty-state-art-inner'),
width: innerSize,
height: innerSize,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.blue.withValues(
alpha: _innerArtOpacity,
),
),
child: Icon(
icon,
key: const Key('empty-state-icon'),
size: iconSize,
color: iconColor,
),
),
),
),
SizedBox(height: compact ? 10 : 14),
ExcludeSemantics(
child: Text(
title,
key: const Key('empty-state-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: scheme.onSurface,
fontSize: 15,
fontWeight: FontWeight.w600,
height: 1.35,
),
),
),
if (subtitle != null) ...[
const SizedBox(height: 6),
ExcludeSemantics(
child: Text(
subtitle!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12.5,
height: 1.45,
),
),
),
],
if (hasAction) ...[
const SizedBox(height: 8),
TextButton(
key: const Key('empty-state-action'),
onPressed: onAction,
style: TextButton.styleFrom(
foregroundColor: scheme.primary,
minimumSize: const Size(44, _actionMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 14),
),
child: Text(
actionLabel!,
style: const TextStyle(fontSize: 13),
),
),
],
],
),
),
),
),
);
}
}
+73
View File
@@ -0,0 +1,73 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../data/models/drama.dart';
import '../theme/app_colors.dart';
import 'remote_image.dart';
/// 收藏等账号列表复用的剧集卡片,视觉与"我的"页列表保持一致。
class LibraryDramaTile extends StatelessWidget {
const LibraryDramaTile({
super.key,
required this.drama,
required this.onTap,
this.trailing,
});
final Drama drama;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(9),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 7),
Text(
drama.genre.isEmpty ? drama.category : drama.genre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
],
),
),
?trailing,
],
),
),
),
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../config/app_config.dart';
import '../theme/app_colors.dart';
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
class RemoteImage extends StatelessWidget {
const RemoteImage(
this.path, {
super.key,
this.width,
this.height,
this.fit = BoxFit.cover,
this.showFallbackIcon = false,
this.fallbackIconKey,
});
final String path;
final double? width;
final double? height;
final BoxFit fit;
final bool showFallbackIcon;
final Key? fallbackIconKey;
@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: AppColors.bar,
alignment: Alignment.center,
child: showFallbackIcon
? Icon(
Icons.image_not_supported_outlined,
key: fallbackIconKey,
color: AppColors.muted,
size: 32,
)
: null,
);
}
}
@@ -0,0 +1,128 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../config/app_config.dart';
import '../config/server_endpoint.dart';
import '../theme/app_colors.dart';
/// Opens the debug-only runtime server address editor.
Future<bool> showServerEndpointDialog(
BuildContext context,
AppLocalizations l10n,
) async {
final changed = await showDialog<bool>(
context: context,
builder: (_) => _ServerEndpointDialog(l10n: l10n),
);
if (changed == true && context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.restartToApply)));
return true;
}
return false;
}
class _ServerEndpointDialog extends StatefulWidget {
const _ServerEndpointDialog({required this.l10n});
final AppLocalizations l10n;
@override
State<_ServerEndpointDialog> createState() => _ServerEndpointDialogState();
}
class _ServerEndpointDialogState extends State<_ServerEndpointDialog> {
late final TextEditingController _controller = TextEditingController(
text: AppConfig.apiBaseUrl,
);
String? _error;
bool _saving = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _save() async {
final normalized = ServerEndpoint.normalize(_controller.text);
if (normalized == null) {
setState(() => _error = widget.l10n.invalidServerAddress);
return;
}
setState(() {
_saving = true;
_error = null;
});
final saved = await ServerEndpoint.save(normalized);
if (!mounted) return;
if (!saved) {
setState(() {
_saving = false;
_error = widget.l10n.invalidServerAddress;
});
return;
}
Navigator.pop(context, true);
}
Future<void> _restoreDefault() async {
setState(() => _saving = true);
await ServerEndpoint.clear();
if (mounted) Navigator.pop(context, true);
}
@override
Widget build(BuildContext context) {
final l10n = widget.l10n;
return AlertDialog(
title: Text(l10n.serverAddress),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
key: const Key('server-endpoint-input'),
controller: _controller,
enabled: !_saving,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
hintText: l10n.serverAddressHint,
errorText: _error,
errorMaxLines: 2,
),
),
const SizedBox(height: 10),
Text(
l10n.restartToApply,
key: const Key('server-endpoint-restart-hint'),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
const SizedBox(height: 4),
TextButton(
key: const Key('server-endpoint-restore'),
onPressed: _saving ? null : _restoreDefault,
child: Text(l10n.restoreDefault),
),
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: Text(l10n.cancel),
),
FilledButton(
key: const Key('server-endpoint-save'),
onPressed: _saving ? null : _save,
child: Text(l10n.saveAddress),
),
],
);
}
}
+267 -11
View File
@@ -1,6 +1,6 @@
{
"@@locale": "en",
"appName": "Nontonki",
"appName": "FruitShorts",
"demo": "DEMO",
"homeHeadline": "Watch now",
"homeSubtitle": "Free short drama preview.",
@@ -28,13 +28,20 @@
"videoPending": "Video pending",
"watchNow": "Watch free",
"noSearchResults": "No matching dramas.",
"emptyHint": "New content will appear here.",
"demoCatalogNotice": "Three demo dramas share the same local video.",
"myTab": "My",
"localViewer": "Local Viewer",
"localDemoProfile": "Local demo profile",
"continueWatching": "Continue Watching",
"watchedPercent": "Watched {percent}%",
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"@watchedPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"resume": "Resume",
"history": "History",
"watchlist": "Watchlist",
@@ -49,6 +56,7 @@
"animatedTab": "Animated",
"recommendedTab": "Recommended",
"noCategoryDramas": "No dramas in this category yet.",
"allDramas": "All Dramas",
"trendingTab": "Trending",
"liveActionTab": "Live Action",
"search": "Search",
@@ -59,10 +67,50 @@
"save": "Save",
"comments": "Comments",
"share": "Share",
"localOnlyComments": "Only visible on this device",
"noComments": "No comments yet.",
"writeComment": "Write a comment",
"postComment": "Post",
"loadMore": "Load more",
"commentsLoadFailed": "Comments could not be loaded.",
"commentPostFailed": "Could not post your comment.",
"delete": "Delete",
"deleteComment": "Delete comment",
"deleteCommentTitle": "Delete this comment?",
"deleteCommentMessage": "This removes your comment from the conversation.",
"commentDeleteFailed": "Could not delete this comment.",
"underReview": "Under review",
"reply": "Reply",
"replyingTo": "Replying to {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "Cancel reply",
"viewReplies": "View replies ({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "Hide replies",
"anonymousUser": "Viewer",
"more": "More",
"likeFailed": "Could not update like. Please retry.",
"reportComment": "Report comment",
"reportDrama": "Report drama",
"reportReason": "Reason",
"reportReasonSpam": "Spam",
"reportReasonAbuse": "Harassment",
"reportReasonPorn": "Sexual content",
"reportReasonAd": "Advertising",
"reportReasonOther": "Other",
"reportDescriptionHint": "Describe the issue (optional)",
"reportSubmit": "Submit report",
"reportSubmitted": "Report submitted. Thank you.",
"reportFailed": "Could not submit report.",
"shareFailed": "Could not open the share sheet.",
"nowPlaying": "Playing now",
"watchFullscreen": "Watch in landscape",
@@ -72,16 +120,40 @@
"originalTag": "Original",
"dramaTag": "Short drama",
"episodeLabel": "Episode {number}",
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"@episodeLabel": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"exploreSeries": "Explore series",
"episodeCount": "{count} EPISODES",
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
"@episodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"technicalDemoCount": "FREE PREVIEW · {count} EPISODES",
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
"@technicalDemoCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"episodes": "Episodes",
"watchEpisodeOne": "Watch episode 1",
"continueEpisode": "Continue episode {number}",
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
"@continueEpisode": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"freeTestMedia": "Same demo clip · free",
"lockedStoreUnavailable": "Locked · store unavailable",
"freeTestClip": "Free test clip",
@@ -89,14 +161,49 @@
"freeSamplesNotice": "Two free samples · store setup pending",
"demoTestMedia": "FREE · SAME DEMO CLIP",
"episodeProgress": "EPISODE {number} / {total}",
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
"@episodeProgress": {
"placeholders": {
"number": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"testVideoNotice": "Self-generated technical test video",
"lockedTitle": "Episode {number} is locked",
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
"@lockedTitle": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"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.",
"categoriesError": "Categories 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.",
"notices": "Notices",
"noNotices": "No notices yet.",
"noticesLoadFailed": "Notices could not be loaded.",
"noticeDetails": "Notice details",
"noticeAnnouncement": "Notice",
"close": "Close",
"carouselSemantics": "Banner {current} of {total}",
"@carouselSemantics": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"retry": "Retry",
"backToSeries": "Back to series",
"nextEpisode": "Next episode",
@@ -112,6 +219,7 @@
"systemLanguage": "System",
"english": "English",
"simplifiedChinese": "简体中文",
"bahasaIndonesia": "Bahasa Indonesia",
"episodeOneTitle": "First Light",
"episodeTwoTitle": "A New Frequency",
"episodeThreeTitle": "Crossing Over",
@@ -129,6 +237,154 @@
"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",
"serverAddress": "Server address",
"serverSettings": "Server settings",
"serverAddressHint": "http(s)://host:port",
"saveAddress": "Save address",
"restartToApply": "Restart the app to apply.",
"restoreDefault": "Restore default",
"invalidServerAddress": "Enter a valid http(s) server address.",
"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.",
"authBrandTagline": "Short dramas made for Indonesia",
"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": "Watch history, favorites, comments and downloads need an account.",
"authGateAction": "Sign In",
"showPassword": "Show password",
"hidePassword": "Hide password",
"cancel": "Not now",
"noEpisodesYet": "No playable episodes yet.",
"networkError": "Network is unreachable. Please check your connection and retry.",
"progressError": "Your watch position could not be loaded.",
"favoritesError": "Your favorites could not be loaded.",
"favoriteToggleFailed": "Could not update favorites. Please retry.",
"myFavorites": "My favorites",
"noFavorites": "No favorites yet.",
"downloads": "Downloads",
"downloadsError": "Download settings could not be loaded.",
"noDownloads": "No download authorizations yet.",
"downloadedItems": "Download authorizations",
"downloadQuota": "{remaining} of {max} authorizations available",
"@downloadQuota": {
"placeholders": {
"remaining": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"downloadLicenseDays": "Authorization valid for {days} days",
"@downloadLicenseDays": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"downloadWifiOnly": "Wi-Fi only",
"downloadQuality": "Maximum quality",
"downloadStatus": "Authorization: {status}",
"@downloadStatus": {
"placeholders": {
"status": {}
}
},
"downloadTaskStatus": "Task: {status}",
"@downloadTaskStatus": {
"placeholders": {
"status": {}
}
},
"downloadProgress": "{progress}%",
"@downloadProgress": {
"placeholders": {
"progress": {
"type": "int"
}
}
},
"downloadExpiresAt": "Expires {time}",
"@downloadExpiresAt": {
"placeholders": {
"time": {}
}
},
"clearDownloadsTitle": "Clear download authorizations?",
"clearDownloadsMessage": "This revokes every download authorization for this device.",
"downloadSettingsFailed": "Could not update download settings.",
"downloadDeleteFailed": "Could not remove this download authorization.",
"downloadClearFailed": "Could not clear download authorizations.",
"downloadGrantFailed": "Could not request this download.",
"downloadGranted": "Download authorization is ready.",
"noWatchRecords": "No watch history yet.",
"recordDeleteFailed": "Could not delete this record. Please retry.",
"recordClearFailed": "Could not clear watch history. Please retry.",
"clearAll": "Clear all",
"clearWatchHistoryTitle": "Clear watch history?",
"clearWatchHistoryMessage": "This removes the watch history from your account.",
"confirmClear": "Clear",
"detailLoadFailed": "Details failed to load",
"retryDetail": "Retry",
"episodeUnavailable": "Episode unavailable",
"updatedAtLabel": "Updated {time}",
"@updatedAtLabel": {
"placeholders": {
"time": {}
}
},
"remove": "Remove",
"unlockComingSoon": "Unlocking with coins is coming soon.",
"resumeProgress": "Resume at {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
}
+390
View File
@@ -0,0 +1,390 @@
{
"@@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.",
"emptyHint": "Konten baru akan muncul di sini.",
"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.",
"allDramas": "Semua Drama",
"trendingTab": "Sedang Tren",
"liveActionTab": "Live Action",
"search": "Cari",
"free": "Gratis",
"mute": "Bisukan",
"unmute": "Nyalakan suara",
"like": "Suka",
"save": "Simpan",
"comments": "Komentar",
"share": "Bagikan",
"noComments": "Belum ada komentar.",
"writeComment": "Tulis komentar",
"postComment": "Kirim",
"loadMore": "Muat lebih banyak",
"commentsLoadFailed": "Komentar tidak dapat dimuat.",
"commentPostFailed": "Komentar tidak dapat dikirim.",
"delete": "Hapus",
"deleteComment": "Hapus komentar",
"deleteCommentTitle": "Hapus komentar ini?",
"deleteCommentMessage": "Komentar ini akan dihapus dari percakapan.",
"commentDeleteFailed": "Komentar tidak dapat dihapus.",
"underReview": "Sedang ditinjau",
"reply": "Balas",
"replyingTo": "Membalas {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "Batal membalas",
"viewReplies": "Lihat balasan ({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "Sembunyikan balasan",
"anonymousUser": "Penonton",
"more": "Lainnya",
"likeFailed": "Status suka tidak dapat diperbarui.",
"reportComment": "Laporkan komentar",
"reportDrama": "Laporkan drama",
"reportReason": "Alasan",
"reportReasonSpam": "Spam",
"reportReasonAbuse": "Pelecehan",
"reportReasonPorn": "Konten seksual",
"reportReasonAd": "Iklan",
"reportReasonOther": "Lainnya",
"reportDescriptionHint": "Jelaskan masalah (opsional)",
"reportSubmit": "Kirim laporan",
"reportSubmitted": "Laporan terkirim. Terima kasih.",
"reportFailed": "Laporan tidak dapat dikirim.",
"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.",
"categoriesError": "Kategori gagal dimuat.",
"detailError": "Daftar episode gagal dimuat.",
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
"noStories": "Belum ada cerita.",
"notices": "Notifikasi",
"noNotices": "Belum ada notifikasi.",
"noticesLoadFailed": "Notifikasi tidak dapat dimuat.",
"noticeDetails": "Detail notifikasi",
"noticeAnnouncement": "Pengumuman",
"close": "Tutup",
"carouselSemantics": "Banner {current} dari {total}",
"@carouselSemantics": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"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": "简体中文",
"bahasaIndonesia": "Bahasa Indonesia",
"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",
"serverAddress": "Alamat server",
"serverSettings": "Pengaturan server",
"serverAddressHint": "http(s)://host:port",
"saveAddress": "Simpan alamat",
"restartToApply": "Mulai ulang aplikasi untuk menerapkan.",
"restoreDefault": "Pulihkan default",
"invalidServerAddress": "Masukkan alamat server http(s) yang valid.",
"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.",
"authBrandTagline": "Drama pendek untuk Indonesia",
"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": "Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.",
"authGateAction": "Masuk",
"showPassword": "Tampilkan kata sandi",
"hidePassword": "Sembunyikan kata sandi",
"cancel": "Nanti saja",
"noEpisodesYet": "Belum ada episode yang bisa diputar.",
"networkError": "Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.",
"progressError": "Posisi tonton gagal dimuat.",
"favoritesError": "Daftar favorit gagal dimuat.",
"favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.",
"myFavorites": "Favorit saya",
"noFavorites": "Belum ada favorit.",
"downloads": "Unduhan",
"downloadsError": "Pengaturan unduhan gagal dimuat.",
"noDownloads": "Belum ada izin unduhan.",
"downloadedItems": "Izin unduhan",
"downloadQuota": "{remaining} dari {max} izin tersedia",
"@downloadQuota": {
"placeholders": {
"remaining": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"downloadLicenseDays": "Izin berlaku {days} hari",
"@downloadLicenseDays": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"downloadWifiOnly": "Hanya Wi-Fi",
"downloadQuality": "Kualitas maksimum",
"downloadStatus": "Izin: {status}",
"@downloadStatus": {
"placeholders": {
"status": {}
}
},
"downloadTaskStatus": "Tugas: {status}",
"@downloadTaskStatus": {
"placeholders": {
"status": {}
}
},
"downloadProgress": "{progress}%",
"@downloadProgress": {
"placeholders": {
"progress": {
"type": "int"
}
}
},
"downloadExpiresAt": "Berakhir {time}",
"@downloadExpiresAt": {
"placeholders": {
"time": {}
}
},
"clearDownloadsTitle": "Hapus izin unduhan?",
"clearDownloadsMessage": "Semua izin unduhan untuk perangkat ini akan dicabut.",
"downloadSettingsFailed": "Gagal memperbarui pengaturan unduhan.",
"downloadDeleteFailed": "Gagal menghapus izin unduhan ini.",
"downloadClearFailed": "Gagal menghapus semua izin unduhan.",
"downloadGrantFailed": "Gagal meminta unduhan ini.",
"downloadGranted": "Izin unduhan sudah siap.",
"noWatchRecords": "Belum ada riwayat tontonan.",
"recordDeleteFailed": "Gagal menghapus riwayat ini. Coba lagi.",
"recordClearFailed": "Gagal menghapus semua riwayat. Coba lagi.",
"clearAll": "Hapus semua",
"clearWatchHistoryTitle": "Hapus riwayat tontonan?",
"clearWatchHistoryMessage": "Semua riwayat akan dihapus dari akun kamu.",
"confirmClear": "Hapus",
"detailLoadFailed": "Detail drama gagal dimuat",
"retryDetail": "Coba lagi",
"episodeUnavailable": "Episode tidak tersedia",
"updatedAtLabel": "Diperbarui {time}",
"@updatedAtLabel": {
"placeholders": {
"time": {}
}
},
"remove": "Hapus",
"unlockComingSoon": "Pembukaan dengan koin segera hadir.",
"resumeProgress": "Lanjut di {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
}
+848 -12
View File
@@ -1,5 +1,3 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
@@ -8,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
@@ -97,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.
@@ -268,6 +268,12 @@ abstract class AppLocalizations {
/// **'No matching dramas.'**
String get noSearchResults;
/// No description provided for @emptyHint.
///
/// In en, this message translates to:
/// **'New content will appear here.'**
String get emptyHint;
/// No description provided for @demoCatalogNotice.
///
/// In en, this message translates to:
@@ -388,6 +394,12 @@ abstract class AppLocalizations {
/// **'No dramas in this category yet.'**
String get noCategoryDramas;
/// No description provided for @allDramas.
///
/// In en, this message translates to:
/// **'All Dramas'**
String get allDramas;
/// No description provided for @trendingTab.
///
/// In en, this message translates to:
@@ -448,12 +460,6 @@ abstract class AppLocalizations {
/// **'Share'**
String get share;
/// No description provided for @localOnlyComments.
///
/// In en, this message translates to:
/// **'Only visible on this device'**
String get localOnlyComments;
/// No description provided for @noComments.
///
/// In en, this message translates to:
@@ -472,6 +478,180 @@ abstract class AppLocalizations {
/// **'Post'**
String get postComment;
/// No description provided for @loadMore.
///
/// In en, this message translates to:
/// **'Load more'**
String get loadMore;
/// No description provided for @commentsLoadFailed.
///
/// In en, this message translates to:
/// **'Comments could not be loaded.'**
String get commentsLoadFailed;
/// No description provided for @commentPostFailed.
///
/// In en, this message translates to:
/// **'Could not post your comment.'**
String get commentPostFailed;
/// No description provided for @delete.
///
/// In en, this message translates to:
/// **'Delete'**
String get delete;
/// No description provided for @deleteComment.
///
/// In en, this message translates to:
/// **'Delete comment'**
String get deleteComment;
/// No description provided for @deleteCommentTitle.
///
/// In en, this message translates to:
/// **'Delete this comment?'**
String get deleteCommentTitle;
/// No description provided for @deleteCommentMessage.
///
/// In en, this message translates to:
/// **'This removes your comment from the conversation.'**
String get deleteCommentMessage;
/// No description provided for @commentDeleteFailed.
///
/// In en, this message translates to:
/// **'Could not delete this comment.'**
String get commentDeleteFailed;
/// No description provided for @underReview.
///
/// In en, this message translates to:
/// **'Under review'**
String get underReview;
/// No description provided for @reply.
///
/// In en, this message translates to:
/// **'Reply'**
String get reply;
/// No description provided for @replyingTo.
///
/// In en, this message translates to:
/// **'Replying to {name}'**
String replyingTo(Object name);
/// No description provided for @cancelReply.
///
/// In en, this message translates to:
/// **'Cancel reply'**
String get cancelReply;
/// No description provided for @viewReplies.
///
/// In en, this message translates to:
/// **'View replies ({count})'**
String viewReplies(int count);
/// No description provided for @hideReplies.
///
/// In en, this message translates to:
/// **'Hide replies'**
String get hideReplies;
/// No description provided for @anonymousUser.
///
/// In en, this message translates to:
/// **'Viewer'**
String get anonymousUser;
/// No description provided for @more.
///
/// In en, this message translates to:
/// **'More'**
String get more;
/// No description provided for @likeFailed.
///
/// In en, this message translates to:
/// **'Could not update like. Please retry.'**
String get likeFailed;
/// No description provided for @reportComment.
///
/// In en, this message translates to:
/// **'Report comment'**
String get reportComment;
/// No description provided for @reportDrama.
///
/// In en, this message translates to:
/// **'Report drama'**
String get reportDrama;
/// No description provided for @reportReason.
///
/// In en, this message translates to:
/// **'Reason'**
String get reportReason;
/// No description provided for @reportReasonSpam.
///
/// In en, this message translates to:
/// **'Spam'**
String get reportReasonSpam;
/// No description provided for @reportReasonAbuse.
///
/// In en, this message translates to:
/// **'Harassment'**
String get reportReasonAbuse;
/// No description provided for @reportReasonPorn.
///
/// In en, this message translates to:
/// **'Sexual content'**
String get reportReasonPorn;
/// No description provided for @reportReasonAd.
///
/// In en, this message translates to:
/// **'Advertising'**
String get reportReasonAd;
/// No description provided for @reportReasonOther.
///
/// In en, this message translates to:
/// **'Other'**
String get reportReasonOther;
/// No description provided for @reportDescriptionHint.
///
/// In en, this message translates to:
/// **'Describe the issue (optional)'**
String get reportDescriptionHint;
/// No description provided for @reportSubmit.
///
/// In en, this message translates to:
/// **'Submit report'**
String get reportSubmit;
/// No description provided for @reportSubmitted.
///
/// In en, this message translates to:
/// **'Report submitted. Thank you.'**
String get reportSubmitted;
/// No description provided for @reportFailed.
///
/// In en, this message translates to:
/// **'Could not submit report.'**
String get reportFailed;
/// No description provided for @shareFailed.
///
/// In en, this message translates to:
@@ -634,12 +814,72 @@ abstract class AppLocalizations {
/// **'The catalog could not be loaded.'**
String get catalogError;
/// No description provided for @categoriesError.
///
/// In en, this message translates to:
/// **'Categories could not be loaded.'**
String get categoriesError;
/// 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:
/// **'No stories available yet.'**
String get noStories;
/// No description provided for @notices.
///
/// In en, this message translates to:
/// **'Notices'**
String get notices;
/// No description provided for @noNotices.
///
/// In en, this message translates to:
/// **'No notices yet.'**
String get noNotices;
/// No description provided for @noticesLoadFailed.
///
/// In en, this message translates to:
/// **'Notices could not be loaded.'**
String get noticesLoadFailed;
/// No description provided for @noticeDetails.
///
/// In en, this message translates to:
/// **'Notice details'**
String get noticeDetails;
/// No description provided for @noticeAnnouncement.
///
/// In en, this message translates to:
/// **'Notice'**
String get noticeAnnouncement;
/// No description provided for @close.
///
/// In en, this message translates to:
/// **'Close'**
String get close;
/// No description provided for @carouselSemantics.
///
/// In en, this message translates to:
/// **'Banner {current} of {total}'**
String carouselSemantics(int current, int total);
/// No description provided for @retry.
///
/// In en, this message translates to:
@@ -730,6 +970,12 @@ abstract class AppLocalizations {
/// **'简体中文'**
String get simplifiedChinese;
/// No description provided for @bahasaIndonesia.
///
/// In en, this message translates to:
/// **'Bahasa Indonesia'**
String get bahasaIndonesia;
/// No description provided for @episodeOneTitle.
///
/// In en, this message translates to:
@@ -835,14 +1081,602 @@ 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 @serverAddress.
///
/// In en, this message translates to:
/// **'Server address'**
String get serverAddress;
/// No description provided for @serverSettings.
///
/// In en, this message translates to:
/// **'Server settings'**
String get serverSettings;
/// No description provided for @serverAddressHint.
///
/// In en, this message translates to:
/// **'http(s)://host:port'**
String get serverAddressHint;
/// No description provided for @saveAddress.
///
/// In en, this message translates to:
/// **'Save address'**
String get saveAddress;
/// No description provided for @restartToApply.
///
/// In en, this message translates to:
/// **'Restart the app to apply.'**
String get restartToApply;
/// No description provided for @restoreDefault.
///
/// In en, this message translates to:
/// **'Restore default'**
String get restoreDefault;
/// No description provided for @invalidServerAddress.
///
/// In en, this message translates to:
/// **'Enter a valid http(s) server address.'**
String get invalidServerAddress;
/// 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 @authBrandTagline.
///
/// In en, this message translates to:
/// **'Short dramas made for Indonesia'**
String get authBrandTagline;
/// 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:
/// **'Watch history, favorites, comments and downloads 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 @showPassword.
///
/// In en, this message translates to:
/// **'Show password'**
String get showPassword;
/// No description provided for @hidePassword.
///
/// In en, this message translates to:
/// **'Hide password'**
String get hidePassword;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Not now'**
String get cancel;
/// No description provided for @noEpisodesYet.
///
/// In en, this message translates to:
/// **'No playable episodes yet.'**
String get noEpisodesYet;
/// No description provided for @networkError.
///
/// In en, this message translates to:
/// **'Network is unreachable. Please check your connection and retry.'**
String get networkError;
/// No description provided for @progressError.
///
/// In en, this message translates to:
/// **'Your watch position could not be loaded.'**
String get progressError;
/// No description provided for @favoritesError.
///
/// In en, this message translates to:
/// **'Your favorites could not be loaded.'**
String get favoritesError;
/// No description provided for @favoriteToggleFailed.
///
/// In en, this message translates to:
/// **'Could not update favorites. Please retry.'**
String get favoriteToggleFailed;
/// No description provided for @myFavorites.
///
/// In en, this message translates to:
/// **'My favorites'**
String get myFavorites;
/// No description provided for @noFavorites.
///
/// In en, this message translates to:
/// **'No favorites yet.'**
String get noFavorites;
/// No description provided for @downloads.
///
/// In en, this message translates to:
/// **'Downloads'**
String get downloads;
/// No description provided for @downloadsError.
///
/// In en, this message translates to:
/// **'Download settings could not be loaded.'**
String get downloadsError;
/// No description provided for @noDownloads.
///
/// In en, this message translates to:
/// **'No download authorizations yet.'**
String get noDownloads;
/// No description provided for @downloadedItems.
///
/// In en, this message translates to:
/// **'Download authorizations'**
String get downloadedItems;
/// No description provided for @downloadQuota.
///
/// In en, this message translates to:
/// **'{remaining} of {max} authorizations available'**
String downloadQuota(int remaining, int max);
/// No description provided for @downloadLicenseDays.
///
/// In en, this message translates to:
/// **'Authorization valid for {days} days'**
String downloadLicenseDays(int days);
/// No description provided for @downloadWifiOnly.
///
/// In en, this message translates to:
/// **'Wi-Fi only'**
String get downloadWifiOnly;
/// No description provided for @downloadQuality.
///
/// In en, this message translates to:
/// **'Maximum quality'**
String get downloadQuality;
/// No description provided for @downloadStatus.
///
/// In en, this message translates to:
/// **'Authorization: {status}'**
String downloadStatus(Object status);
/// No description provided for @downloadTaskStatus.
///
/// In en, this message translates to:
/// **'Task: {status}'**
String downloadTaskStatus(Object status);
/// No description provided for @downloadProgress.
///
/// In en, this message translates to:
/// **'{progress}%'**
String downloadProgress(int progress);
/// No description provided for @downloadExpiresAt.
///
/// In en, this message translates to:
/// **'Expires {time}'**
String downloadExpiresAt(Object time);
/// No description provided for @clearDownloadsTitle.
///
/// In en, this message translates to:
/// **'Clear download authorizations?'**
String get clearDownloadsTitle;
/// No description provided for @clearDownloadsMessage.
///
/// In en, this message translates to:
/// **'This revokes every download authorization for this device.'**
String get clearDownloadsMessage;
/// No description provided for @downloadSettingsFailed.
///
/// In en, this message translates to:
/// **'Could not update download settings.'**
String get downloadSettingsFailed;
/// No description provided for @downloadDeleteFailed.
///
/// In en, this message translates to:
/// **'Could not remove this download authorization.'**
String get downloadDeleteFailed;
/// No description provided for @downloadClearFailed.
///
/// In en, this message translates to:
/// **'Could not clear download authorizations.'**
String get downloadClearFailed;
/// No description provided for @downloadGrantFailed.
///
/// In en, this message translates to:
/// **'Could not request this download.'**
String get downloadGrantFailed;
/// No description provided for @downloadGranted.
///
/// In en, this message translates to:
/// **'Download authorization is ready.'**
String get downloadGranted;
/// No description provided for @noWatchRecords.
///
/// In en, this message translates to:
/// **'No watch history yet.'**
String get noWatchRecords;
/// No description provided for @recordDeleteFailed.
///
/// In en, this message translates to:
/// **'Could not delete this record. Please retry.'**
String get recordDeleteFailed;
/// No description provided for @recordClearFailed.
///
/// In en, this message translates to:
/// **'Could not clear watch history. Please retry.'**
String get recordClearFailed;
/// No description provided for @clearAll.
///
/// In en, this message translates to:
/// **'Clear all'**
String get clearAll;
/// No description provided for @clearWatchHistoryTitle.
///
/// In en, this message translates to:
/// **'Clear watch history?'**
String get clearWatchHistoryTitle;
/// No description provided for @clearWatchHistoryMessage.
///
/// In en, this message translates to:
/// **'This removes the watch history from your account.'**
String get clearWatchHistoryMessage;
/// No description provided for @confirmClear.
///
/// In en, this message translates to:
/// **'Clear'**
String get confirmClear;
/// No description provided for @detailLoadFailed.
///
/// In en, this message translates to:
/// **'Details failed to load'**
String get detailLoadFailed;
/// No description provided for @retryDetail.
///
/// In en, this message translates to:
/// **'Retry'**
String get retryDetail;
/// No description provided for @episodeUnavailable.
///
/// In en, this message translates to:
/// **'Episode unavailable'**
String get episodeUnavailable;
/// No description provided for @updatedAtLabel.
///
/// In en, this message translates to:
/// **'Updated {time}'**
String updatedAtLabel(Object time);
/// No description provided for @remove.
///
/// In en, this message translates to:
/// **'Remove'**
String get remove;
/// No description provided for @unlockComingSoon.
///
/// In en, this message translates to:
/// **'Unlocking with coins is coming soon.'**
String get unlockComingSoon;
/// No description provided for @resumeProgress.
///
/// In en, this message translates to:
/// **'Resume at {position} / {total}'**
String resumeProgress(Object position, Object total);
}
class _AppLocalizationsDelegate
@@ -856,7 +1690,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;
@@ -867,6 +1701,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'id':
return AppLocalizationsId();
case 'zh':
return AppLocalizationsZh();
}
+454 -8
View File
@@ -1,5 +1,3 @@
// Author: fruit.com.cn
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
@@ -11,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';
@@ -96,6 +94,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get noSearchResults => 'No matching dramas.';
@override
String get emptyHint => 'New content will appear here.';
@override
String get demoCatalogNotice =>
'Three demo dramas share the same local video.';
@@ -159,6 +160,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get noCategoryDramas => 'No dramas in this category yet.';
@override
String get allDramas => 'All Dramas';
@override
String get trendingTab => 'Trending';
@@ -189,9 +193,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get share => 'Share';
@override
String get localOnlyComments => 'Only visible on this device';
@override
String get noComments => 'No comments yet.';
@@ -201,6 +202,98 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get postComment => 'Post';
@override
String get loadMore => 'Load more';
@override
String get commentsLoadFailed => 'Comments could not be loaded.';
@override
String get commentPostFailed => 'Could not post your comment.';
@override
String get delete => 'Delete';
@override
String get deleteComment => 'Delete comment';
@override
String get deleteCommentTitle => 'Delete this comment?';
@override
String get deleteCommentMessage =>
'This removes your comment from the conversation.';
@override
String get commentDeleteFailed => 'Could not delete this comment.';
@override
String get underReview => 'Under review';
@override
String get reply => 'Reply';
@override
String replyingTo(Object name) {
return 'Replying to $name';
}
@override
String get cancelReply => 'Cancel reply';
@override
String viewReplies(int count) {
return 'View replies ($count)';
}
@override
String get hideReplies => 'Hide replies';
@override
String get anonymousUser => 'Viewer';
@override
String get more => 'More';
@override
String get likeFailed => 'Could not update like. Please retry.';
@override
String get reportComment => 'Report comment';
@override
String get reportDrama => 'Report drama';
@override
String get reportReason => 'Reason';
@override
String get reportReasonSpam => 'Spam';
@override
String get reportReasonAbuse => 'Harassment';
@override
String get reportReasonPorn => 'Sexual content';
@override
String get reportReasonAd => 'Advertising';
@override
String get reportReasonOther => 'Other';
@override
String get reportDescriptionHint => 'Describe the issue (optional)';
@override
String get reportSubmit => 'Submit report';
@override
String get reportSubmitted => 'Report submitted. Thank you.';
@override
String get reportFailed => 'Could not submit report.';
@override
String get shareFailed => 'Could not open the share sheet.';
@@ -296,9 +389,41 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get catalogError => 'The catalog could not be loaded.';
@override
String get categoriesError => 'Categories 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.';
@override
String get notices => 'Notices';
@override
String get noNotices => 'No notices yet.';
@override
String get noticesLoadFailed => 'Notices could not be loaded.';
@override
String get noticeDetails => 'Notice details';
@override
String get noticeAnnouncement => 'Notice';
@override
String get close => 'Close';
@override
String carouselSemantics(int current, int total) {
return 'Banner $current of $total';
}
@override
String get retry => 'Retry';
@@ -344,6 +469,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get simplifiedChinese => '简体中文';
@override
String get bahasaIndonesia => 'Bahasa Indonesia';
@override
String get episodeOneTitle => 'First Light';
@@ -399,8 +527,326 @@ 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 serverAddress => 'Server address';
@override
String get serverSettings => 'Server settings';
@override
String get serverAddressHint => 'http(s)://host:port';
@override
String get saveAddress => 'Save address';
@override
String get restartToApply => 'Restart the app to apply.';
@override
String get restoreDefault => 'Restore default';
@override
String get invalidServerAddress => 'Enter a valid http(s) server address.';
@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 authBrandTagline => 'Short dramas made for Indonesia';
@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 =>
'Watch history, favorites, comments and downloads need an account.';
@override
String get authGateAction => 'Sign In';
@override
String get showPassword => 'Show password';
@override
String get hidePassword => 'Hide password';
@override
String get cancel => 'Not now';
@override
String get noEpisodesYet => 'No playable episodes yet.';
@override
String get networkError =>
'Network is unreachable. Please check your connection and retry.';
@override
String get progressError => 'Your watch position could not be loaded.';
@override
String get favoritesError => 'Your favorites could not be loaded.';
@override
String get favoriteToggleFailed =>
'Could not update favorites. Please retry.';
@override
String get myFavorites => 'My favorites';
@override
String get noFavorites => 'No favorites yet.';
@override
String get downloads => 'Downloads';
@override
String get downloadsError => 'Download settings could not be loaded.';
@override
String get noDownloads => 'No download authorizations yet.';
@override
String get downloadedItems => 'Download authorizations';
@override
String downloadQuota(int remaining, int max) {
return '$remaining of $max authorizations available';
}
@override
String downloadLicenseDays(int days) {
return 'Authorization valid for $days days';
}
@override
String get downloadWifiOnly => 'Wi-Fi only';
@override
String get downloadQuality => 'Maximum quality';
@override
String downloadStatus(Object status) {
return 'Authorization: $status';
}
@override
String downloadTaskStatus(Object status) {
return 'Task: $status';
}
@override
String downloadProgress(int progress) {
return '$progress%';
}
@override
String downloadExpiresAt(Object time) {
return 'Expires $time';
}
@override
String get clearDownloadsTitle => 'Clear download authorizations?';
@override
String get clearDownloadsMessage =>
'This revokes every download authorization for this device.';
@override
String get downloadSettingsFailed => 'Could not update download settings.';
@override
String get downloadDeleteFailed =>
'Could not remove this download authorization.';
@override
String get downloadClearFailed => 'Could not clear download authorizations.';
@override
String get downloadGrantFailed => 'Could not request this download.';
@override
String get downloadGranted => 'Download authorization is ready.';
@override
String get noWatchRecords => 'No watch history yet.';
@override
String get recordDeleteFailed =>
'Could not delete this record. Please retry.';
@override
String get recordClearFailed =>
'Could not clear watch history. Please retry.';
@override
String get clearAll => 'Clear all';
@override
String get clearWatchHistoryTitle => 'Clear watch history?';
@override
String get clearWatchHistoryMessage =>
'This removes the watch history from your account.';
@override
String get confirmClear => 'Clear';
@override
String get detailLoadFailed => 'Details failed to load';
@override
String get retryDetail => 'Retry';
@override
String get episodeUnavailable => 'Episode unavailable';
@override
String updatedAtLabel(Object time) {
return 'Updated $time';
}
@override
String get remove => 'Remove';
@override
String get unlockComingSoon => 'Unlocking with coins is coming soon.';
@override
String resumeProgress(Object position, Object total) {
return 'Resume at $position / $total';
}
}
+851
View File
@@ -0,0 +1,851 @@
// 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 emptyHint => 'Konten baru akan muncul di sini.';
@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 allDramas => 'Semua Drama';
@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 noComments => 'Belum ada komentar.';
@override
String get writeComment => 'Tulis komentar';
@override
String get postComment => 'Kirim';
@override
String get loadMore => 'Muat lebih banyak';
@override
String get commentsLoadFailed => 'Komentar tidak dapat dimuat.';
@override
String get commentPostFailed => 'Komentar tidak dapat dikirim.';
@override
String get delete => 'Hapus';
@override
String get deleteComment => 'Hapus komentar';
@override
String get deleteCommentTitle => 'Hapus komentar ini?';
@override
String get deleteCommentMessage =>
'Komentar ini akan dihapus dari percakapan.';
@override
String get commentDeleteFailed => 'Komentar tidak dapat dihapus.';
@override
String get underReview => 'Sedang ditinjau';
@override
String get reply => 'Balas';
@override
String replyingTo(Object name) {
return 'Membalas $name';
}
@override
String get cancelReply => 'Batal membalas';
@override
String viewReplies(int count) {
return 'Lihat balasan ($count)';
}
@override
String get hideReplies => 'Sembunyikan balasan';
@override
String get anonymousUser => 'Penonton';
@override
String get more => 'Lainnya';
@override
String get likeFailed => 'Status suka tidak dapat diperbarui.';
@override
String get reportComment => 'Laporkan komentar';
@override
String get reportDrama => 'Laporkan drama';
@override
String get reportReason => 'Alasan';
@override
String get reportReasonSpam => 'Spam';
@override
String get reportReasonAbuse => 'Pelecehan';
@override
String get reportReasonPorn => 'Konten seksual';
@override
String get reportReasonAd => 'Iklan';
@override
String get reportReasonOther => 'Lainnya';
@override
String get reportDescriptionHint => 'Jelaskan masalah (opsional)';
@override
String get reportSubmit => 'Kirim laporan';
@override
String get reportSubmitted => 'Laporan terkirim. Terima kasih.';
@override
String get reportFailed => 'Laporan tidak dapat dikirim.';
@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 categoriesError => 'Kategori 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 notices => 'Notifikasi';
@override
String get noNotices => 'Belum ada notifikasi.';
@override
String get noticesLoadFailed => 'Notifikasi tidak dapat dimuat.';
@override
String get noticeDetails => 'Detail notifikasi';
@override
String get noticeAnnouncement => 'Pengumuman';
@override
String get close => 'Tutup';
@override
String carouselSemantics(int current, int total) {
return 'Banner $current dari $total';
}
@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 bahasaIndonesia => 'Bahasa Indonesia';
@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 serverAddress => 'Alamat server';
@override
String get serverSettings => 'Pengaturan server';
@override
String get serverAddressHint => 'http(s)://host:port';
@override
String get saveAddress => 'Simpan alamat';
@override
String get restartToApply => 'Mulai ulang aplikasi untuk menerapkan.';
@override
String get restoreDefault => 'Pulihkan default';
@override
String get invalidServerAddress =>
'Masukkan alamat server http(s) yang valid.';
@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 authBrandTagline => 'Drama pendek untuk Indonesia';
@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 =>
'Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.';
@override
String get authGateAction => 'Masuk';
@override
String get showPassword => 'Tampilkan kata sandi';
@override
String get hidePassword => 'Sembunyikan kata sandi';
@override
String get cancel => 'Nanti saja';
@override
String get noEpisodesYet => 'Belum ada episode yang bisa diputar.';
@override
String get networkError =>
'Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.';
@override
String get progressError => 'Posisi tonton gagal dimuat.';
@override
String get favoritesError => 'Daftar favorit gagal dimuat.';
@override
String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.';
@override
String get myFavorites => 'Favorit saya';
@override
String get noFavorites => 'Belum ada favorit.';
@override
String get downloads => 'Unduhan';
@override
String get downloadsError => 'Pengaturan unduhan gagal dimuat.';
@override
String get noDownloads => 'Belum ada izin unduhan.';
@override
String get downloadedItems => 'Izin unduhan';
@override
String downloadQuota(int remaining, int max) {
return '$remaining dari $max izin tersedia';
}
@override
String downloadLicenseDays(int days) {
return 'Izin berlaku $days hari';
}
@override
String get downloadWifiOnly => 'Hanya Wi-Fi';
@override
String get downloadQuality => 'Kualitas maksimum';
@override
String downloadStatus(Object status) {
return 'Izin: $status';
}
@override
String downloadTaskStatus(Object status) {
return 'Tugas: $status';
}
@override
String downloadProgress(int progress) {
return '$progress%';
}
@override
String downloadExpiresAt(Object time) {
return 'Berakhir $time';
}
@override
String get clearDownloadsTitle => 'Hapus izin unduhan?';
@override
String get clearDownloadsMessage =>
'Semua izin unduhan untuk perangkat ini akan dicabut.';
@override
String get downloadSettingsFailed => 'Gagal memperbarui pengaturan unduhan.';
@override
String get downloadDeleteFailed => 'Gagal menghapus izin unduhan ini.';
@override
String get downloadClearFailed => 'Gagal menghapus semua izin unduhan.';
@override
String get downloadGrantFailed => 'Gagal meminta unduhan ini.';
@override
String get downloadGranted => 'Izin unduhan sudah siap.';
@override
String get noWatchRecords => 'Belum ada riwayat tontonan.';
@override
String get recordDeleteFailed => 'Gagal menghapus riwayat ini. Coba lagi.';
@override
String get recordClearFailed => 'Gagal menghapus semua riwayat. Coba lagi.';
@override
String get clearAll => 'Hapus semua';
@override
String get clearWatchHistoryTitle => 'Hapus riwayat tontonan?';
@override
String get clearWatchHistoryMessage =>
'Semua riwayat akan dihapus dari akun kamu.';
@override
String get confirmClear => 'Hapus';
@override
String get detailLoadFailed => 'Detail drama gagal dimuat';
@override
String get retryDetail => 'Coba lagi';
@override
String get episodeUnavailable => 'Episode tidak tersedia';
@override
String updatedAtLabel(Object time) {
return 'Diperbarui $time';
}
@override
String get remove => 'Hapus';
@override
String get unlockComingSoon => 'Pembukaan dengan koin segera hadir.';
@override
String resumeProgress(Object position, Object total) {
return 'Lanjut di $position / $total';
}
}
+445 -8
View File
@@ -1,5 +1,3 @@
// Author: fruit.com.cn
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
@@ -11,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 => '演示';
@@ -94,6 +92,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get noSearchResults => '没有匹配的短剧。';
@override
String get emptyHint => '有新内容时会显示在这里。';
@override
String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。';
@@ -156,6 +157,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get noCategoryDramas => '这个分类暂时没有剧集。';
@override
String get allDramas => '全部剧目';
@override
String get trendingTab => '热门';
@@ -186,9 +190,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get share => '分享';
@override
String get localOnlyComments => '仅在本机可见';
@override
String get noComments => '还没有评论。';
@@ -198,6 +199,97 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get postComment => '发布';
@override
String get loadMore => '加载更多';
@override
String get commentsLoadFailed => '评论加载失败,请重试。';
@override
String get commentPostFailed => '评论发布失败,请重试。';
@override
String get delete => '删除';
@override
String get deleteComment => '删除评论';
@override
String get deleteCommentTitle => '删除这条评论?';
@override
String get deleteCommentMessage => '删除后,这条评论将从评论列表中移除。';
@override
String get commentDeleteFailed => '评论删除失败,请重试。';
@override
String get underReview => '审核中';
@override
String get reply => '回复';
@override
String replyingTo(Object name) {
return '回复 $name';
}
@override
String get cancelReply => '取消回复';
@override
String viewReplies(int count) {
return '查看回复($count)';
}
@override
String get hideReplies => '收起回复';
@override
String get anonymousUser => '观众';
@override
String get more => '更多';
@override
String get likeFailed => '点赞状态更新失败,请重试。';
@override
String get reportComment => '举报评论';
@override
String get reportDrama => '举报短剧';
@override
String get reportReason => '举报原因';
@override
String get reportReasonSpam => '垃圾广告';
@override
String get reportReasonAbuse => '辱骂骚扰';
@override
String get reportReasonPorn => '色情低俗';
@override
String get reportReasonAd => '引流广告';
@override
String get reportReasonOther => '其他';
@override
String get reportDescriptionHint => '补充说明(选填)';
@override
String get reportSubmit => '提交举报';
@override
String get reportSubmitted => '举报已提交,感谢反馈。';
@override
String get reportFailed => '举报提交失败,请重试。';
@override
String get shareFailed => '无法打开系统分享面板。';
@@ -291,9 +383,41 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get catalogError => '剧目列表加载失败。';
@override
String get categoriesError => '分类加载失败。';
@override
String get detailError => '剧集信息加载失败。';
@override
String get noMediaNotice => '该剧集还没有可播放的片源。';
@override
String get noStories => '暂无剧目。';
@override
String get notices => '通知';
@override
String get noNotices => '暂无通知。';
@override
String get noticesLoadFailed => '通知加载失败,请重试。';
@override
String get noticeDetails => '通知详情';
@override
String get noticeAnnouncement => '公告';
@override
String get close => '关闭';
@override
String carouselSemantics(int current, int total) {
return '第 $current 张,共 $total 张';
}
@override
String get retry => '重试';
@@ -339,6 +463,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get simplifiedChinese => '简体中文';
@override
String get bahasaIndonesia => '印尼语';
@override
String get episodeOneTitle => '初见微光';
@@ -391,8 +518,318 @@ 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 serverAddress => '服务器地址';
@override
String get serverSettings => '服务器设置';
@override
String get serverAddressHint => 'http(s)://主机:端口';
@override
String get saveAddress => '保存地址';
@override
String get restartToApply => '重启 App 生效';
@override
String get restoreDefault => '恢复默认';
@override
String get invalidServerAddress => '请输入有效的 http(s) 服务器地址。';
@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 authBrandTagline => '为印尼市场打造的短剧体验';
@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 showPassword => '显示密码';
@override
String get hidePassword => '隐藏密码';
@override
String get cancel => '暂不';
@override
String get noEpisodesYet => '还没有可播放的剧集。';
@override
String get networkError => '网络异常,请检查网络后重试。';
@override
String get progressError => '续看进度加载失败。';
@override
String get favoritesError => '收藏列表加载失败。';
@override
String get favoriteToggleFailed => '收藏操作失败,请重试。';
@override
String get myFavorites => '我的收藏';
@override
String get noFavorites => '还没有收藏。';
@override
String get downloads => '下载';
@override
String get downloadsError => '下载设置加载失败。';
@override
String get noDownloads => '还没有下载授权。';
@override
String get downloadedItems => '下载授权';
@override
String downloadQuota(int remaining, int max) {
return '剩余 $remaining / $max 个授权';
}
@override
String downloadLicenseDays(int days) {
return '授权有效期 $days 天';
}
@override
String get downloadWifiOnly => '仅 Wi-Fi 下载';
@override
String get downloadQuality => '最高清晰度';
@override
String downloadStatus(Object status) {
return '授权:$status';
}
@override
String downloadTaskStatus(Object status) {
return '任务:$status';
}
@override
String downloadProgress(int progress) {
return '$progress%';
}
@override
String downloadExpiresAt(Object time) {
return '到期时间 $time';
}
@override
String get clearDownloadsTitle => '清空下载授权?';
@override
String get clearDownloadsMessage => '这会吊销当前设备的全部下载授权。';
@override
String get downloadSettingsFailed => '下载设置更新失败。';
@override
String get downloadDeleteFailed => '删除下载授权失败。';
@override
String get downloadClearFailed => '清空下载授权失败。';
@override
String get downloadGrantFailed => '申请下载失败。';
@override
String get downloadGranted => '下载授权已就绪。';
@override
String get noWatchRecords => '还没有观看记录。';
@override
String get recordDeleteFailed => '删除记录失败,请重试。';
@override
String get recordClearFailed => '清空观看记录失败,请重试。';
@override
String get clearAll => '清空';
@override
String get clearWatchHistoryTitle => '清空观看记录?';
@override
String get clearWatchHistoryMessage => '这会从当前账号移除全部观看记录。';
@override
String get confirmClear => '清空';
@override
String get detailLoadFailed => '剧集信息加载失败';
@override
String get retryDetail => '重试';
@override
String get episodeUnavailable => '集数未知';
@override
String updatedAtLabel(Object time) {
return '更新于 $time';
}
@override
String get remove => '移除';
@override
String get unlockComingSoon => '金币解锁功能即将开放。';
@override
String resumeProgress(Object position, Object total) {
return '续看至 $position / $total';
}
}
+229 -6
View File
@@ -1,6 +1,6 @@
{
"@@locale": "zh",
"appName": "Nontonki",
"appName": "FruitShorts",
"demo": "演示",
"homeHeadline": "即刻观看",
"homeSubtitle": "免费短剧预览。",
@@ -28,13 +28,20 @@
"videoPending": "视频待提供",
"watchNow": "免费观看",
"noSearchResults": "没有匹配的短剧。",
"emptyHint": "有新内容时会显示在这里。",
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
"myTab": "我的",
"localViewer": "本地观众",
"localDemoProfile": "本地演示档案",
"continueWatching": "继续观看",
"watchedPercent": "已观看 {percent}%",
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"@watchedPercent": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"resume": "继续播放",
"history": "历史",
"watchlist": "追剧",
@@ -49,6 +56,7 @@
"animatedTab": "漫剧",
"recommendedTab": "推荐",
"noCategoryDramas": "这个分类暂时没有剧集。",
"allDramas": "全部剧目",
"trendingTab": "热门",
"liveActionTab": "真人剧",
"search": "搜索",
@@ -59,10 +67,50 @@
"save": "收藏",
"comments": "评论",
"share": "分享",
"localOnlyComments": "仅在本机可见",
"noComments": "还没有评论。",
"writeComment": "写评论",
"postComment": "发布",
"loadMore": "加载更多",
"commentsLoadFailed": "评论加载失败,请重试。",
"commentPostFailed": "评论发布失败,请重试。",
"delete": "删除",
"deleteComment": "删除评论",
"deleteCommentTitle": "删除这条评论?",
"deleteCommentMessage": "删除后,这条评论将从评论列表中移除。",
"commentDeleteFailed": "评论删除失败,请重试。",
"underReview": "审核中",
"reply": "回复",
"replyingTo": "回复 {name}",
"@replyingTo": {
"placeholders": {
"name": {}
}
},
"cancelReply": "取消回复",
"viewReplies": "查看回复({count})",
"@viewReplies": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hideReplies": "收起回复",
"anonymousUser": "观众",
"more": "更多",
"likeFailed": "点赞状态更新失败,请重试。",
"reportComment": "举报评论",
"reportDrama": "举报短剧",
"reportReason": "举报原因",
"reportReasonSpam": "垃圾广告",
"reportReasonAbuse": "辱骂骚扰",
"reportReasonPorn": "色情低俗",
"reportReasonAd": "引流广告",
"reportReasonOther": "其他",
"reportDescriptionHint": "补充说明(选填)",
"reportSubmit": "提交举报",
"reportSubmitted": "举报已提交,感谢反馈。",
"reportFailed": "举报提交失败,请重试。",
"shareFailed": "无法打开系统分享面板。",
"nowPlaying": "正在播放",
"watchFullscreen": "横屏全屏观看",
@@ -72,7 +120,13 @@
"originalTag": "原创",
"dramaTag": "短剧",
"episodeLabel": "第 {number} 集",
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"@episodeLabel": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"exploreSeries": "查看剧集",
"episodeCount": "{count} 集",
"technicalDemoCount": "免费试看 · {count} 集",
@@ -91,7 +145,27 @@
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
"playbackError": "此视频暂时无法播放。",
"catalogError": "剧目列表加载失败。",
"categoriesError": "分类加载失败。",
"detailError": "剧集信息加载失败。",
"noMediaNotice": "该剧集还没有可播放的片源。",
"noStories": "暂无剧目。",
"notices": "通知",
"noNotices": "暂无通知。",
"noticesLoadFailed": "通知加载失败,请重试。",
"noticeDetails": "通知详情",
"noticeAnnouncement": "公告",
"close": "关闭",
"carouselSemantics": "第 {current} 张,共 {total} 张",
"@carouselSemantics": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"retry": "重试",
"backToSeries": "返回剧集详情",
"nextEpisode": "下一集",
@@ -107,6 +181,7 @@
"systemLanguage": "跟随系统",
"english": "English",
"simplifiedChinese": "简体中文",
"bahasaIndonesia": "印尼语",
"episodeOneTitle": "初见微光",
"episodeTwoTitle": "新的频率",
"episodeThreeTitle": "越过边界",
@@ -124,6 +199,154 @@
"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": "设置",
"serverAddress": "服务器地址",
"serverSettings": "服务器设置",
"serverAddressHint": "http(s)://主机:端口",
"saveAddress": "保存地址",
"restartToApply": "重启 App 生效",
"restoreDefault": "恢复默认",
"invalidServerAddress": "请输入有效的 http(s) 服务器地址。",
"signOut": "退出登录",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
"mockNotice": "当前是示例数据。",
"topBadge": "TOP 1",
"signIn": "登录",
"signUp": "注册",
"authSignInTitle": "欢迎回来",
"authSignUpTitle": "创建账号",
"authSubtitle": "用邮箱和密码登录。",
"authBrandTagline": "为印尼市场打造的短剧体验",
"authEmailLabel": "邮箱",
"authPasswordLabel": "密码",
"authPasswordHint": "至少 8 位,需包含字母和数字",
"authEmailRequired": "请输入邮箱",
"authEmailInvalid": "邮箱格式不正确",
"authPasswordRequired": "请输入密码",
"authPasswordLength": "密码长度需在 8-64 位之间",
"authPasswordWeak": "密码需同时包含字母和数字",
"authNetworkError": "网络异常,请稍后重试",
"authSwitchToSignUp": "还没有账号?去注册",
"authSwitchToSignIn": "已有账号?去登录",
"authGateTitle": "登录后可继续",
"authGateMessage": "观看记录、收藏、评论和下载需要登录。",
"authGateAction": "去登录",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"cancel": "暂不",
"noEpisodesYet": "还没有可播放的剧集。",
"networkError": "网络异常,请检查网络后重试。",
"progressError": "续看进度加载失败。",
"favoritesError": "收藏列表加载失败。",
"favoriteToggleFailed": "收藏操作失败,请重试。",
"myFavorites": "我的收藏",
"noFavorites": "还没有收藏。",
"downloads": "下载",
"downloadsError": "下载设置加载失败。",
"noDownloads": "还没有下载授权。",
"downloadedItems": "下载授权",
"downloadQuota": "剩余 {remaining} / {max} 个授权",
"@downloadQuota": {
"placeholders": {
"remaining": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"downloadLicenseDays": "授权有效期 {days} 天",
"@downloadLicenseDays": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"downloadWifiOnly": "仅 Wi-Fi 下载",
"downloadQuality": "最高清晰度",
"downloadStatus": "授权:{status}",
"@downloadStatus": {
"placeholders": {
"status": {}
}
},
"downloadTaskStatus": "任务:{status}",
"@downloadTaskStatus": {
"placeholders": {
"status": {}
}
},
"downloadProgress": "{progress}%",
"@downloadProgress": {
"placeholders": {
"progress": {
"type": "int"
}
}
},
"downloadExpiresAt": "到期时间 {time}",
"@downloadExpiresAt": {
"placeholders": {
"time": {}
}
},
"clearDownloadsTitle": "清空下载授权?",
"clearDownloadsMessage": "这会吊销当前设备的全部下载授权。",
"downloadSettingsFailed": "下载设置更新失败。",
"downloadDeleteFailed": "删除下载授权失败。",
"downloadClearFailed": "清空下载授权失败。",
"downloadGrantFailed": "申请下载失败。",
"downloadGranted": "下载授权已就绪。",
"noWatchRecords": "还没有观看记录。",
"recordDeleteFailed": "删除记录失败,请重试。",
"recordClearFailed": "清空观看记录失败,请重试。",
"clearAll": "清空",
"clearWatchHistoryTitle": "清空观看记录?",
"clearWatchHistoryMessage": "这会从当前账号移除全部观看记录。",
"confirmClear": "清空",
"detailLoadFailed": "剧集信息加载失败",
"retryDetail": "重试",
"episodeUnavailable": "集数未知",
"updatedAtLabel": "更新于 {time}",
"@updatedAtLabel": {
"placeholders": {
"time": {}
}
},
"remove": "移除",
"unlockComingSoon": "金币解锁功能即将开放。",
"resumeProgress": "续看至 {position} / {total}",
"@resumeProgress": {
"placeholders": {
"position": {},
"total": {}
}
}
}
+3
View File
@@ -2,10 +2,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app.dart';
import 'core/config/server_endpoint.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await ServerEndpoint.load();
runApp(const LumaDramaApp());
}
+6
View File
@@ -0,0 +1,6 @@
import 'auth_logic.dart';
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
class AuthBinding {
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
}
+363
View File
@@ -0,0 +1,363 @@
// Author: fruit.com.cn
part of 'auth_view.dart';
class _AuthFields extends StatelessWidget {
const _AuthFields({required this.state, required this.callbacks});
final _AuthCardState state;
final _AuthCardCallbacks callbacks;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel(state.l10n.authEmailLabel),
const SizedBox(height: 8),
_emailField(),
const SizedBox(height: 16),
_fieldLabel(state.l10n.authPasswordLabel),
const SizedBox(height: 8),
_passwordField(),
],
);
}
Widget _emailField() {
final enabled = !state.status.busy;
return ListenableBuilder(
listenable: state.fields.emailFocus,
builder: (context, _) => SizedBox(
height: _fieldHeight,
child: TextField(
key: const Key('auth-email'),
controller: state.fields.email,
focusNode: state.fields.emailFocus,
enabled: enabled,
autofocus: true,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
onChanged: callbacks.onEmailChanged,
onSubmitted: (_) => state.fields.passwordFocus.requestFocus(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
decoration: _decoration(
hint: 'name@example.com',
icon: Icons.mail_outline_rounded,
focused: state.fields.emailFocus.hasFocus,
),
),
),
);
}
Widget _passwordField() {
final enabled = !state.status.busy;
final l10n = state.l10n;
return ListenableBuilder(
listenable: state.fields.passwordFocus,
builder: (context, _) => SizedBox(
height: _fieldHeight,
child: TextField(
key: const Key('auth-password'),
controller: state.fields.password,
focusNode: state.fields.passwordFocus,
enabled: enabled,
obscureText: state.status.obscurePassword,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onChanged: callbacks.onPasswordChanged,
onSubmitted: (_) => callbacks.onSubmit(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
decoration: _decoration(
hint: l10n.authPasswordHint,
icon: Icons.lock_outline_rounded,
focused: state.fields.passwordFocus.hasFocus,
suffix: IconButton(
key: const Key('auth-password-visibility'),
constraints: const BoxConstraints.tightFor(
width: _minTapTarget,
height: _minTapTarget,
),
tooltip: state.status.obscurePassword
? l10n.showPassword
: l10n.hidePassword,
onPressed: enabled ? callbacks.onTogglePasswordVisibility : null,
icon: Icon(
state.status.obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: AppColors.muted,
),
),
),
),
),
);
}
Widget _fieldLabel(String label) => Text(
label,
style: const TextStyle(
color: AppColors.muted,
fontSize: 13,
fontWeight: FontWeight.w600,
),
);
InputDecoration _decoration({
required String hint,
required IconData icon,
required bool focused,
Widget? suffix,
}) {
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(_fieldRadius),
borderSide: BorderSide.none,
);
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14),
prefixIcon: Icon(
icon,
size: 21,
color: focused ? AppColors.blueSoft : AppColors.muted,
),
prefixIconConstraints: const BoxConstraints(
minWidth: _minTapTarget,
minHeight: _minTapTarget,
),
suffixIcon: suffix,
filled: true,
fillColor: AppColors.bar,
isDense: true,
contentPadding: const EdgeInsets.only(right: 16),
border: border,
enabledBorder: border,
disabledBorder: border,
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_fieldRadius),
borderSide: const BorderSide(
color: AppColors.blue,
width: _fieldBorderWidth,
),
),
);
}
}
class _AuthSubmitButton extends StatelessWidget {
const _AuthSubmitButton({required this.state, required this.onSubmit});
final _AuthCardState state;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final status = state.status;
final l10n = state.l10n;
return SizedBox(
width: double.infinity,
height: _buttonHeight,
child: FilledButton(
key: const Key('auth-submit'),
onPressed: status.busy ? null : onSubmit,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return AppColors.blue.withValues(alpha: 0.5);
}
if (states.contains(WidgetState.pressed)) {
return AppColors.bluePressed;
}
return AppColors.blue;
}),
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
elevation: const WidgetStatePropertyAll(0),
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_buttonRadius),
),
),
),
child: status.busy
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.onPrimary,
),
)
: Text(
status.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class _ModeSegment extends StatelessWidget {
const _ModeSegment({
required this.segmentKey,
required this.label,
required this.hiddenLabel,
required this.selected,
required this.onTap,
});
final Key segmentKey;
final String label;
final String hiddenLabel;
final bool selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: segmentKey,
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: AnimatedContainer(
duration: _segmentDuration,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.blue : Colors.transparent,
borderRadius: BorderRadius.circular(18),
),
child: Stack(
fit: StackFit.expand,
children: [
Center(
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
Positioned.fill(
child: ExcludeSemantics(
child: Opacity(
opacity: 0,
child: Center(
child: Text(
hiddenLabel,
maxLines: 1,
softWrap: false,
style: const TextStyle(fontSize: 14),
),
),
),
),
),
],
),
),
);
}
}
class _AuthBrand extends StatelessWidget {
const _AuthBrand({required this.tagline});
final String tagline;
@override
Widget build(BuildContext context) {
return Row(
key: const Key('auth-brand'),
children: [
Container(
key: const Key('auth-brand-mark'),
width: _brandMarkSize,
height: _brandMarkSize,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: AppColors.blue.withValues(alpha: 0.32),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.movie_filter_rounded,
color: AppColors.onPrimary,
size: 26,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.appName,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 23,
fontWeight: FontWeight.w800,
height: 1.1,
),
),
const SizedBox(height: 4),
Text(
tagline,
style: const TextStyle(
color: AppColors.blueSoft,
fontSize: 13,
height: 1.35,
),
),
],
),
),
],
);
}
}
class _AuthError extends StatelessWidget {
const _AuthError({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.error;
return Container(
key: const Key('auth-error-capsule'),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: _errorOpacity),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: _errorBorderOpacity)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline_rounded, color: color, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
key: const Key('auth-error'),
style: TextStyle(color: color, fontSize: 13, height: 1.35),
),
),
],
),
);
}
}
+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,
}
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more