Author SHA1 Message Date
Zhou Weidong bc5feebf39 merge: 修正清屏态点击与滑动复位 2026-10-19 17:06:32 +08:00
Zhou Weidong aea130be11 fix(player): 修正清屏态点击与滑动复位 2026-10-19 17:05:52 +08:00
Zhou Weidong a2de5e8eb7 merge: 增加退出清屏浮动按钮 2026-10-19 16:53:57 +08:00
Zhou Weidong e2e809e2d5 fix(player): 增加退出清屏浮动按钮 2026-10-19 16:53:23 +08:00
Zhou Weidong 3fcfd68660 merge: 仅在暂停时显示播放按钮 2026-10-19 16:51:20 +08:00
Zhou Weidong 0eabb0a763 fix(player): 仅在暂停时显示播放按钮 2026-10-19 16:50:44 +08:00
Zhou Weidong 60ec19f08d merge: 改为显式清屏交互 2026-10-19 16:44:31 +08:00
Zhou Weidong f613033088 fix(player): 改为显式清屏交互 2026-10-19 16:43:50 +08:00
Zhou Weidong f4e1a5424e merge: 修正详情选集等宽网格 2026-10-19 16:33:20 +08:00
Zhou Weidong fbfb9769a0 fix(detail): 选集改为等宽懒加载网格 2026-10-19 16:32:09 +08:00
Zhou Weidong cbbd4c80e4 merge: 修正六屏顶栏与内容间距 2026-10-19 16:26:01 +08:00
Zhou Weidong 2fd92cdbea fix(ui): 修正六屏顶栏与内容间距 2026-10-19 16:25:25 +08:00
Zhou Weidong e03ea76505 merge: 统一暂停按钮并自动隐藏控制层 2026-10-19 16:16:53 +08:00
Zhou Weidong 03e34b3b57 fix(player): 统一暂停按钮并自动隐藏控制层 2026-10-19 16:16:14 +08:00
Zhou Weidong 568f051b7a merge: 修复详情暂停重复按钮 2026-10-19 16:07:42 +08:00
Zhou Weidong f0c53542ef fix(player): 移除详情暂停重复按钮 2026-10-19 16:07:06 +08:00
Zhou Weidong c82502559b merge: 清理 K26 旧 UI 与死代码 2026-10-19 15:59:03 +08:00
Zhou Weidong 5422581f8b refactor(ui): 清理 K26 旧 UI 与死代码 2026-10-19 15:58:22 +08:00
Zhou Weidong a5398daab3 merge: K26 客户端六屏按预览校正 2026-10-19 15:51:04 +08:00
Zhou Weidong 8767b5644b test(ui): 更新三 Tab 预览断言 2026-10-19 15:50:24 +08:00
Zhou Weidong fecc5b0d27 feat(ui): 新增登录入口与我的页预览 2026-10-19 15:50:15 +08:00
Zhou Weidong 21d457fd98 feat(ui): 校正首页发现详情播放器预览 2026-10-19 15:50:11 +08:00
Zhou Weidong 87db548591 feat(theme): 校正 K26 预览色板与导航 2026-10-19 15:50:07 +08:00
Zhou Weidong eefabb6898 merge: 修复播放器全屏进入方式 2026-10-19 15:24:40 +08:00
Zhou Weidong 1c94b727fb fix(player): 恢复详情与选集全屏播放 2026-10-19 15:24:06 +08:00
Zhou Weidong 04fddce1d1 merge: K25 客户端详情页与播放器 2026-10-19 15:17:18 +08:00
Zhou Weidong 689063a694 feat(player): 新增卡片式播放器 2026-10-19 15:16:40 +08:00
Zhou Weidong ec163f905d feat(detail): 重做详情页视觉结构 2026-10-19 15:16:36 +08:00
Zhou Weidong 4553606ec2 merge: K24 客户端 UI 对齐设计稿 2026-10-19 15:02:44 +08:00
Zhou Weidong e8c1859e70 feat(ui): 重做三 Tab 首页与发现页 2026-10-19 15:01:45 +08:00
Zhou Weidong eae1c533ce feat(theme): 接入 Obsidian Flare 设计系统 2026-10-19 15:01:41 +08:00
Zhou Weidong b1c69e22af feat(auth): 合并 K21 登录页优化 2026-10-19 10:34:37 +08:00
Zhou Weidong 8a96f341c8 feat(auth): 优化登录页布局与字段反馈 2026-10-19 10:33:33 +08:00
Zhou Weidong e44fbdf6f2 style(ui): 合并 K20 客户端视觉统一 2026-10-19 10:23:38 +08:00
Zhou Weidong 5f1df401dc fix(ui): 保留空态既有字号口径 2026-10-19 10:23:24 +08:00
Zhou Weidong c5bfd006fa style(ui): 统一页面字号间距与状态样式 2026-10-19 10:21:28 +08:00
Zhou Weidong df5fbe5b08 feat(ui): 增加视觉 token 与统一导航 2026-10-19 10:16:12 +08:00
Zhou Weidong 42ce1d1d84 fix(account): 合并网络异常文案修复 2026-10-19 10:00:48 +08:00
Zhou Weidong 27ab7cebfb fix(account): 本地化网络异常文案 2026-10-19 09:59:45 +08:00
Zhou Weidong 9b3ab9d5de feat(account): 合并 K19 账号中心 2026-10-19 09:55:37 +08:00
Zhou Weidong 7151bc9d8b style(account): 保持账号入口首屏可用 2026-10-19 09:54:10 +08:00
Zhou Weidong 6fce24d9ec refactor(account): 收敛账号页组件结构 2026-10-19 09:51:23 +08:00
Zhou Weidong da095115d4 feat(account): 增加账号中心页面与入口 2026-10-19 09:50:23 +08:00
Zhou Weidong c68f02449f feat(account): 增加账号中心页面逻辑 2026-10-19 09:48:07 +08:00
Zhou Weidong ef7f4ce3b6 feat(account): 同步资料保存后的登录态 2026-10-19 09:47:28 +08:00
Zhou Weidong 44f6feb249 feat(account): 接入个人中心接口模型 2026-10-19 09:47:06 +08:00
Zhou Weidong 7d68699523 fix(config): 合并开发默认服务器地址 2026-10-19 09:34:54 +08:00
Zhou Weidong 60043d1e41 fix(config): 更新开发默认服务器地址 2026-10-19 09:34:44 +08:00
Zhou Weidong 9df36bc358 feat(ui): 合并品牌启动页与选集交互 2026-10-19 09:28:27 +08:00
Zhou Weidong c1cb7d1c11 feat(ui): 更新品牌启动页与选集交互 2026-10-19 09:28:16 +08:00
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
296 changed files with 31032 additions and 3882 deletions

No files matched your search

+7 -1
View File
@@ -45,7 +45,13 @@ app.*.map.json
/android/app/release
# Local demo media and internal planning are absent from the public repo.
/assets/
# Launcher/splash source icons are required at build time, so keep them tracked.
/assets/*
!/assets/icon/
!/assets/icon/*.png
!/assets/fonts/
!/assets/fonts/*.ttf
!/assets/fonts/OFL-*.txt
/server/
/docs/
/AGENTS.md
+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 画中画尚未接入。
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<!-- Android 12 already shows the icon in the system splash. This layer stays
the same color so the icon is not drawn a second time. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 B

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -15,6 +15,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowBackground">@color/splash_background</item>
</style>
</resources>
+2 -2
View File
@@ -2,6 +2,6 @@
<!-- Author: fruit.com.cn -->
<resources>
<color name="splash_background">#FF000000</color>
<color name="ic_launcher_background">#D74A7D</color>
<color name="splash_background">#FF100C0A</color>
<color name="ic_launcher_background">#C4322A</color>
</resources>
+1 -1
View File
@@ -15,6 +15,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowBackground">@color/splash_background</item>
</style>
</resources>
@@ -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>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+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');
});
}
+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"
+1 -1
View File
@@ -10,7 +10,7 @@ final class RunnerFlutterViewController: FlutterViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
view.backgroundColor = UIColor(red: 16.0 / 255.0, green: 12.0 / 255.0, blue: 10.0 / 255.0, alpha: 1)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1001 B

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 958 B

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 B

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -15,12 +15,10 @@
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="0" green="0" blue="0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" red="0.06274509803921569" green="0.047058823529411764" blue="0.0392156862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="top-launch"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="bot-launch"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="lead-launch"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="trailing" secondItem="Ze5-6b-2t3" secondAttribute="trailing" id="trail-launch"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="cx-launch"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="cy-launch"/>
</constraints>
</view>
</viewController>
@@ -30,6 +28,6 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="84" height="84"/>
</resources>
</document>
+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>
+199 -33
View File
@@ -1,38 +1,215 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'core/config/locale_controller.dart';
import 'core/data/remote/account_api.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/account_repository.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/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/video_player_port.dart';
import 'core/platform/haptics.dart';
import 'core/theme/app_theme.dart';
import 'core/theme/app_colors.dart';
import 'l10n/app_localizations.dart';
import 'pages/home/home_view.dart';
class LumaDramaApp extends StatelessWidget {
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
class LumaDramaApp extends StatefulWidget {
const LumaDramaApp({
super.key,
this.playerFactory = VideoPlayerPort.new,
this.catalogRepository,
this.categoryRepository,
this.downloadRepository,
this.watchHistoryRepository,
this.favoriteRepository,
this.playbackRepository,
this.commentRepository,
this.interactionRepository,
this.noticeRepository,
this.accountRepository,
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 AccountRepository? accountRepository;
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 AccountRepository _accountRepository =
widget.accountRepository ?? HttpAccountRepository(AccountApi(_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<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<AccountRepository>.value(value: _accountRepository),
Provider<Haptics>.value(value: widget.haptics),
ChangeNotifierProvider(
create: (_) => CatalogController(DemoCatalogRepository())..load(),
create: (_) => CatalogController(_catalogRepository)..load(),
),
ChangeNotifierProvider(
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(
@@ -40,30 +217,19 @@ 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.system,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6D4AFF),
themeMode: ThemeMode.dark,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: AppColors.bar,
systemNavigationBarIconBrightness: Brightness.light,
),
scaffoldBackgroundColor: const Color(0xFFF8F7FC),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF9B86FF),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF101019),
child: child ?? const SizedBox.shrink(),
),
home: const HomePage(),
),
+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://192.168.31.187: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.
}
}
}
+40
View File
@@ -0,0 +1,40 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 登录设备列表项;只保留页面展示需要的字段。
class AccountDevice {
const AccountDevice({
required this.id,
required this.deviceId,
required this.platform,
required this.deviceModel,
required this.status,
this.firstSeenAt,
this.lastSeenAt,
});
final String id;
final String deviceId;
final String platform;
final String deviceModel;
final String status;
final DateTime? firstSeenAt;
final DateTime? lastSeenAt;
/// 兼容 Long 字符串与数字;userId/deleted 不解析、不展示。
factory AccountDevice.fromJson(Map<String, dynamic> json) {
return AccountDevice(
id: jsonToId(json['id']),
deviceId: json['deviceId']?.toString() ?? '',
platform: json['platform']?.toString() ?? '',
deviceModel: json['deviceModel']?.toString() ?? '',
status: json['status']?.toString() ?? '',
firstSeenAt: _date(json['firstSeenAt']),
lastSeenAt: _date(json['lastSeenAt']),
);
}
static DateTime? _date(dynamic value) =>
DateTime.tryParse(value?.toString() ?? '');
}
+74
View File
@@ -0,0 +1,74 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'account_device.dart';
import 'account_profile.dart';
/// 导出中的登录凭据摘要。
class AccountCredential {
const AccountCredential({
required this.type,
required this.masked,
required this.verified,
});
final String type;
final String masked;
final bool verified;
factory AccountCredential.fromJson(Map<String, dynamic> json) {
return AccountCredential(
type: json['type']?.toString() ?? '',
masked: json['masked']?.toString() ?? '',
verified: json['verified'] == true,
);
}
}
/// 账号导出数据;`rawJson` 用于复制后端下发的完整内容。
class AccountExport {
const AccountExport({
required this.profile,
required this.credentials,
required this.consentCount,
required this.devices,
required this.raw,
});
final AccountProfile profile;
final List<AccountCredential> credentials;
final int consentCount;
final List<AccountDevice> devices;
final Map<String, dynamic> raw;
int get deviceCount => devices.length;
String get rawJson => const JsonEncoder.withIndent(' ').convert(raw);
factory AccountExport.fromJson(Map<String, dynamic> json) {
final profile = _map(json['profile']);
final credentials = _list(json['credentials']);
final consents = _list(json['consents']);
final devices = _list(json['devices']);
return AccountExport(
profile: AccountProfile.fromJson(profile),
credentials: [
for (final item in credentials) AccountCredential.fromJson(item),
],
consentCount: consents.length,
devices: [for (final item in devices) AccountDevice.fromJson(item)],
raw: json,
);
}
static Map<String, dynamic> _map(dynamic value) =>
value is Map ? value.cast<String, dynamic>() : const {};
static List<Map<String, dynamic>> _list(dynamic value) {
if (value is! List) return const [];
return [
for (final item in value)
if (item is Map) item.cast<String, dynamic>(),
];
}
}
+101
View File
@@ -0,0 +1,101 @@
// Author: fruit.com.cn
import 'auth_session.dart';
import 'json_numbers.dart';
/// 当前账号资料;字段与 `GET/PUT /app/me` 的实际响应一致。
class AccountProfile {
const AccountProfile({
required this.uid,
required this.nickname,
required this.avatarUrl,
required this.gender,
required this.countryCode,
required this.regionCode,
required this.language,
required this.timezone,
required this.currency,
required this.status,
required this.maskedTarget,
required this.vipLevel,
this.createdAt,
this.lastLoginAt,
this.vipExpireAt,
});
final String uid;
final String nickname;
final String avatarUrl;
final String gender;
final String countryCode;
final String regionCode;
final String language;
final String timezone;
final String currency;
final String status;
final String maskedTarget;
final int vipLevel;
final DateTime? createdAt;
final DateTime? lastLoginAt;
final DateTime? vipExpireAt;
/// 解析资料响应;缺失字段按空值处理。
factory AccountProfile.fromJson(Map<String, dynamic> json) {
return AccountProfile(
uid: jsonToId(json['uid']),
nickname: json['nickname']?.toString() ?? '',
avatarUrl: json['avatarUrl']?.toString() ?? '',
gender: json['gender']?.toString() ?? '',
countryCode: json['countryCode']?.toString() ?? '',
regionCode: json['regionCode']?.toString() ?? '',
language: json['language']?.toString() ?? '',
timezone: json['timezone']?.toString() ?? '',
currency: json['currency']?.toString() ?? '',
status: json['status']?.toString() ?? '',
maskedTarget: json['maskedTarget']?.toString() ?? '',
vipLevel: jsonToInt(json['vipLevel']),
createdAt: _date(json['createdAt']),
lastLoginAt: _date(json['lastLoginAt']),
vipExpireAt: _date(json['vipExpireAt']),
);
}
static DateTime? _date(dynamic value) =>
DateTime.tryParse(value?.toString() ?? '');
/// 转为本地登录态摘要;只同步登录态实际使用的三项。
AuthUser toAuthUser() =>
AuthUser(uid: uid, nickname: nickname, avatarUrl: avatarUrl);
}
/// 资料更新请求;null 字段不会进入请求体。
class AccountProfileUpdate {
const AccountProfileUpdate({
this.nickname,
this.avatarUrl,
this.gender,
this.birthday,
this.language,
this.timezone,
this.regionCode,
});
final String? nickname;
final String? avatarUrl;
final String? gender;
final String? birthday;
final String? language;
final String? timezone;
final String? regionCode;
/// 只发送本次有值的字段。
Map<String, dynamic> toJson() => {
if (nickname != null) 'nickname': nickname,
if (avatarUrl != null) 'avatarUrl': avatarUrl,
if (gender != null) 'gender': gender,
if (birthday != null) 'birthday': birthday,
if (language != null) 'language': language,
if (timezone != null) 'timezone': timezone,
if (regionCode != null) 'regionCode': regionCode,
};
}
+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,
);
}
}
+52
View File
@@ -0,0 +1,52 @@
// Author: fruit.com.cn
import '../models/account_device.dart';
import '../models/account_export.dart';
import '../models/account_profile.dart';
import 'api_client.dart';
/// 个人中心接口:`/app/me*`。
class AccountApi {
const AccountApi(this._client);
final ApiClient _client;
Future<AccountProfile> profile() async {
final data = await _client.get('/app/me');
return AccountProfile.fromJson(_map(data));
}
Future<AccountProfile> updateProfile(AccountProfileUpdate update) async {
final data = await _client.put('/app/me', body: update.toJson());
return AccountProfile.fromJson(_map(data));
}
Future<List<AccountDevice>> devices() async {
final data = await _client.get('/app/me/devices');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map) AccountDevice.fromJson(item.cast<String, dynamic>()),
];
}
Future<AccountExport> export() async {
final data = await _client.get('/app/me/export');
return AccountExport.fromJson(_map(data));
}
Future<DateTime?> deactivate({String reason = ''}) async {
final data = await _client.post(
'/app/me/deactivate',
body: {'reason': reason.trim()},
);
return DateTime.tryParse(data?.toString() ?? '');
}
Future<void> cancelDeactivation() async {
await _client.post('/app/me/deactivate/cancel');
}
Map<String, dynamic> _map(Object? data) =>
data is Map ? data.cast<String, dynamic>() : const {};
}
+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, '返回数据格式异常');
}
}
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff. Show more