Author SHA1 Message Date
Zhou Weidong 76c4b528d3 merge: 修正双击点赞与心形联动 2026-10-22 22:30:11 +08:00
Zhou Weidong 68ccfa21c0 fix: 修正双击点赞与心形联动 2026-10-22 22:30:01 +08:00
Zhou Weidong 78d0166eb7 merge: 增加播放器双击点赞与飘心 2026-10-22 22:25:22 +08:00
Zhou Weidong 4140c4eb50 feat: 增加播放器双击点赞与飘心 2026-10-22 22:25:14 +08:00
Zhou Weidong e2cc409e5a merge: 重做首页热播榜布局 2026-10-22 22:20:01 +08:00
Zhou Weidong 9b83283689 feat: 重做首页热播榜布局 2026-10-22 22:19:55 +08:00
Zhou Weidong db8433ace3 merge: 统一下拉与加载指示器 2026-10-22 22:07:21 +08:00
Zhou Weidong 6f1da5aa39 style: 统一下拉与加载指示器 2026-10-22 22:07:13 +08:00
Zhou Weidong 397483d339 merge: 统一全站深色视觉 2026-10-22 22:01:45 +08:00
Zhou Weidong 37e35f4b47 style: 统一全站深色视觉 2026-10-22 22:01:36 +08:00
Zhou Weidong 86aa7e0f4f merge: 增加推荐剧单渐变背景 2026-10-22 21:52:20 +08:00
Zhou Weidong 604ab60b63 style: 增加推荐剧单渐变背景 2026-10-22 21:52:13 +08:00
Zhou Weidong 0b3f362093 merge: 修正我的 UID 行高度 2026-10-22 21:49:54 +08:00
Zhou Weidong 19acbb2278 fix: 修正我的 UID 行高度 2026-10-22 21:49:47 +08:00
Zhou Weidong ce3ecc4d21 merge: 收紧我的用户区信息间距 2026-10-22 21:46:57 +08:00
Zhou Weidong c604cc1333 style: 收紧我的用户区信息间距 2026-10-22 21:46:49 +08:00
Zhou Weidong e7367e99be merge: 补充我的页内容区块 2026-10-22 21:44:36 +08:00
Zhou Weidong 9953c955e3 feat: 补充我的页内容区块 2026-10-22 21:44:03 +08:00
Zhou Weidong 7971ac18fc merge: 增强我的页渐变与图标配色 2026-10-22 21:38:55 +08:00
Zhou Weidong a81bec4d47 style: 增强我的页渐变与图标配色 2026-10-22 21:38:49 +08:00
Zhou Weidong 647f6afd77 merge: 改造我的页渐变与图标网格 2026-10-22 21:35:20 +08:00
Zhou Weidong 9785a1baf3 feat: 改造我的页渐变与图标网格 2026-10-22 21:35:14 +08:00
Zhou Weidong e70ca03c03 merge: 调整继续观看按钮样式 2026-10-22 21:28:41 +08:00
Zhou Weidong 95fb2ff882 style: 调整继续观看按钮样式 2026-10-22 21:28:36 +08:00
Zhou Weidong b6e5f808d6 merge: 优化追剧页切换与编辑按钮 2026-10-22 21:26:01 +08:00
Zhou Weidong 3f3dc9263c fix: 优化追剧页切换与编辑按钮 2026-10-22 21:25:55 +08:00
Zhou Weidong f67b8bd514 merge: 调整追剧加号颜色 2026-10-22 21:21:58 +08:00
Zhou Weidong 4d304cb549 style: 调整追剧加号颜色 2026-10-22 21:21:52 +08:00
Zhou Weidong 201d58e8f4 merge: 调整追剧加号卡片顺序 2026-10-22 21:20:12 +08:00
Zhou Weidong 091ea358bd fix: 调整追剧加号卡片顺序 2026-10-22 21:20:06 +08:00
Zhou Weidong af828d2c46 merge: 播放历史单列列表 2026-10-22 21:18:08 +08:00
Zhou Weidong c14d89ffce feat: 调整播放历史为单列列表 2026-10-22 21:18:02 +08:00
Zhou Weidong b185200e8c merge: 追剧页双列表与三列卡片 2026-10-22 21:14:31 +08:00
Zhou Weidong 2fac46f248 feat: 改造追剧页双列表与三列卡片 2026-10-22 21:14:23 +08:00
Zhou Weidong cffb68809c merge: 更正筛选按钮所属页面 2026-10-22 21:06:00 +08:00
Zhou Weidong 13d70fcef2 fix(recommend): 更正筛选按钮所属页面 2026-10-22 21:05:51 +08:00
Zhou Weidong bf20e44b85 merge: 移除二级标签筛选按钮 2026-10-22 21:02:40 +08:00
Zhou Weidong 9cf1efc316 fix(home): 移除二级标签筛选按钮 2026-10-22 21:02:29 +08:00
Zhou Weidong 3655d94869 merge: 修正窄筛选行横向居中 2026-10-22 20:59:14 +08:00
Zhou Weidong 196ce69a12 fix(recommend): 修正窄筛选行横向居中 2026-10-22 20:59:02 +08:00
Zhou Weidong c7a0e277f2 merge: 统一全部短剧筛选左对齐 2026-10-22 20:56:53 +08:00
Zhou Weidong 57c9e6b477 fix(recommend): 统一全部短剧筛选左对齐 2026-10-22 20:56:45 +08:00
Zhou Weidong 64bc1997ba merge: 修正筛选左起始与横向滑动 2026-10-22 20:53:16 +08:00
Zhou Weidong 21bb716af4 fix(recommend): 修正筛选左起始与横向滑动 2026-10-22 20:53:07 +08:00
Zhou Weidong a40adbb99a merge: 按截图重做全部短剧筛选区 2026-10-22 20:50:03 +08:00
Zhou Weidong d79e200650 style(recommend): 按截图重做全部短剧筛选区 2026-10-22 20:49:54 +08:00
Zhou Weidong 3b9ff8a306 merge: 对齐全部短剧筛选行 2026-10-22 20:46:21 +08:00
Zhou Weidong af4d918938 fix(recommend): 对齐全部短剧筛选行 2026-10-22 20:46:13 +08:00
Zhou Weidong 3289012f64 merge: 固定全部短剧筛选区并统一间距 2026-10-22 20:43:22 +08:00
Zhou Weidong e147002e00 fix(recommend): 固定全部短剧筛选区并统一间距 2026-10-22 20:43:15 +08:00
Zhou Weidong 099d05064e merge: 重排全部短剧筛选与网格 2026-10-22 20:39:09 +08:00
Zhou Weidong 4eb411ef28 style(recommend): 重排全部短剧筛选与网格 2026-10-22 20:39:00 +08:00
Zhou Weidong 14885d9efa merge: 支持横向切换三个内容页 2026-10-22 20:35:06 +08:00
Zhou Weidong 17faa749b2 feat(recommend): 支持横向切换三个内容页 2026-10-22 20:34:58 +08:00
Zhou Weidong 5fce65ac57 merge: 修正标题与观看全集跳转 2026-10-22 20:31:48 +08:00
Zhou Weidong 357492acb2 fix(recommend): 修正标题与观看全集跳转 2026-10-22 20:31:41 +08:00
Zhou Weidong 23799353b5 merge: 修复进度条拖动树稳定性 2026-10-22 20:27:07 +08:00
Zhou Weidong cf6d12b7c8 fix(player): 修复进度条拖动树稳定性 2026-10-22 20:26:58 +08:00
Zhou Weidong 5af6caea6e merge: 修正进度条按下与拖动状态 2026-10-22 20:23:32 +08:00
Zhou Weidong d66ff13d0f fix(player): 修正进度条按下与拖动状态 2026-10-22 20:23:24 +08:00
Zhou Weidong 7599da1d40 merge: 收窄拖动清屏范围并提升灵敏度 2026-10-22 20:19:29 +08:00
Zhou Weidong 04f1f5a73f fix(recommend): 收窄拖动清屏范围并提升灵敏度 2026-10-22 20:19:21 +08:00
Zhou Weidong f5721592f7 merge: 收窄观看全集按钮 2026-10-22 20:16:22 +08:00
Zhou Weidong 5abee5f69a style(recommend): 收窄观看全集按钮 2026-10-22 20:16:13 +08:00
Zhou Weidong 3690fb1bc4 merge: 修正进度拖动与底栏联动 2026-10-22 20:15:14 +08:00
Zhou Weidong 2765343340 fix(recommend): 修正进度拖动与底栏联动 2026-10-22 20:15:07 +08:00
Zhou Weidong afe7a49ef0 merge: 恢复进度拖动前浮层状态 2026-10-22 20:08:53 +08:00
Zhou Weidong b6666290ba fix(recommend): 恢复进度拖动前浮层状态 2026-10-22 20:08:45 +08:00
Zhou Weidong c478eebb0e merge: 修正进度取消状态更新编译错误 2026-10-22 20:04:51 +08:00
Zhou Weidong d731b36c1f fix(recommend): 修正进度取消状态更新编译错误 2026-10-22 20:04:43 +08:00
Zhou Weidong ac4a800b78 merge: 稳定进度拖动与清屏状态 2026-10-22 20:02:02 +08:00
Zhou Weidong 30b55afdd4 fix(recommend): 稳定进度拖动与清屏状态 2026-10-22 20:01:54 +08:00
Zhou Weidong 76c4f1d411 merge: 精简底部信息并优化进度拖动 2026-10-22 19:55:16 +08:00
Zhou Weidong d66ac8327e fix(recommend): 精简底部信息并优化进度拖动 2026-10-22 19:55:07 +08:00
Zhou Weidong ccda41b15b merge: 修正热播信息行与操作栏位置 2026-10-22 19:46:52 +08:00
Zhou Weidong 0c06a6dccd fix(recommend): 修正热播信息行与操作栏位置 2026-10-22 19:46:44 +08:00
Zhou Weidong 8737937875 merge: 对齐沉浸式推荐页视觉布局 2026-10-22 19:42:33 +08:00
Zhou Weidong 5dd3492aea feat(recommend): 对齐沉浸式推荐页视觉布局 2026-10-22 19:42:22 +08:00
Zhou Weidong 77689a3eaa merge: 再次加宽首页胶片带素材 2026-10-22 19:34:07 +08:00
Zhou Weidong b22ac6ab04 chore(home): 再次加宽首页胶片带素材 2026-10-22 19:34:00 +08:00
Zhou Weidong 2fdff34ed0 merge: 加宽首页胶片带素材 2026-10-22 19:32:25 +08:00
Zhou Weidong f9e9334934 chore(home): 加宽首页胶片带素材 2026-10-22 19:32:20 +08:00
Zhou Weidong 856a825a74 merge: 更新首页胶片带导航素材 2026-10-22 19:31:01 +08:00
Zhou Weidong df960f84ea chore(home): 更新首页胶片带导航素材 2026-10-22 19:30:52 +08:00
Zhou Weidong bfe1a2f9e8 merge: 放大底部导航图标与文字 2026-10-22 19:30:11 +08:00
Zhou Weidong fd2b0dbf23 style(home): 放大底部导航图标与文字 2026-10-22 19:30:03 +08:00
Zhou Weidong 5860302ce7 merge: 首页图标区分实心选中态 2026-10-22 19:29:18 +08:00
Zhou Weidong 4592b5dfba feat(home): 首页图标区分实心选中态 2026-10-22 19:29:12 +08:00
Zhou Weidong 6a10992812 merge: 更新首页导航摄影机素材 2026-10-22 19:26:36 +08:00
Zhou Weidong 8ae2ed4d93 chore(home): 更新首页导航摄影机素材 2026-10-22 19:26:31 +08:00
Zhou Weidong b1963a718e merge: 使用胶片盘图片导航图标 2026-10-22 19:24:40 +08:00
Zhou Weidong f47e4dd298 feat(home): 使用胶片盘图片导航图标 2026-10-22 19:24:34 +08:00
Zhou Weidong c24518c1a4 merge: 更换底部首页导航图标 2026-10-22 19:22:09 +08:00
Zhou Weidong 97e2db1b7b feat(home): 更换底部首页导航图标 2026-10-22 19:22:01 +08:00
Zhou Weidong bd4bdf8aea merge: 修正二级筛选按钮右对齐 2026-10-22 19:19:56 +08:00
Zhou Weidong 38643e7fde fix(home): 修正二级筛选按钮右对齐 2026-10-22 19:19:50 +08:00
Zhou Weidong 2cfa655750 merge: 修正 ChangeNotifier 监听生命周期 2026-10-22 19:14:41 +08:00
Zhou Weidong 8936c3d6eb fix(client): 修正 ChangeNotifier 监听生命周期 2026-10-22 19:14:35 +08:00
Zhou Weidong 4291031ace merge: 优化短剧列表下拉刷新体验 2026-10-22 19:11:36 +08:00
Zhou Weidong 7945f3ffed fix(home): 优化短剧列表下拉刷新体验 2026-10-22 19:11:30 +08:00
Zhou Weidong f69a27bb80 merge: 修复分类分页控制器初始化 2026-10-22 19:07:41 +08:00
Zhou Weidong d6f1cf2a2c fix(home): 修复分类分页控制器初始化 2026-10-22 19:07:32 +08:00
Zhou Weidong 6c1f3b9eae merge: 支持滑动切换首页分类 2026-10-22 19:06:05 +08:00
Zhou Weidong 5282194ca5 feat(home): 支持滑动切换首页分类 2026-10-22 19:05:55 +08:00
Zhou Weidong b921f79a71 merge: 更新搜索栏图标比例与间距 2026-10-22 19:03:52 +08:00
Zhou Weidong 4fa9d36b45 assets(home): 更新搜索栏图标比例与间距 2026-10-22 19:03:42 +08:00
Zhou Weidong 024f5f3736 merge: 统一搜索栏图标视觉比例 2026-10-22 19:00:08 +08:00
Zhou Weidong 403cce0e3b assets(home): 统一搜索栏图标视觉比例 2026-10-22 18:59:58 +08:00
Zhou Weidong ae8546fcf9 merge: 收紧二级标签间距 2026-10-22 18:59:19 +08:00
Zhou Weidong 5a4e910554 fix(home): 收紧二级标签间距 2026-10-22 18:59:11 +08:00
Zhou Weidong 147662d085 merge: 添加搜索栏皇冠与礼物图标 2026-10-22 18:58:11 +08:00
Zhou Weidong 714e5caec3 assets(home): 添加搜索栏皇冠与礼物图标 2026-10-22 18:58:03 +08:00
Zhou Weidong c1d4b61936 merge: 使用图片渲染搜索栏入口 2026-10-22 18:52:51 +08:00
Zhou Weidong 953ddec587 fix(home): 使用图片渲染搜索栏入口 2026-10-22 18:52:43 +08:00
Zhou Weidong afd8927a9f merge: 放大搜索栏右侧图标 2026-10-22 18:48:29 +08:00
Zhou Weidong 8feede1763 fix(home): 放大搜索栏右侧图标 2026-10-22 18:48:20 +08:00
Zhou Weidong f4e5af19fe merge: 调整搜索栏右侧入口样式 2026-10-22 18:46:27 +08:00
Zhou Weidong 909762ac3b fix(home): 调整搜索栏右侧入口样式 2026-10-22 18:46:19 +08:00
Zhou Weidong acae05d354 merge: 修复样式常量与测试类型错误 2026-10-22 18:43:11 +08:00
Zhou Weidong ba0ce2f8c0 fix(home): 修复样式常量与测试类型错误 2026-10-22 18:43:02 +08:00
Zhou Weidong 62cb4d9809 merge: 稳定二级标签切换布局 2026-10-22 18:41:05 +08:00
Zhou Weidong b3ea85df41 fix(home): 稳定二级标签切换布局 2026-10-22 18:40:56 +08:00
Zhou Weidong 76296f2be0 merge: 对齐二级筛选图标中心线 2026-10-22 18:38:48 +08:00
Zhou Weidong 4304727ffd fix(home): 对齐二级筛选图标中心线 2026-10-22 18:38:41 +08:00
Zhou Weidong 8ac781bb5e merge: 重绘二级筛选三线图标 2026-10-22 18:37:22 +08:00
Zhou Weidong 448010115f fix(home): 重绘二级筛选三线图标 2026-10-22 18:37:16 +08:00
Zhou Weidong 8197abd55d merge: 调整二级筛选按钮尺寸 2026-10-22 18:36:35 +08:00
Zhou Weidong c6f3e3fa68 fix(home): 调整二级筛选按钮尺寸 2026-10-22 18:36:30 +08:00
Zhou Weidong 368fbe7ef1 merge: 调整短剧封面比例 2026-10-22 18:34:17 +08:00
Zhou Weidong 91f4de01a1 fix(home): 调整短剧封面比例 2026-10-22 18:34:11 +08:00
Zhou Weidong 3886c577eb merge: 调整封面播放量图标与位置 2026-10-22 18:33:38 +08:00
Zhou Weidong 0e7dcf4739 fix(home): 调整封面播放量图标与位置 2026-10-22 18:33:31 +08:00
Zhou Weidong f20e7ba7c5 merge: 调整封面播放量与状态角标 2026-10-22 18:31:09 +08:00
Zhou Weidong 8375d8b255 fix(home): 收紧封面状态角标 2026-10-22 18:31:03 +08:00
Zhou Weidong 972e542e5c fix(home): 调整封面播放量位置 2026-10-22 18:30:33 +08:00
Zhou Weidong e11f55777e merge: 调整一级分类未选字号 2026-10-22 18:29:28 +08:00
Zhou Weidong 325f7c1f5d fix(home): 调整一级分类未选字号 2026-10-22 18:29:22 +08:00
Zhou Weidong da69a058b4 merge: 优化二级标签选中样式 2026-10-22 18:28:51 +08:00
Zhou Weidong 1adcc16727 fix(home): 优化二级标签选中样式 2026-10-22 18:28:44 +08:00
Zhou Weidong 5da836dded merge: 调整播放量行内展示 2026-10-22 18:28:00 +08:00
Zhou Weidong f7782aef14 fix(home): 调整播放量行内展示 2026-10-22 18:27:55 +08:00
Zhou Weidong c9fce7745e merge: 修正封面角标与题材标签 2026-10-22 18:26:59 +08:00
Zhou Weidong e16faa258a fix(home): 修正封面角标与题材标签 2026-10-22 18:26:52 +08:00
Zhou Weidong 3abf69e886 merge: 收紧首页顶部区间距 2026-10-22 18:23:44 +08:00
Zhou Weidong ce310afa89 fix(home): 收紧首页顶部区间距 2026-10-22 18:23:37 +08:00
Zhou Weidong d3fdb3c631 merge: 收紧短剧卡片间距 2026-10-22 18:22:52 +08:00
Zhou Weidong 9543d850c8 fix(home): 收紧短剧卡片间距 2026-10-22 18:22:46 +08:00
Zhou Weidong 81dd33e8c7 merge: 调整首页分类与卡片标签样式 2026-10-22 18:22:11 +08:00
Zhou Weidong 4f6116896f fix(home): 调整首页分类与卡片标签样式 2026-10-22 18:22:05 +08:00
Zhou Weidong 7446431511 merge: 修正首页分类与卡片布局 2026-10-22 18:14:40 +08:00
Zhou Weidong aa4870cce6 fix(home): 修正首页分类与卡片布局 2026-10-22 18:14:33 +08:00
Zhou Weidong 10477562d4 merge: 对齐短剧列表字号与间距 2026-10-22 18:06:09 +08:00
Zhou Weidong d7a59e5a27 style(client): 对齐短剧列表字号与间距 2026-10-22 18:06:03 +08:00
Zhou Weidong d03dcf162e merge: 移除全局两侧悬浮元素 2026-10-22 17:58:51 +08:00
Zhou Weidong 9cf832b318 refactor(navigation): 移除全局两侧悬浮元素 2026-10-22 17:58:43 +08:00
Zhou Weidong 5cead7ce00 merge: 重排我的页并新增迷你播放器 2026-10-22 17:45:05 +08:00
Zhou Weidong 79bdef4b43 docs(mine): 回填我的页改版记录 2026-10-22 17:44:59 +08:00
Zhou Weidong cbc6d80cd4 feat(mine): 重排我的页并新增迷你播放器 2026-10-22 17:44:48 +08:00
Zhou Weidong 5aa8170951 merge: 修正推荐子页内容映射 2026-10-22 17:37:03 +08:00
Zhou Weidong d06967c6ab fix(recommend): 修正推荐子页内容映射 2026-10-22 17:36:58 +08:00
Zhou Weidong af6b9bb3db merge: 追剧页新增三栏与双列网格 2026-10-22 17:32:17 +08:00
Zhou Weidong 97e3f4fc47 feat(following): 新增追剧三栏与双列网格 2026-10-22 17:32:09 +08:00
Zhou Weidong 73380ee090 merge: 推荐页新增分区流与全部短剧 2026-10-22 17:23:41 +08:00
Zhou Weidong f9aec827cf docs(recommend): 回填推荐页改版记录 2026-10-22 17:23:36 +08:00
Zhou Weidong f1b5418954 feat(recommend): 新增分区流全部短剧与剧单 2026-10-22 17:23:22 +08:00
Zhou Weidong 0ef2438100 merge: 统一我的页深色视觉风格 2026-10-22 17:07:32 +08:00
Zhou Weidong e40f4779c0 docs(mine): 回填我的页视觉统一记录 2026-10-22 17:07:27 +08:00
Zhou Weidong 83129acae4 feat(mine): 统一我的页深色视觉风格 2026-10-22 17:07:09 +08:00
Zhou Weidong 47bf6458e8 merge: 首页切换三列探索网格 2026-10-22 17:04:08 +08:00
Zhou Weidong 1bb227e083 docs(home): 回填首页网格改版记录 2026-10-22 17:04:03 +08:00
Zhou Weidong dd96792e33 feat(home): 首页改为三列探索网格 2026-10-22 17:03:45 +08:00
Zhou Weidong 6dea9d479e merge: 对齐四 Tab 底栏布局规格 2026-10-22 16:38:29 +08:00
Zhou Weidong 5362f97744 docs(nav): 回填底栏布局规格记录 2026-10-22 16:38:25 +08:00
Zhou Weidong 81787dc3aa style(nav): 对齐四 Tab 底栏布局规格 2026-10-22 16:38:14 +08:00
Zhou Weidong 667fdc5f0a merge: 合并四 Tab 底部导航 2026-10-22 16:21:39 +08:00
Zhou Weidong 729c226c95 docs(nav): 回填四 Tab 底部导航记录 2026-10-22 16:21:30 +08:00
Zhou Weidong 91e869d589 feat(nav): 底部导航改为首页推荐追剧我的 2026-10-22 16:21:05 +08:00
Zhou Weidong ec3cb8f163 merge: 客户端切换深色视觉风格 2026-10-22 13:49:38 +08:00
Zhou Weidong 4bc04fd8ae style(theme): 客户端切换深色视觉风格 2026-10-22 13:49:20 +08:00
Zhou Weidong 295c21513c merge: 观看历史改用分页契约 2026-10-22 13:21:36 +08:00
Zhou Weidong 5b2d8445dc refactor(watch): 观看历史改用分页契约 2026-10-22 13:21:27 +08:00
Zhou Weidong 1924b8626f chore(merge): 修复我的收藏与观看历史列表不显示 2026-10-22 13:10:03 +08:00
154 changed files with 12370 additions and 3170 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -0,0 +1,28 @@
# ReelShort 客户端深色视觉改造
## 目标
- 深色设为默认且唯一主题,页面、卡片、浮层、播放器和底部导航保持一致。
- 色板收敛到 `AppColors`,主题组件配置收敛到 `AppTheme`。
- 保持现有布局、信息结构、路由、业务逻辑和文案不变。
## 结果
- 背景 `#0B1020`、卡片 `#151B2E`、浮层 `#1C2436`、分割线白色 6%。
- 主文字白色 92%、次文字 64%、禁用 38%。
- 主色 `#FF6B2C`,辅助链接色 `#22D3EE`。
- 卡片 12、按钮/输入框 10、标签 6,阴影关闭,组件使用分割线分层。
- 页面级透明、遮罩、阴影、媒体底、玻璃面和渐变全部映射到 AppColors 语义 token。
- 系统栏统一浅色图标,AppBar 与底部导航使用深色表面。
## 验证
- 主题 token、对比度、系统栏、圆角与关键页 smoke 测试。
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 约束
- 不提交 `pubspec.lock` 漂移。
- 不启停服务、不操作真机。
+571
View File
@@ -730,3 +730,574 @@ $ flutter test --no-pub
解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致;
全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。
`pubspec.lock` 既有改动未提交。
## 2026-10-22 ReelShort 客户端深色视觉改造
- 分支:`feature/dark-theme-client`
- 基线:开发前已有约 43 个未提交视觉文件,本次在原改动上继续收敛,未推倒重来。
- 计划:`docs/plans/2026-10-22-dark-theme-client.md`
### 颜色与主题
- `AppTheme.dark()` 成为默认且唯一主题,系统状态栏/导航栏图标统一浅色。
- 色板统一为背景 `#0B1020`、卡片 `#151B2E`、浮层 `#1C2436`、
divider `#0FFFFFFF`、主色 `#FF6B2C`、辅助链接色 `#22D3EE`。
- 主/次/禁用文字分别使用白色 92% / 64% / 38%。
- 卡片/按钮/输入框/标签圆角统一为 12/10/10/6,卡片阴影关闭。
- 页面和组件内的 `Colors.*`、`Color(0x...)` 已全部映射到 `AppColors`
语义 token;剩余原始色值只在 `app_colors.dart` 中定义。
- 导航、玻璃面板、播放器遮罩、加载轨道、渐变、边框和阴影均改为深色语义。
- 修正全局输入装饰 padding 导致账户性别下拉溢出,保持原布局宽度。
### 验证命令与原始结果
```text
$ git checkout -- pubspec.lock
$ git status --short --branch
## feature/dark-theme-client
$ rg -n -P "Color\(0x|(?<!App)Colors\." lib | rg -v '^lib/core/theme/'
(no output)
$ rg -n "SystemUiOverlayStyle\.dark" lib
(no output)
```
解读:pubspec.lock 漂移已还原;页面/组件层不再直接写颜色,所有状态栏均使用浅色图标。
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
```
```text
$ flutter test --no-pub
00:51 +644: All tests passed!
```
```text
$ flutter build apk --debug
Running Gradle task 'assembleDebug'...
2,058ms
✓ Built build/app/outputs/flutter-apk/app-debug.apk
```
解读:静态分析零告警,644 项测试全部通过,debug APK 构建成功;测试与构建后再次确认
`pubspec.lock` 无改动,未启停服务、未操作真机。
## 2026-10-22 客户端底部导航改为首页/推荐/追剧/我的
- 分支:`feature/client-bottom-nav-4tabs`
- 基线:`develop` / `origin/develop` = `ec3cb8f`
- 计划:`docs/plans/2026-10-22-client-bottom-nav-4tabs.md`
- 实现提交:`91e869d feat(nav): 底部导航改为首页推荐追剧我的`
### 问题
根导航仍为首页/剧场/创作/我的,底栏选中态使用品牌橙。追剧若通过新路由打开,
会叠加第二层底栏并产生返回栈,不符合参考图和单根导航交互。
### 改动
- 新增 `AppRootTab` 统一四个 tab 的顺序、key、图标和 l10n 标签。
- `AppBottomNav` 改为纯黑底、白色选中、`#8A8A8E` 未选中,图标 24、文字 11,
保留 SafeArea 与轻震动。
- 首页 `IndexedStack` 第三槽直接使用 `FavoritesContent`,不再 push 收藏路由。
追剧保留顶部标题区、隐藏返回按钮,只有一个根底栏,不创建嵌套 Scaffold。
- `FavoritesPage` 保留独立 Scaffold 和返回行为,继续供“我的”入口使用。
- 推荐、追剧、我的三个根页面统一调用 `HomeLogic.select` 切换,不进入返回栈。
- 创作页保留,`mine-creator-center` 入口保留;创作不再出现在根底栏。
- 中文使用“追剧”,英文使用 `Home / Recommend / Following / My`,
印尼语使用 `Beranda / Rekomendasi / Daftar Tonton / Saya`。
- `FavoritesContent` 内置顶部 SafeArea,避免嵌入首页后被状态栏遮挡。
### 门禁命令与原始输出
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
```
```text
$ flutter test --no-pub
04:38 +644: All tests passed!
```
导航测试覆盖:四个 tab 数量与顺序、纯白/灰色状态、点击追剧进入
`FavoritesContent`、仅一个 `AppBottomNav`、追剧内部无嵌套 Scaffold、
`Navigator.canPop == false`、创作不再出现在底栏且我的入口保留。
解读:静态分析零告警,644 项测试全部通过;追剧与首页/推荐/我的均在同一根页面
内切换,无叠加导航或返回栈。`pubspec.lock` 未提交,未启动或停止服务,未操作真机。
## 2026-10-22 根导航布局规格对齐
- 分支:`feature/client-bottom-nav-layout`
- 基线:`develop` / `origin/develop` = `667fdc5`
- 实现提交:`81787dc style(nav): 对齐四 Tab 底栏布局规格`
### 改动
- 新增 `AppRootBottomNav` 作为首页、推荐、追剧、我的唯一根导航入口,
三个 Shell 页面不再各自拼装 item。
- 四列使用 `Expanded` 等分,图标在上、文字在下,间距 4。
- 内容高度 56,另由 `SafeArea` 增加底部安全区;图标 26、文字 11、
`letterSpacing = 0`,选中 `w600`、未选中 `w400`。
- 背景纯黑,取消顶部边框;选中 `#FFFFFF`,未选中 `#8E8E93`。
- 首页、推荐、书签、人像继续使用各自的选中实心/未选中描边图标。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found! (ran in 5.2s)
```
```text
$ flutter test
04:43 +645: All tests passed!
```
解读:四等分、56 内容高度、安全区、26 图标、4 间距、精确色值和无分割线
均有测试覆盖;645 项测试全部通过。`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 首页改为三列探索网格
- 分支:`feature/home-grid-layout`
- 基线:`develop` / `origin/develop` = `6dea9d4`
- 实现提交:`dd96792 feat(home): 首页改为三列探索网格`
### 布局与数据
- 首页按参考图改为纯黑探索页:搜索框、皇冠、礼物入口、一级分类、
二级标签与三列封面网格。
- `HomeFeedController` 继续使用 10 条游标分页;支持下拉刷新、触底加载、
空态与失败重试,没有全量拉取。
- 一级固定项为精选/新剧/分类/榜单;古装/社群仅在 `/app/categories`
名称匹配时显示。新剧/分类/榜单复用现有页面。
- 二级标签按分类与题材可用性显示,筛选按钮复用 `FilterPage`。
- 卡片封面 5:6、圆角 8、间距 8;包含真实热剧/新剧/漫剧角标、
播放量、最多两行标题与最多两个题材标签。
- `Drama` 增加 `dramaType` 和可选 `createdAt`;卡片未下发上架时间时
从 Snowflake id 解析,不使用假数据。
- 原 `PlayerPage(homeMode: true)` 保留并迁至推荐 tab,播放器文件与测试未删除。
- 首页顶部中性色、卡片与描边 token 已沉淀到 `AppColors/AppDimens`,
后续 P14 复用。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found! (ran in 2.7s)
```
```text
$ flutter test
02:32 +651: All tests passed!
```
解读:三列网格、分类顺序、动态入口隐藏、角标、卡片信息、分页大小、
下一页追加和卡片进入播放器均有测试覆盖。`home_view.dart` 为 478 行,
其余新增组件均低于 500 行。`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 「我的」页视觉风格统一
- 分支:`feature/mine-visual-style`
- 基线:`develop` / `origin/develop` = `47bf645`
- 实现提交:`83129ac feat(mine): 统一我的页深色视觉风格`
### Token 与改动
- 复用 P11 的 `appBackground/contentCard/contentSurface/contentBorder`,
新增 `textPrimary/mineCardRadius/mineRowHeight/minePageTitle/mineSectionTitle`。
- 页面底色改为纯黑,移除「我的」页的旧深蓝渐变;页头白色标题 21/w700。
- 头像、三宫格、金币卡、会员卡、菜单统一为深灰 16 圆角、1px 极淡描边,
无阴影、无渐变、无发光。
- 主文字/昵称/金额使用 `#FFFFFF`,次级说明和 chevron 使用 `#8E8E93`。
- 三宫格等宽,入口图标 24 线性;菜单行高 52、标题 15,分割线只存在行间。
- 橙色仅保留在充值、加入 VIP 等主操作;头像与菜单图标不再使用粉色/橙色装饰。
- 区块顺序、全部入口、跳转与交互逻辑保持不变。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found!
```
```text
$ flutter test
00:56 +651: All tests passed!
```
解读:背景、卡片 token、圆角/描边、无渐变阴影、三宫格等宽、菜单 52 行高、
入口保留和收藏页跳转均有测试覆盖。`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 推荐页沉浸流 / 剧单 / 全部短剧
- 分支:`feature/recommend-section-feed`、`feature/recommend-tab-feed-correction`
- 修正基线:`develop` / `origin/develop` = `af6b9bb`
固定顶部三 tab 为推荐 / 剧单 / 全部短剧,右侧保留搜索。推荐 tab 直接内嵌
`PlayerPage(homeMode: true, embedded: true)`,支持一屏一剧、上下滑切换、
收藏/点赞/选集/简介/进度条等现有播放器能力;播放器内部频道标题条关闭,
只保留视频与浮层。剧单 tab 包含本週爆款推薦、本週精選新劇、古裝神劇大賞
三条横滑卡片,数据分别本地按播放量、上架时间和古装分类/题材排序过滤。
全部短剧提供四行可用筛选 chips、三列首页同款卡片和 20 条触底分页;
无后端字段的本地/地区/字幕/配音筛选项不展示。根底栏始终保留四个入口。
```text
$ flutter analyze
Analyzing reelshort-recommend-correction...
No issues found! (ran in 3.2s)
$ flutter test
01:03 +644: All tests passed!
```
解读:沉浸式推荐播放器、剧单三分区横滑、四行筛选、三列网格、tab 状态保留、
搜索、卡片播放、播放器路由覆盖/恢复均有测试覆盖;所有生效文件低于 500 行。
`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 追剧页三 tab 与双列网格
- 分支:`feature/following-tabs-grid`
- 基线:`develop` / `origin/develop` = `73380ee`
### 页面与数据
- 根导航追剧槽位改为内嵌 `FollowingPage`,不再 push 收藏路由,也不叠加
第二层底部导航。
- 页面提供追剧 / 播放历史 / 预约三个子 tab,使用 `IndexedStack` 保留各自
滚动位置;切换 tab 会退出编辑态。
- 追剧复用 `FavoriteController`,显示“第 N 集 / 共 M 集”,支持下拉刷新、
触底分页、加载/错误/无更多状态。
- 播放历史复用 `WatchRecordsController`,优先使用分页接口的
`dramaTitle/dramaCover/episodeNumber`,仅在标题缺失时补取详情;卡片展示
已看集数与可用进度条,同样支持刷新和分页。
- 双列网格使用共享卡片:3:4 封面、播放量胶囊、两行标题、副信息与编辑态
减号。删除前二次确认,最后一条移除后自动退出编辑态。
- 预约没有独立分页数据源,本轮保留真实空态;未登录沿用 `AuthGatePanel`。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found! (ran in 1.9s)
```
```text
$ flutter test
01:12 +643: All tests passed!
```
解读:三 tab、双列网格、后端历史元数据直出、编辑删除确认、最后一条自动
退出编辑、未登录与空态均有测试覆盖;`following_page.dart` 低于 500 行。
`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 「我的」页布局重排与迷你播放器
- 分支:`feature/mine-layout-redesign`
- 基线:`develop` / `origin/develop` = `5aa8170`
- 实现提交:`cbc6d80 feat(mine): 重排我的页并新增迷你播放器`
### 页面与入口
- 用户区改为 56 圆形头像、20/w700 昵称与 13 灰色 UID;UID 支持复制,
未登录显示 Visitor 与登录按钮,已登录显示编辑按钮。
- VIP 改为金色渐变通栏卡,品牌文案走 l10n,右侧开通按钮复用现有 VIP 页。
- 删除收藏/历史/下载三宫格;单列菜单固定为钱包、离线缓存、播放历史、
观看偏好、联系客服、语言、设置,行高 56、图标 22、仅行间显示分割线。
- 收藏由底部追剧 tab 承载;历史、下载、钱包、偏好、客服、语言、设置均
复用现有页面。我的作品、创作者中心与通知保留在登录后的编辑操作面板,
设置页中的通知入口继续可用。
- 我的页 token 收敛到 `AppColors/AppDimens`:背景纯黑、卡片 `#16161A`、
16 圆角、极淡描边、金色渐变仅用于 VIP。
- 左下角迷你播放器为 96x132,直接使用观看历史首条数据,展示封面、剧名和
集号;无记录或关闭后不占位,点击按记录集号进入全屏播放器。
- 删除三宫格对应的 `localViewer/watchlist` 废弃 l10n key 与旧组件代码。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-mine-redesign...
No issues found! (ran in 2.1s)
```
```text
$ flutter test
01:01 +647: All tests passed!
```
解读:用户区、UID、登录/编辑、VIP、7 项菜单顺序与跳转、旧入口可达性、
迷你播放器显隐/关闭/开播及无数据不渲染均有测试覆盖。`mine_view.dart`
与新增子组件均低于 500 行;`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 全局移除两侧悬浮元素
- 分支:`feature/remove-floating-overlays`
- 基线:`develop` / `origin/develop` = `5cead7c`
### 改动
- 保留首页顶部礼物入口的 `home-wallet-badge` 红色余额角标。
- 删除「我的」页 `MineMiniPlayer` 文件、渲染分支及最近观看开播逻辑。
- 删除仅为迷你播放器新增的 `mineMini*` 尺寸 token;没有新增专属 l10n key。
- 首页、推荐、剧单、全部短剧和追剧页均不渲染两侧游戏/活动悬浮球、
悬浮关闭按钮或迷你播放器。
- 卡片角标、播放量、标题、标签和顶部钱包红标保持不变。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-home-floating...
No issues found! (ran in 2.5s)
```
```text
$ flutter test
01:32 +647: All tests passed!
```
解读:登录且钱包快照存在时钱包红标仍显示;首页四个 tab、推荐三个子 tab、
追剧页和我的页均断言不存在两侧悬浮组件。`pubspec.lock` 未提交,
未操作真机。
## 2026-10-22 首页与短剧列表视觉字号对齐
- 分支:`feature/visual-type-scale`
- 基线:`develop` / `origin/develop` = `d03dcf1`
### Token 与组件
- 新增 `textTertiary = #9E9E9E`,以及搜索、页签、筛选 chip、分区标题、
卡片标题/播放量/标签/角标、封面比例和网格间距的公共 token。
- 搜索框统一为高 40、圆角 8、placeholder 13;一级分类 16,选中
白字 w700,未选中 `#9E9E9E` w400;二级标签 13 并采用参考图选中/未选中
底色。
- 三列首页网格与全部短剧网格统一左右边距 14、列间距 6、行间距 18;
封面 5:6、圆角 6。
- 首页、剧单横滑、追剧双列卡片统一标题 15/w500/1.3、播放量 11 白字、
标签 11/高 18/左右 6,封面到标题 8、标题到标签 6。
- 剧单分区标题 17/w700,副标题 11 `#8E8E93`,更多 12 `#8E8E93`;
分区间距保持 24。
- 全部短剧筛选 chip 统一高 30、圆角 15、字号 13;我的页标题 17,
追剧顶部页签 16。
### 门禁命令与原始输出
```text
$ flutter analyze
Analyzing reelshort-visual-type...
No issues found! (ran in 1.8s)
```
```text
$ flutter test
00:57 +648: All tests passed!
```
解读:首页搜索/页签/二级标签、三类卡片字体与标签、5:6 封面、网格间距、
筛选 chip、我的标题和追剧双列 320 宽度适配均有测试覆盖。目标组件均低于
500 行;`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 首页布局对齐修正
- 一级分类未选 18/w400、选中 20/w700;二级 chips 间距 10、左右 8、15/w600,并以稳定 AnimatedContainer 切换。
- 首页内容区改为横向 PageView:点击与滑动共用 PageController,各 tab 保留纵向滚动位置。
- PageController 与各 tab ScrollController 改为字段直接初始化,修复热重载后的 LateInitializationError。
- 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。
- 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。
- 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。
- 搜索框右侧皇冠与礼物使用 28 图片图标,缺图时回退原彩色 Icon;透明热区、间距和角标不变。
- 皇冠/礼物 144×144 透明 PNG 已归档到 `assets/icon/`,组件路径与资源注册一致。
- 两张图标已按统一 104/144 内容高重新归一化,28×28 渲染时视觉高度一致。
- 第二轮素材按 100/144 内容高更新,入口改为 30×30、间距 6,礼物角标继续对齐右上角。
- 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。
- 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。
- 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。
## 2026-10-22 短剧列表刷新体验优化
- 公共下拉刷新统一为白指示器、40 displacement、Bouncing + AlwaysScrollable,并在下拉期间屏蔽内容点击与滚动条。
- 首页移除父级 feed 监听,网格、剧单和全部短剧改为局部监听,刷新时保留旧数据及原滚动位置。
- 剧单、全部短剧、追剧沿用相同刷新策略;首帧加载与刷新指示器分离。
- 按当前流程未执行 analyze/test。
## 2026-10-22 ChangeNotifier 监听生命周期排查
- 全仓核对手动 add/remove;首页、推荐和追剧改为内容区单实例 ListenableBuilder,避免重复订阅。
- 播放器进度监听改为具名回调,在切换、重建会话和 dispose 时解绑;评论输入弹窗补 controller 更换迁移。
- `_onFeedChanged` 已无源码调用链,报错来源为热重载前注册的旧 tear-off;需完全关闭 App 后冷启动。
- 按当前流程未执行 analyze/test。
## 2026-10-22 二级标签筛选按钮右对齐
- 三横线热区由 44 宽改为 26×26,消除 44 宽居中盒造成的视觉右边距偏移。
- 标签滚动区与按钮间距改为 6,按钮右边缘固定对齐页面 14 边距。
- 补充按钮尺寸与屏幕右边距断言;按当前流程未执行 analyze/test。
## 2026-10-22 首页导航图标调整
- `AppRootTab.home` 未选中改为 `theaters_outlined`,选中改为 `theaters`。
- 其余三个根导航图标、26 尺寸及白/灰颜色规则保持不变;测试断言同步。
- 按当前流程未执行 analyze/test。
## 2026-10-22 首页圆形胶片盘图标
- `AppRootTab` 新增可选 `assetPath`,首页使用 `assets/icon/film_reel.png`。
- 共享导航通过 `ColorFiltered + Image.asset` 按选中态着色,尺寸保持 26,缺图回退胶片 Material 图标。
- 推荐、追剧、我的图标不变;素材与测试断言同步,按当前流程未执行 analyze/test。
## 2026-10-22 首页选中态实心摄影机
- `AppRootTab` 新增 `selectedAssetPath`,首页选中使用 `film_reel_filled.png`,未选中使用描边版。
- 共享导航保持 `ColorFiltered(srcIn)` 与 26×26,缺图按选中态回退实心/描边胶片图标。
- 推荐、追剧、我的不变;测试断言同步,按当前流程未执行 analyze/test。
## 2026-10-22 底部导航图标与文字加大
- 公共 token 调整为图标 30、文字 13、图标文字间距 5、内容高度 62。
- 图片首页图标同步使用 30×30;SafeArea 仍在内容区外独立追加,四等分布局不变。
- 尺寸测试断言同步,按当前流程未执行 analyze/test。
## 2026-10-22 推荐页沉浸式布局对齐
- 推荐/剧单/全部短剧顶部改为视频内居中浮层,17 字号、无下划线,搜索固定右侧 14。
- 右侧保留收藏/点赞/选集,白色 32 图标、28 间距;评论、分享入口已从该浮层移除。
- 底部信息重构为标题、真实热度、剧名、题材/集数、可展开简介与渐变观看全集按钮。
- 新增三语“观看全集”文案;按当前流程未执行 analyze/test。
- 榜单行支持可选排名:有名次显示 `TOPn·热播榜`,无名次回退“热播榜”;热度值改用 `w` 格式。
- 题材改为空格分隔,集数固定右侧单行;右侧操作栏改为距屏幕边缘 8,底部位置不变。
## 2026-10-22 推荐页底部信息二次修正
- 按最新反馈移除整行榜单与热度胶囊,信息顺序收敛为标题、剧名、标签、简介、观看全集。
- 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。
- 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。
- 拖动仅控制视频浮层内容,顶部导航与根底栏保持常驻。
- 进度条固定中心并动画到 6 高,命中高度 32,时间气泡限制在左右 12;end/cancel/pointer 兜底均恢复拖动前状态。
- 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。
- 观看全集按钮相对进度条上移 8,间距调整为 20;按钮改为屏幕宽度 70% 左对齐,`flutter analyze` 无问题。
## 2026-10-22 进度条按下与拖动彻底修复
- Slider 不再在 `onChangeStart` 清屏;首次真实移动后才进入 scrub,轨道 2/6、thumb 6/8。
- 推荐流拖动使用独立 opaque 手势与 pointer 兜底,end/cancel 同时恢复浮层和轨道。
- 推荐浮层改为 AnimatedOpacity + IgnorePointer,进度条节点始终留在树中;scrub setState 均加 mounted 守卫。
- `flutter test test/player/seek_scrub_visibility_test.dart`:6 项测试全部通过。
## 2026-10-22 推荐页播放跳转修正
- 标题、剧名行和观看全集统一通过 `playDramaFromCatalog` 打开非 `homeMode` 逐集播放页。
- 仅含集数的剧集标题不再显示在标题上方,集数只保留下方进度文案。
- `flutter analyze`:No issues found。
## 2026-10-22 推荐页横向切换
- 推荐、剧单、全部短剧改为共享 PageController 的横向 PageView,点击与滑动均驱动同一选中态。
- 三页使用 AutomaticKeepAliveClientMixin 保留视频、滚动位置和筛选状态;顶部导航固定在外层。
- `flutter analyze`:No issues found。
## 2026-10-22 全部短剧布局重排
- 四行筛选统一 28 高、8 行距、24 项目间距;选中 chip 改为 `#4A1E1E`、白字 13/w600、圆角 14。
- 最热/最新改为纯文字选中态,三横线筛选固定在右端;网格改为左右 12、列距 6、行距 12。
- 第三行筛选完整展示,卡片继续复用首页组件;`flutter analyze`:No issues found。
- 筛选项字号提升到 15、圆角 15;筛选区移出滚动容器并固定吸顶,下拉刷新仅作用于网格。
- 四行首项文字统一到同一 x;行高 30、行距 6、项目距 12,第 4 行保持弹性空白后固定筛选图标。
- 按最新截图改为 16sp、32 高、10 行距、16 项距;选中态为 `#21090E` 底配 `#FF4D6A` 粉字。
- 第 4 行复用选中胶囊样式,右侧改为 44×44 圆形深灰按钮和 22 三横线图标;`flutter analyze` 无问题。
- 筛选行距收紧到 6、项距 10,chip 左右 padding 12;四行均可横向回弹滑动。
- 第 4 行仅最热/最新在滚动区内,44 圆形筛选按钮固定在滚动区外;`flutter analyze` 无问题。
- 四行统一复用 `_filterRow` 构建,筛选区仅保留一层左右 12 padding。
- 新增量化测试断言四行首项 x 均为 12、相邻 chip 间距均为 10;`flutter analyze` 无问题。
- 修复横向 viewport 对短内容居中:滚动内容最小宽度撑满视口后由 Row.start 排列。
- `flutter test test/recommend/all_dramas_filter_alignment_test.dart`:1 项测试通过。
## 2026-10-22 首页二级标签移除筛选按钮
- 首页二级标签行改为纯横向滑动,右侧三横线按钮不渲染也不占位。
- 删除不再使用的筛选回调、尺寸 token、颜色和 CustomPainter;全部短剧页筛选按钮保持不变。
- `flutter analyze`:No issues found。
- 按后续更正恢复首页透明三横线按钮(26、无背景、`#7A7A7E`),删除全部短剧第 4 行圆形按钮及回调链路。
## 2026-10-22 追剧页三列改造
- 追剧页移除预约 tab,改为追剧/播放历史双页横向切换,并保留各自滚动位置。
- 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。
- 按最新口径调整列距 6、行距 12、封面圆角 6;`flutter analyze`:No issues found。
## 2026-10-22 播放历史单列列表
- 追剧保持三列网格,播放历史改为左封面右信息的单列列表,行距 16。
- 历史卡片补充 96×128 封面、观看进度、已看百分比、继续观看和编辑删除。
- 追剧加号卡片移到全部追剧卡片之后,空列表时仍在左上角。
- 加号改为 `#8E8E93`、28 尺寸、1.5 描边,卡片底色与圆角不变。
- 刷新、分页与各页独立滚动位置保持;`flutter analyze`:No issues found。
## 2026-10-22 追剧页切换与编辑按钮修正
- 追剧与历史页增加 keep-alive,tab 与编辑态改用局部 ValueNotifier 更新,切换不再重建整个页面。
- 编辑图标调整为 20、`#B0B0B0`,热区 40×40,保持右侧 14 边距和垂直居中。
- 列表刷新期间保留已有数据与各页滚动位置;`flutter analyze`:No issues found。
## 2026-10-22 播放历史继续观看按钮微调
- 继续观看改为无边框、无底色的纯文字按钮,默认 `#B0B0B0`、12/w500,按压态使用品牌色。
- 保持 28 高点击热区和右侧零间距;未执行测试或 analyze。
## 2026-10-22 我的页渐变与图标网格
- 页面背景改为暖黑三段纵向渐变,用户区与 VIP 卡布局保持不变。
- 行式入口替换为 4 列图标网格,7 个原入口按顺序保留,第 8 格为空占位。
- 网格使用 14 边距、半透明深灰底、圆角 12 和仅内部 1px 分隔线;`flutter analyze`:No issues found。
- 渐变增强为 `#3A1B0E`/`#1A0C07`/黑色并叠加顶部品牌橙径向光晕,同时移除页面级「我的」标题栏。
- 7 个宫格图标分别使用金币金、天蓝、薄荷绿、粉红、紫色、青色和冷灰;`flutter analyze`:No issues found。
## 2026-10-22 我的页补充内容
- VIP 与宫格之间新增继续观看横向列表、追剧/观看记录/金币统计面板,继续观看最多 10 条。
- 宫格后新增常见问题与关于我们入口;关于页版本暂用与 pubspec 同步的常量占位。
- 继续观看全部入口可切到追剧页播放历史 tab;`flutter analyze`:No issues found。
- 用户区昵称与 UID 收紧为 4 间距,昵称列相对头像垂直居中;UID 12、复制图标 14。
- UID 行改用 GestureDetector 并锁定 16 高,昵称与 UID 间距 2、昵称行高 1.15,避免 Material 热区撑高;`flutter analyze`:No issues found。
## 2026-10-22 推荐剧单渐变背景
- 仅为推荐页「剧单」子页增加暖黑三段渐变和品牌橙径向光晕,推荐视频与全部短剧背景不变。
- 渐变复用「我的」页色值,光晕使用 `#26FF6B2C`;分区与卡片样式保持原样;`flutter analyze`:No issues found。
## 2026-10-22 全站深色视觉统一
- 全局 token 改为纯黑页面、`#16161A` 卡片、`#1C1C1E` 描边/分割线与 B0/8E 文本层级,旧深蓝页面渐变替换为暖黑渐变。
- 普通二级页标题栏改为透明、白色标题与返回图标;统一卡片 12 圆角、1px 描边和无阴影策略。
- 覆盖新剧、筛选、榜单、预约、收藏、观看历史、下载、设置、钱包、VIP、通知、详情、登录、关于;播放器沉浸区保持纯黑。
- `flutter analyze`:No issues found。
## 2026-10-22 统一下拉与加载指示器
- 下拉刷新统一为 40×40、60% 黑底圆角指示器内嵌 20 白色加载圈,不再显示任何状态文字。
- 网络分页与本地分页 footer 统一仅显示白色加载圈,无更多状态完全隐藏;通知页补触底自动加载。
- 移除不再使用的刷新提示与无更多文案;`flutter analyze`:No issues found。
## 2026-10-22 首页热播榜重做
- 首页榜单 tab 改为独立领奖台 + 单列排行布局,不再复用三列网格。
- 前三名按 2/1/3 排列,冠军放大并上移;第 4 名起使用大号描边名次与横向信息行。
- 榜单沿用现有播放热度排序、分页和播放逻辑;`flutter analyze`:No issues found。
## 2026-10-22 播放器双击点赞与飘心
- 沉浸式播放器改用 onTapUp + 280ms Timer 判定双击,双击取消单击,长按左右倍速与中间更多保持不变。
- 双击和右侧心形共用点赞方法,成功后控制器通知并点亮红色实心心形,失败由 controller 回滚并使用 Toast。
- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。
- `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。
+10 -8
View File
@@ -53,13 +53,15 @@ import 'core/theme/app_colors.dart';
import 'l10n/app_localizations.dart';
import 'pages/home/home_view.dart';
/// 全局系统栏样式:浅暖导航栏、关闭 Android 对比遮罩。
final SystemUiOverlayStyle appSystemOverlayStyle = SystemUiOverlayStyle.dark
/// 全局系统栏样式:深色底 + 浅色图标,关闭 Android 对比遮罩。
final SystemUiOverlayStyle appSystemOverlayStyle = SystemUiOverlayStyle.light
.copyWith(
statusBarColor: Colors.transparent,
statusBarColor: AppColors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: AppColors.ink,
systemNavigationBarDividerColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
);
@@ -280,9 +282,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
localeResolutionCallback: (deviceLocale, _) =>
resolveAppLocale(deviceLocale),
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.light,
theme: AppTheme.light(),
darkTheme: AppTheme.light(),
themeMode: ThemeMode.dark,
theme: AppTheme.dark(),
darkTheme: AppTheme.dark(),
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
value: appSystemOverlayStyle,
child: child ?? const SizedBox.shrink(),
+12
View File
@@ -86,6 +86,8 @@ class Drama {
this.favoriteCount,
this.commentCount,
this.language = '',
this.dramaType = '',
this.createdAt,
this.detailLoaded = false,
});
@@ -109,6 +111,12 @@ class Drama {
/// 卡片语种(如 zh-CN / id),用于剧场语言筛选。
final String language;
/// 内容类型:ANIME 漫剧,LIVE 真人剧。
final String dramaType;
/// 上架时间;卡片接口未直接下发时可由 Snowflake id 估算。
final DateTime? createdAt;
/// 详情接口是否已经取过完整剧集列表。
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
final bool detailLoaded;
@@ -137,6 +145,8 @@ class Drama {
favoriteCount: _optionalInt(json['favoriteCount']),
commentCount: _optionalInt(json['commentCount']),
language: json['language'] as String? ?? '',
dramaType: json['dramaType'] as String? ?? '',
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
);
}
@@ -157,6 +167,8 @@ class Drama {
favoriteCount: _optionalInt(json['favoriteCount']),
commentCount: _optionalInt(json['commentCount']),
language: json['language'] as String? ?? '',
dramaType: json['dramaType'] as String? ?? '',
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
detailLoaded: true,
episodes: (json['episodes'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
+8 -3
View File
@@ -40,7 +40,7 @@ class WatchApi {
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看分页(服务端已按更新时间倒序)。
/// 最近观看分页:`pageIndex`/`pageSize`,解包 `PageResult`。
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
@@ -49,8 +49,13 @@ class WatchApi {
'/app/watch/recent',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
return PageResult.parse(data, WatchProgress.fromJson);
if (data is! Map) {
return const PageResult();
}
return PageResult.fromJson(
Map<String, dynamic>.from(data),
WatchProgress.fromJson,
);
}
/// 删除某部剧的观看记录(记录页清单条用)。
@@ -18,7 +18,7 @@ class HomeFeedController extends ChangeNotifier {
channel: _ChannelFeedState(),
};
static const int pageSize = 10;
static const int pageSize = 20;
static const Duration defaultRequestTimeout = Duration(seconds: 10);
final CatalogRepository _repository;
+8
View File
@@ -7,3 +7,11 @@ String formatInteractionCount(int count) {
if (count < 10000) return '$count';
return '${(count / 10000).toStringAsFixed(1)}万';
}
/// 播放量展示:以 w 为单位,整数不保留小数,非整数保留一位。
String formatPlayCount(int count) {
if (count < 10000) return '$count';
final value = count / 10000;
if (value == value.roundToDouble()) return '${value.toInt()}w';
return '${value.toStringAsFixed(1)}w';
}
+144 -43
View File
@@ -2,106 +2,207 @@
import 'package:flutter/material.dart';
/// K28-S 浅色暖橙主题色板。
/// 深色视觉色板。
///
/// 保留既有语义名,避免页面大范围改名;实际颜色统一由本文件控制。
abstract final class AppColors {
/// 主操作、选中态、播放进度。
static const blue = Color(0xFFFF7A1A);
/// 透明与媒体黑底。
static const transparent = Colors.transparent;
static const mediaBlack = Color(0xFF000000);
/// 阴影与遮罩层级。
static const shadowSubtle = Color(0x14000000);
static const shadowSoft = Color(0x33000000);
static const shadow = Color(0x8A000000);
static const shadowStrong = Color(0xE6000000);
static const scrim = Color(0xD1000000);
static const controlScrim = Color(0x80000000);
static const controlSoftScrim = Color(0x42000000);
static const timeBubbleScrim = Color(0xB3000000);
static const scrubPreviewScrim = Color(0xDE000000);
static const overlaySoft = Color(0x66000000);
static const overlayBar = Color(0x4D000000);
static const overlayMedium = Color(0x99000000);
static const headerScrim = Color(0x59000000);
static const overlaySecondary = Color(0xFFC7C7CC);
static const overlayLabel = Color(0xFFE6E6EB);
/// 主操作、选中态、播放进度(品牌橙)。
static const blue = Color(0xFFFF6B2C);
static const primary = blue;
static const actionRed = Color(0xFFFF3B30);
/// 主操作按下态。
static const bluePressed = Color(0xFFFF6A00);
static const bluePressed = Color(0xFFE55A1F);
/// 粉色辅助强调色。
static const blueSoft = Color(0xFFFF6A8A);
/// 链接与次要高亮:统一改用中性浅灰,避免旧青色残留。
static const blueSoft = Color(0xFFB0B0B0);
static const secondary = blueSoft;
static const lavender = blueSoft;
/// 金币与奖励。
static const gold = Color(0xFFF3B34C);
static const goldDeep = Color(0xFFD9A23A);
static const homeCrownGold = Color(0xFFF5C451);
static const homeGiftPink = Color(0xFFFF4D8D);
static const vipGoldStart = Color(0xFFF5D9A0);
static const vipGoldEnd = Color(0xFFE8B65A);
static const vipBadgeSurface = Color(0x1F3D2408);
static const bronze = Color(0xFFCD7F32);
static const bronzeDeep = Color(0xFFA9713A);
static const rankSilver = Color(0xFFC0C0C0);
/// 成功状态。
static const success = Color(0xFF2FA873);
/// 收藏和喜欢使用粉色强调。
static const favorite = secondary;
/// 错误与危险操作。
static const error = Color(0xFFFF6B6B);
/// 收藏与喜欢(非主色铺色,独立强调)。
static const favorite = Color(0xFFFF6A8A);
static const doubleTapHeartRed = Color(0xFFFF4D6D);
static const doubleTapHeartPink = Color(0xFFFF7A9A);
/// 金币底色上的文字。
static const onGold = Color(0xFF5A3511);
static const onGold = Color(0xFF3D2408);
/// 主色上的文字与图标。
static const onPrimary = Color(0xFFFFFFFF);
/// 页面背景。
static const ink = Color(0xFFFFF8F0);
static const ink = Color(0xFF000000);
/// 首页/追剧/我的统一的纯黑内容背景。
static const appBackground = Color(0xFF000000);
/// 中性深灰卡片与控件表面。
static const contentCard = Color(0xFF16161A);
static const contentSurface = Color(0xFF1C1C1E);
static const contentChip = Color(0xFF2C2C2E);
static const contentBorder = Color(0x1AFFFFFF);
static const filterChipSelected = Color(0xFF21090E);
static const filterChipTextSelected = Color(0xFFFF4D6A);
static const filterChipTextInactive = Color(0xFF8A8A8A);
/// 「我的」页卡片与描边。
static const mineCard = Color(0xFF16161A);
static const mineOutline = Color(0xFF4A4A4A);
static const mineGradientTop = Color(0xFF3A1B0E);
static const mineGradientMiddle = Color(0xFF1A0C07);
static const mineGradientBottom = Color(0xFF000000);
static const mineGradientGlow = Color(0x33FF6B2C);
static const mineGridSurface = Color(0x99101010);
static const minePanelSurface = Color(0xFF101010);
static const mineMoreDivider = Color(0xFF1C1C1E);
static const mineGridDivider = Color(0xFF2A2A2A);
static const mineGridIconSurface = Color(0xFF1C1C1E);
static const mineGridLabel = Color(0xFFE6E6EB);
static const mineIconWallet = Color(0xFFF5C451);
static const mineIconDownloads = Color(0xFF4DA3FF);
static const mineIconHistory = Color(0xFF4ADE80);
static const mineIconPreferences = Color(0xFFFF6B9A);
static const mineIconSupport = Color(0xFFA78BFA);
static const mineIconLanguage = Color(0xFF22D3EE);
static const mineIconSettings = Color(0xFFA1A1AA);
static const recommendPlaylistGlow = Color(0x26FF6B2C);
/// 次级文字与列表 chevron。
static const textPrimary = Color(0xFFFFFFFF);
static const textSecondary = Color(0xFFB0B0B0);
static const textTertiary = Color(0xFF8E8E93);
static const textTabInactive = Color(0xFFB0B0B0);
static const textFilterIcon = Color(0xFF7A7A7E);
/// 原生开屏与冷启动过渡底色。
static const splashInk = Color(0xFF1F1B16);
static const splashInk = ink;
/// 页面顶部暖色渐变起点。
static const surfaceTop = Color(0xFFFFE9D6);
/// 页面顶部暖黑色,渐变底部仍收口到纯黑。
static const surfaceTop = mineGradientTop;
/// 底部导航底色。
static const bar = Color(0xFFFFFDFB);
static const bar = ink;
/// 卡片轻阴影。
static const cardShadow = Color(0x14FF8A3D);
/// 底部导航专用:纯黑背景、选中白与未选中灰。
static const navBackground = Color(0xFF000000);
static const navSelected = Color(0xFFFFFFFF);
/// 卡片与弹层。
static const card = Color(0xFFFFFFFF);
/// 卡片阴影(深色几乎不用阴影)。
static const cardShadow = Color(0x00000000);
/// 卡片与列表容器。
static const card = Color(0xFF16161A);
/// 次要卡片表面。
static const cardAlt = Color(0xFFFFF4E9);
static const cardAlt = Color(0xFF101010);
/// 浮层、输入框和嵌套区域。
static const raised = Color(0xFFFFF1E5);
static const quick = Color(0xFFFFF0E1);
static const input = Color(0xFFFFF4EA);
/// 浮层、弹窗、输入与嵌套区域。
static const raised = Color(0xFF1C1C1E);
static const quick = raised;
static const input = raised;
/// 评论输入框底色。
static const commentInput = Color(0xFFF5EFE8);
static const goldCard = Color(0xFFFFF4DF);
static const commentInput = raised;
static const goldCard = Color(0xFF2A2418);
/// 正文颜色。
static const cream = Color(0xFF1F1B16);
/// 正文颜色(与主文字同级)。
static const cream = Color(0xEBFFFFFF);
/// 主文字。
static const onSurface = Color(0xFF1F1B16);
/// 主文字 rgba(255,255,255,0.92)。
static const onSurface = Color(0xEBFFFFFF);
/// 次要文字。
static const muted = Color(0xFF8C837B);
/// 次要文字 rgba(255,255,255,0.64)。
static const muted = Color(0xA3FFFFFF);
/// 弱提示与时间。
static const subtle = Color(0xFFB3AAA1);
/// 弱提示与禁用 rgba(255,255,255,0.38)。
static const subtle = Color(0x61FFFFFF);
/// 橙色强调文案。
static const accent = blue;
/// 卡片描边。
static const border = Color(0xFFF0E4D6);
/// 统一卡片描边、分割线与输入框边框。
static const border = Color(0xFF1C1C1E);
/// 玻璃面板描边。
static const glassBorder = Color(0x66FFFFFF);
static const glassBorder = Color(0x33FFFFFF);
static const glassSurfaceStart = Color(0xEB16161A);
static const glassSurfaceEnd = Color(0xD1101010);
/// 粉色角标。
static const badgePink = Color(0xFFFFE3EC);
static const badgePinkText = Color(0xFFFF6A8A);
/// 角标底与文字。
static const badgePink = Color(0x33FF6A8A);
static const badgePinkText = favorite;
/// 封面锁定数量与视频遮罩。
static const overlayDark = Color(0x33000000);
static const overlayDark = Color(0x66000000);
/// 封面集数胶囊。
static const badgeBeige = Color(0xFFFFF0DF);
static const badgeBeige = raised;
/// 导航未选中。
static const navUnselected = muted;
static const navUnselected = textSecondary;
/// 首页内容角标。
static const badgeHot = Color(0xFFE5484D);
static const badgeNew = Color(0xFF3B82F6);
static const badgeAnime = Color(0xFF8B5CF6);
/// 播放器次要文字。
static const playerMuted = Color(0xFFBBBBBB);
static const playerMuted = Color(0xA3FFFFFF);
/// 播放器控件胶囊与圆形底色。
static const playerOverlayPill = Color(0xB3000000);
static const refreshIndicatorSurface = Color(0x99000000);
static const cardBottomScrimStart = Color(0x00000000);
static const cardBottomScrimEnd = Color(0x40000000);
/// 进度条未激活轨道。
static const inactiveTrack = Color(0x24FFFFFF);
/// 横幅/封面底部渐变。
static const bannerScrimMid = Color(0x8A1A0C07);
static const bannerScrimEnd = Color(0xE6000000);
/// 票券虚线等主色描边。
static const primaryBorder = Color(0x66FF6B2C);
static const outlineDark = border;
static const outlineVariantDark = border;
+172 -16
View File
@@ -9,18 +9,20 @@ abstract final class AppDimens {
static const double spaceXl = 24;
static const double pageHorizontal = 16;
static const double cardRadius = 16;
static const double authCardRadius = 20;
static const double cardRadius = 12;
static const double authCardRadius = 12;
static const double cardPadding = 16;
static const double innerRadius = 12;
static const double innerRadius = 10;
static const double innerPadding = 12;
static const double pillRadius = 999;
static const double glassSmallRadius = 16;
static const double glassRadius = 20;
static const double glassCardRadius = 20;
static const double buttonRadius = 999;
static const double coverRadius = 16;
static const double sheetRadius = 20;
static const double glassSmallRadius = 12;
static const double glassRadius = 12;
static const double glassCardRadius = 12;
static const double buttonRadius = 10;
static const double inputRadius = 10;
static const double chipRadius = 6;
static const double coverRadius = 12;
static const double sheetRadius = 12;
static const double caption = 12;
static const double emptyStateSubtitle = 12.5;
@@ -34,7 +36,8 @@ abstract final class AppDimens {
static const double previewCardTitle = 13;
static const double previewSectionTitle = 14;
static const double chipFont = 10;
static const double navLabel = 12;
static const double navLabel = 13;
static const double navLabelGap = 5;
static const double buttonHeight = 48;
static const double primaryButtonHeight = 52;
@@ -45,13 +48,20 @@ abstract final class AppDimens {
static const double tapTarget = 44;
static const double contentHeaderHeight = 46;
static const double navHeight = 68;
static const double navIcon = 24;
static const double navHeight = 62;
static const double navIcon = 30;
static const double progressHeight = 3;
static const double progressRadius = 2;
static const double progressLineHeight = 3;
static const double refreshIndicatorSize = 40;
static const double refreshIndicatorRadius = 20;
static const double refreshSpinnerSize = 20;
static const double refreshSpinnerStroke = 2;
static const double loadMoreIndicatorHeight = 40;
static const double searchHeight = 44;
static const double searchHeight = 40;
static const double searchRadius = 8;
static const double searchPlaceholderFont = 13;
static const double navDot = 3;
static const double glassBlur = 24;
@@ -61,13 +71,159 @@ abstract final class AppDimens {
static const double watchPlaceholderHeight = 114;
static const double heroBannerHeight = 240;
static const double heroBannerRadius = 20;
static const double heroBannerRadius = 12;
static const double heroTitle = 28;
static const double heroSubtitle = 14;
static const double cardMeta = 12;
static const double cardViews = 11;
static const double cardTitle = 15;
static const double cardShadowBlur = 18;
static const double cardShadowY = 6;
static const double cardShadowBlur = 0;
static const double cardShadowY = 0;
/// 首页探索网格。
static const double feedPageHorizontal = 14;
static const double homeGridGap = 6;
static const double homeGridRowGap = 10;
static const double homeCardRadius = 6;
static const double cardCoverRadius = 8;
static const double homeCardPadding = 8;
static const double cardTitleFont = 15;
static const double cardTitleLineHeight = 1.3;
static const double cardViewsFont = 11;
static const double cardViewsIcon = 11;
static const double cardPlayCountFont = 12;
static const double cardPlayCountGap = 4;
static const double cardPlayCountBottom = 2;
static const double cardPlayCountRight = 6;
static const double cardPlayCountScrimHeight = 32;
static const double cardGenreRibbonHeight = 16;
static const double cardGenreRibbonLeftInset = 4;
static const double coverBadgeRibbonHorizontalPadding = 4;
static const double cardTagFont = 11;
static const double cardBadgeFont = 9;
static const double cardCoverTitleGap = 6;
static const double cardTitleTagGap = 6;
static const double cardCoverAspectRatio = 3 / 4;
static const double homeCardBodyExtent = 84;
static const double homePrimaryTabFont = 18;
static const double homePrimaryTabSelectedFont = 20;
static const double homePrimaryRowHeight = 30;
static const Duration homePrimaryTabAnimationDuration = Duration(
milliseconds: 180,
);
static const double homeSecondaryTagFont = 15;
static const double homeSecondaryTagHeight = 26;
static const double homeSecondaryTagHorizontalPadding = 8;
static const double homeSecondaryTagRadius = 13;
static const Duration homeSecondaryTagAnimationDuration = Duration(
milliseconds: 150,
);
static const double homeSecondaryFilterGap = 6;
static const double homeSecondaryFilterVisualSize = 26;
static const double homeSecondaryFilterIcon = 16;
static const double homeSecondaryFilterStroke = 1.7;
static const double homePrimaryCategoryGap = 24;
static const double homeSecondaryTagGap = 10;
static const double homeSearchTopGap = 0;
static const double homeSearchPrimaryGap = 8;
static const double homePrimarySecondaryGap = 8;
static const double homeSecondaryGridGap = 10;
static const double homeHeaderIcon = 22;
static const double homeActionSlot = 40;
static const double homeActionIcon = 30;
static const double homeActionGap = 6;
/// 推荐分区。
static const double recommendSectionTitle = 17;
static const double recommendSectionSubtitle = 11;
static const double recommendSectionMore = 12;
static const double recommendRowHeight = 252;
static const double recommendCardWidth = 122;
/// 首页热播榜。
static const double rankingHeroTitle = 22;
static const double rankingHeroSubtitle = 11;
static const double rankingPodiumChampionWidth = 110;
static const double rankingPodiumChampionHeight = 146;
static const double rankingPodiumWidth = 92;
static const double rankingPodiumHeight = 122;
static const double rankingPodiumRadius = 10;
static const double rankingRowHeight = 96;
static const double rankingNumberWidth = 56;
static const double rankingNumberFont = 48;
static const double rankingCoverWidth = 60;
static const double rankingCoverHeight = 80;
static const double rankingCoverRadius = 6;
/// 全部短剧筛选与追剧双列。
static const double filterChipFont = 16;
static const double filterChipHeight = 32;
static const double filterChipRadius = 16;
static const double filterChipHorizontalPadding = 12;
static const double filterRowGap = 6;
static const double filterItemGap = 10;
static const double allDramasHorizontal = 12;
static const double allDramasGridGap = 6;
static const double allDramasGridRowGap = 12;
static const double allDramasFilterToGridGap = 12;
static const double followingHeaderHeight = 52;
static const double followingTabFont = 18;
static const double followingTabGap = 24;
static const double followingEditIconSize = 20;
static const double followingEditHitSize = 40;
static const double followingAddIconSize = 28;
static const double followingAddIconStroke = 1.5;
static const double followingGridGap = 6;
static const double followingGridRowGap = 12;
static const double followingCardTextExtent = 66;
static const double followingSubtitleFont = 12;
static const double followingSubtitleGap = 4;
static const double followingProgressHeight = 2;
static const double followingHistoryCoverWidth = 96;
static const double followingHistoryCoverHeight = 128;
static const double followingHistoryCoverRadius = 6;
static const double followingHistoryContentGap = 12;
static const double followingHistoryRowGap = 16;
static const double followingHistoryProgressHeight = 3;
static const double followingHistoryProgressRadius = 2;
static const double followingHistoryProgressGap = 8;
static const double followingHistoryLabelGap = 4;
static const double followingHistoryActionHeight = 28;
static const Duration followingTabAnimationDuration = Duration(
milliseconds: 180,
);
static const Duration followingPageAnimationDuration = Duration(
milliseconds: 220,
);
/// 「我的」页中性视觉。
static const double mineCardRadius = 16;
static const double mineSectionTitle = 15;
static const double mineAvatar = 56;
static const double mineUserName = 20;
static const double mineUserNameLineHeight = 1.15;
static const double mineUserNameUidGap = 2;
static const double mineUid = 12;
static const double mineUidRowHeight = 16;
static const double mineCopyIcon = 14;
static const double mineGridHorizontal = 14;
static const double mineGridRadius = 12;
static const double mineGridCellHeight = 96;
static const double mineGridIconSurface = 44;
static const double mineGridIcon = 22;
static const double mineGridLabel = 12;
static const double mineGridDividerWidth = 1;
static const double mineSectionTitleFont = 16;
static const double mineSectionLinkFont = 12;
static const double mineSectionGap = 20;
static const double mineContinueCardWidth = 96;
static const double mineContinueRowHeight = 168;
static const double mineContinueTitleFont = 13;
static const double mineContinueMetaFont = 11;
static const double mineStatsValueFont = 18;
static const double mineStatsLabelFont = 11;
static const double minePanelPadding = 12;
static const double mineMoreRowHeight = 52;
static const double mineMoreFont = 15;
}
+151 -31
View File
@@ -6,46 +6,64 @@ import 'package:flutter/services.dart';
import 'app_colors.dart';
import 'app_dimens.dart';
/// K28-S 浅色暖橙主题。
/// 深色视觉主题(默认且唯一)。
abstract final class AppTheme {
/// 页面顶部暖色到米白背景的渐变。
/// 页面背景:顶部暖黑、底部纯黑,避免旧深蓝渐变。
static const LinearGradient pageGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.surfaceTop, AppColors.ink],
stops: [0, 0.24],
colors: [
AppColors.mineGradientTop,
AppColors.mineGradientMiddle,
AppColors.mineGradientBottom,
],
stops: [0, 0.4, 0.75],
);
/// 卡片轻阴影。
/// 卡片阴影关闭,改用底色与分割线。
static const BoxShadow cardShadow = BoxShadow(
color: AppColors.cardShadow,
blurRadius: AppDimens.cardShadowBlur,
offset: Offset(0, AppDimens.cardShadowY),
);
/// 全局浅色暖橙主题。
static ThemeData light() {
const scheme = ColorScheme.light(
static final BorderRadius _buttonRadius = BorderRadius.circular(
AppDimens.buttonRadius,
);
static final BorderRadius _cardRadius = BorderRadius.circular(
AppDimens.cardRadius,
);
static final BorderRadius _inputRadius = BorderRadius.circular(
AppDimens.inputRadius,
);
/// 全局深色主题。
static ThemeData dark() {
const scheme = ColorScheme.dark(
primary: AppColors.blue,
onPrimary: AppColors.onPrimary,
secondary: AppColors.secondary,
onSecondary: AppColors.onPrimary,
primaryContainer: AppColors.surfaceTop,
onSecondary: AppColors.ink,
primaryContainer: AppColors.raised,
onPrimaryContainer: AppColors.onSurface,
secondaryContainer: AppColors.badgePink,
onSecondaryContainer: AppColors.badgePinkText,
surface: AppColors.ink,
onSurface: AppColors.onSurface,
surfaceContainerHighest: AppColors.raised,
outline: AppColors.border,
outlineVariant: AppColors.border,
surfaceTint: AppColors.blue,
surfaceTint: AppColors.transparent,
inversePrimary: AppColors.bluePressed,
error: AppColors.error,
onError: AppColors.onPrimary,
);
final base = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
brightness: Brightness.dark,
colorScheme: scheme,
scaffoldBackgroundColor: AppColors.ink,
canvasColor: AppColors.ink,
fontFamily: 'PlusJakartaSans',
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.blue,
@@ -61,7 +79,23 @@ abstract final class AppTheme {
unselectedLabelColor: AppColors.muted,
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppColors.blue),
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
shape: RoundedRectangleBorder(borderRadius: _buttonRadius),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(_buttonColor),
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
elevation: const WidgetStatePropertyAll(0),
minimumSize: const WidgetStatePropertyAll(
Size(0, AppDimens.buttonHeight),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: _buttonRadius),
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
@@ -70,47 +104,109 @@ abstract final class AppTheme {
minimumSize: const WidgetStatePropertyAll(
Size(0, AppDimens.buttonHeight),
),
shape: const WidgetStatePropertyAll(StadiumBorder()),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: _buttonRadius),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
foregroundColor: const WidgetStatePropertyAll(AppColors.blue),
foregroundColor: const WidgetStatePropertyAll(AppColors.textPrimary),
minimumSize: const WidgetStatePropertyAll(
Size(0, AppDimens.buttonHeight),
),
side: const WidgetStatePropertyAll(
BorderSide(color: AppColors.border),
BorderSide(color: AppColors.mineOutline),
),
shape: const WidgetStatePropertyAll(StadiumBorder()),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: _buttonRadius),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.input,
hintStyle: const TextStyle(color: AppColors.subtle),
labelStyle: const TextStyle(color: AppColors.muted),
border: OutlineInputBorder(
borderRadius: _inputRadius,
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: _inputRadius,
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: _inputRadius,
borderSide: const BorderSide(color: AppColors.blue),
),
),
chipTheme: ChipThemeData(
backgroundColor: AppColors.raised,
selectedColor: AppColors.blue.withValues(alpha: 0.22),
labelStyle: const TextStyle(color: AppColors.onSurface),
secondaryLabelStyle: const TextStyle(color: AppColors.muted),
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDimens.chipRadius),
),
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onSurface,
backgroundColor: AppColors.transparent,
foregroundColor: AppColors.textPrimary,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.dark,
scrolledUnderElevation: 0,
surfaceTintColor: AppColors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.light,
titleTextStyle: TextStyle(
color: AppColors.onSurface,
color: AppColors.textPrimary,
fontFamily: 'Outfit',
fontSize: AppDimens.pageTitle,
fontWeight: FontWeight.w800,
),
),
iconTheme: const IconThemeData(color: AppColors.onSurface),
dividerTheme: const DividerThemeData(color: AppColors.border),
cardTheme: const CardThemeData(
iconTheme: const IconThemeData(color: AppColors.textPrimary),
dividerTheme: const DividerThemeData(
color: AppColors.border,
thickness: 1,
space: 1,
),
cardTheme: CardThemeData(
color: AppColors.card,
elevation: 0,
shadowColor: AppColors.transparent,
surfaceTintColor: AppColors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppDimens.cardRadius)),
side: BorderSide(color: AppColors.border),
borderRadius: _cardRadius,
side: const BorderSide(color: AppColors.border),
),
),
dialogTheme: DialogThemeData(
backgroundColor: AppColors.raised,
surfaceTintColor: AppColors.transparent,
shape: RoundedRectangleBorder(borderRadius: _cardRadius),
titleTextStyle: const TextStyle(
color: AppColors.onSurface,
fontFamily: 'Outfit',
fontSize: AppDimens.sectionTitle,
fontWeight: FontWeight.w700,
),
contentTextStyle: const TextStyle(
color: AppColors.muted,
fontSize: AppDimens.body,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.navBackground,
selectedItemColor: AppColors.navSelected,
unselectedItemColor: AppColors.navUnselected,
elevation: 0,
type: BottomNavigationBarType.fixed,
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Colors.transparent,
modalBackgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
backgroundColor: AppColors.transparent,
modalBackgroundColor: AppColors.transparent,
surfaceTintColor: AppColors.transparent,
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
@@ -124,8 +220,13 @@ abstract final class AppTheme {
),
),
snackBarTheme: const SnackBarThemeData(
backgroundColor: AppColors.onSurface,
contentTextStyle: TextStyle(color: AppColors.onPrimary),
backgroundColor: AppColors.raised,
contentTextStyle: TextStyle(color: AppColors.onSurface),
actionTextColor: AppColors.secondary,
),
listTileTheme: const ListTileThemeData(
iconColor: AppColors.onSurface,
textColor: AppColors.onSurface,
),
);
final textTheme = base.textTheme
@@ -142,10 +243,29 @@ abstract final class AppTheme {
headlineMedium: _display(base.textTheme.headlineMedium),
headlineSmall: _display(base.textTheme.headlineSmall),
titleLarge: _display(base.textTheme.titleLarge),
bodyLarge: base.textTheme.bodyLarge?.copyWith(
color: AppColors.onSurface,
),
bodyMedium: base.textTheme.bodyMedium?.copyWith(
color: AppColors.onSurface,
),
bodySmall: base.textTheme.bodySmall?.copyWith(color: AppColors.muted),
labelLarge: base.textTheme.labelLarge?.copyWith(
color: AppColors.onSurface,
),
labelMedium: base.textTheme.labelMedium?.copyWith(
color: AppColors.muted,
),
labelSmall: base.textTheme.labelSmall?.copyWith(
color: AppColors.subtle,
),
);
return base.copyWith(textTheme: textTheme);
}
/// 兼容旧调用点,统一落到深色主题。
static ThemeData light() => dark();
static TextStyle? _display(TextStyle? style) =>
style?.copyWith(fontFamily: 'Outfit', color: AppColors.onSurface);
+71 -66
View File
@@ -1,7 +1,5 @@
// Author: fruit.com.cn
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
@@ -16,63 +14,46 @@ class AppBottomNavItem {
required this.label,
required this.onTap,
this.selected = false,
this.assetPath,
this.selectedAssetPath,
});
final Key tabKey;
final IconData icon;
final IconData selectedIcon;
final String? assetPath;
final String? selectedAssetPath;
final String label;
final VoidCallback onTap;
final bool selected;
}
/// 四槽底部导航:首页 feed 用深色样式,其它页面保持浅色暖橙风格。
/// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。
class AppBottomNav extends StatelessWidget {
const AppBottomNav({
super.key,
required this.items,
this.backgroundColor = AppColors.bar,
this.barKey,
this.dark = false,
});
const AppBottomNav({super.key, required this.items, this.barKey});
final List<AppBottomNavItem> items;
final Color backgroundColor;
final Key? barKey;
final bool dark;
@override
Widget build(BuildContext context) {
final bar = Container(
key: barKey,
height: AppDimens.navHeight,
decoration: BoxDecoration(
color: dark ? Colors.black : backgroundColor.withValues(alpha: 0.95),
border: Border(
top: BorderSide(color: dark ? Colors.white12 : AppColors.border),
),
),
color: AppColors.navBackground,
child: Row(
children: [
for (final item in items)
_NavDestination(
item: item,
unselectedColor: dark ? Colors.white70 : AppColors.navUnselected,
unselectedColor: AppColors.navUnselected,
),
],
),
);
if (dark) {
return ColoredBox(
color: Colors.black,
child: SafeArea(top: false, child: bar),
);
}
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: SafeArea(top: false, child: bar),
),
return ColoredBox(
color: AppColors.navBackground,
child: SafeArea(top: false, child: bar),
);
}
}
@@ -84,45 +65,69 @@ class _NavDestination extends StatelessWidget {
final Color unselectedColor;
@override
Widget build(BuildContext context) => Expanded(
child: Semantics(
button: true,
selected: item.selected,
label: item.label,
child: InkWell(
key: item.tabKey,
onTap: item.onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppDimens.navHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedScale(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
scale: item.selected ? 1 : 0.96,
child: Icon(
item.selected ? item.selectedIcon : item.icon,
size: AppDimens.navIcon,
color: item.selected ? AppColors.blue : unselectedColor,
Widget build(BuildContext context) {
final color = item.selected ? AppColors.navSelected : unselectedColor;
final fallbackIcon = item.selected ? item.selectedIcon : item.icon;
final assetPath = item.selected
? item.selectedAssetPath ?? item.assetPath
: item.assetPath;
return Expanded(
child: Semantics(
button: true,
selected: item.selected,
label: item.label,
child: InkWell(
key: item.tabKey,
onTap: item.onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppDimens.navHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedScale(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
scale: item.selected ? 1 : 0.96,
child: assetPath == null
? Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
)
: ColorFiltered(
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
child: Image.asset(
assetPath,
width: AppDimens.navIcon,
height: AppDimens.navIcon,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => Icon(
fallbackIcon,
size: AppDimens.navIcon,
color: color,
),
),
),
),
),
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDimens.navLabel,
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
color: item.selected ? AppColors.blue : unselectedColor,
letterSpacing: 0,
const SizedBox(height: AppDimens.navLabelGap),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDimens.navLabel,
fontWeight: item.selected
? FontWeight.w600
: FontWeight.w400,
color: color,
letterSpacing: 0,
),
),
),
],
],
),
),
),
),
),
);
);
}
}
+44 -56
View File
@@ -4,19 +4,18 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
const double _maxPullDistance = 96;
const double _refreshThreshold = 56;
const double _indicatorHeight = 56;
const double _indicatorMaxOffset = 10;
const Duration _arrowTurnDuration = Duration(milliseconds: 160);
const double _indicatorDisplacement = 40;
const Duration _settleDuration = Duration(milliseconds: 200);
enum _PullState { idle, pull, armed, refreshing }
/// 带文字状态提示的下拉刷新容器,指示区悬浮在内容上方。
/// 下拉刷新容器,指示区只显示统一的白色加载圈。
class AppPullToRefresh extends StatefulWidget {
const AppPullToRefresh({
super.key,
@@ -26,7 +25,7 @@ class AppPullToRefresh extends StatefulWidget {
});
static const AlwaysScrollableScrollPhysics physics =
AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics());
AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics());
final Widget child;
final Future<void> Function() onRefresh;
@@ -146,10 +145,13 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
onNotification: _onScroll,
child: AnimatedBuilder(
animation: _offsetController,
child: Listener(
onPointerUp: (_) => _release(),
onPointerCancel: (_) => _release(),
child: widget.child,
child: ScrollConfiguration(
behavior: const _NoScrollbarScrollBehavior(),
child: Listener(
onPointerUp: (_) => _release(),
onPointerCancel: (_) => _release(),
child: widget.child,
),
),
builder: (context, child) {
final offset = _offsetController.value;
@@ -160,7 +162,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
key: const Key('pull-refresh-content'),
offset: Offset(0, offset),
child: IgnorePointer(
ignoring: _refreshing || _retracting,
ignoring: offset > 0 || _refreshing || _retracting,
child: child,
),
),
@@ -169,7 +171,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
top: widget.edgeOffset,
left: 0,
right: 0,
child: IgnorePointer(child: _indicatorLayer(context, offset)),
child: IgnorePointer(child: _indicatorLayer(offset)),
),
],
);
@@ -178,19 +180,19 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
);
}
Widget _indicatorLayer(BuildContext context, double offset) {
Widget _indicatorLayer(double offset) {
final progress = (offset / _refreshThreshold).clamp(0.0, 1.0);
return SizedBox(
height: _indicatorHeight,
child: Opacity(
opacity: progress,
child: Transform.translate(
offset: Offset(0, _indicatorMaxOffset * progress),
offset: Offset(0, _indicatorDisplacement * progress),
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _indicator(context),
child: _indicator(),
),
),
),
@@ -198,50 +200,36 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
);
}
Widget _indicator(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (_state == _PullState.refreshing) {
return _label(
const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.blue,
),
),
l10n.refreshingLabel,
);
}
final armed = _state == _PullState.armed;
return _label(
AnimatedRotation(
turns: armed ? 0.5 : 0,
duration: _arrowTurnDuration,
child: Icon(
Icons.arrow_downward_rounded,
size: 18,
color: AppColors.blue,
Widget _indicator() {
return Container(
key: const Key('pull-refresh-indicator'),
width: AppDimens.refreshIndicatorSize,
height: AppDimens.refreshIndicatorSize,
padding: const EdgeInsets.all(
(AppDimens.refreshIndicatorSize - AppDimens.refreshSpinnerSize) / 2,
),
decoration: BoxDecoration(
color: AppColors.refreshIndicatorSurface,
borderRadius: BorderRadius.circular(AppDimens.refreshIndicatorRadius),
),
child: const SizedBox.square(
dimension: AppDimens.refreshSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: AppDimens.refreshSpinnerStroke,
color: AppColors.textPrimary,
),
),
armed ? l10n.releaseToRefresh : l10n.pullToRefresh,
);
}
Widget _label(Widget icon, String text) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
color: AppColors.blue,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
class _NoScrollbarScrollBehavior extends MaterialScrollBehavior {
const _NoScrollbarScrollBehavior();
@override
Widget buildScrollbar(
BuildContext context,
Widget child,
ScrollableDetails details,
) => child;
}
+87
View File
@@ -0,0 +1,87 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import 'app_bottom_nav.dart';
/// 客户端根导航的固定顺序。
enum AppRootTab {
home(
assetPath: 'assets/icon/film_reel.png',
selectedAssetPath: 'assets/icon/film_reel_filled.png',
),
recommend(),
following(),
mine();
const AppRootTab({this.assetPath, this.selectedAssetPath});
final String? assetPath;
final String? selectedAssetPath;
}
extension AppRootTabUi on AppRootTab {
Key get tabKey => switch (this) {
AppRootTab.home => const Key('tab-home'),
AppRootTab.recommend => const Key('tab-recommend'),
AppRootTab.following => const Key('tab-follow'),
AppRootTab.mine => const Key('tab-mine'),
};
IconData get icon => switch (this) {
AppRootTab.home => Icons.theaters_outlined,
AppRootTab.recommend => Icons.smart_display_outlined,
AppRootTab.following => Icons.bookmark_border_rounded,
AppRootTab.mine => Icons.person_outline_rounded,
};
IconData get selectedIcon => switch (this) {
AppRootTab.home => Icons.theaters,
AppRootTab.recommend => Icons.smart_display_rounded,
AppRootTab.following => Icons.bookmark_rounded,
AppRootTab.mine => Icons.person_rounded,
};
String label(AppLocalizations l10n) => switch (this) {
AppRootTab.home => l10n.homeTab,
AppRootTab.recommend => l10n.recommendedTab,
AppRootTab.following => l10n.followingTab,
AppRootTab.mine => l10n.myTab,
};
}
/// 根导航唯一入口,确保四个主页面使用完全一致的底栏配置。
class AppRootBottomNav extends StatelessWidget {
const AppRootBottomNav({
super.key,
required this.selected,
required this.onSelected,
this.barKey,
});
final AppRootTab selected;
final ValueChanged<AppRootTab> onSelected;
final Key? barKey;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AppBottomNav(
barKey: barKey,
items: [
for (final tab in AppRootTab.values)
AppBottomNavItem(
tabKey: tab.tabKey,
icon: tab.icon,
selectedIcon: tab.selectedIcon,
assetPath: tab.assetPath,
selectedAssetPath: tab.selectedAssetPath,
label: tab.label(l10n),
selected: tab == selected,
onTap: () => onSelected(tab),
),
],
);
}
}
+2 -2
View File
@@ -37,7 +37,7 @@ Future<bool> ensureSignedIn(BuildContext context) async {
Text(
l10n.authGateTitle,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w800,
),
@@ -98,7 +98,7 @@ class AuthGatePanel extends StatelessWidget {
l10n.authGateTitle,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w800,
),
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../config/app_config.dart';
import '../config/server_endpoint.dart';
import '../theme/app_colors.dart';
const String _title = '设置服务地址';
const String _save = '保存';
@@ -94,7 +95,7 @@ class _CatalogEndpointDialogState extends State<_CatalogEndpointDialog> {
const Text(
_localNetworkHint,
key: Key('catalog-debug-endpoint-hint'),
style: TextStyle(fontSize: 12, color: Colors.grey),
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
if (_error != null) ...[
const SizedBox(height: 8),
+5 -5
View File
@@ -4,6 +4,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
const Key _toastKey = Key('centered-toast');
const Duration _fadeDuration = Duration(milliseconds: 200);
const Duration _visibleDuration = Duration(seconds: 2);
@@ -11,9 +13,8 @@ const double _toastRadius = 12;
const double _toastMaxWidth = 320;
const double _toastHorizontalInset = 20;
const double _toastVerticalInset = 12;
const double _toastBackgroundOpacity = 0.78;
const TextStyle _toastTextStyle = TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w500,
@@ -108,10 +109,9 @@ class _CenteredToastState extends State<_CenteredToast>
constraints: const BoxConstraints(maxWidth: _toastMaxWidth),
padding: _toastPadding,
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: _toastBackgroundOpacity,
),
color: AppColors.raised,
borderRadius: BorderRadius.circular(_toastRadius),
border: Border.all(color: AppColors.border),
),
child: Text(
widget.message,
+60
View File
@@ -0,0 +1,60 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../formatting/count_format.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
class CoverPlayCount extends StatelessWidget {
const CoverPlayCount({super.key, required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.local_fire_department_rounded,
color: AppColors.textPrimary,
size: AppDimens.cardViewsIcon,
),
const SizedBox(width: AppDimens.cardPlayCountGap),
Text(
formatPlayCount(count),
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.cardPlayCountFont,
fontWeight: FontWeight.w400,
),
),
],
);
}
}
class CoverBottomScrim extends StatelessWidget {
const CoverBottomScrim({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: AppDimens.cardPlayCountScrimHeight,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.cardBottomScrimStart,
AppColors.cardBottomScrimEnd,
],
),
),
),
);
}
}
@@ -0,0 +1,66 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
/// 封面右上角的斜边状态角标。
class DramaCoverBadgeRibbon extends StatelessWidget {
const DramaCoverBadgeRibbon({
super.key,
required this.label,
required this.color,
required this.keyPrefix,
});
final String label;
final Color color;
final String keyPrefix;
@override
Widget build(BuildContext context) {
return ClipPath(
key: Key('drama-cover-badge-ribbon-$keyPrefix'),
clipper: const _SlantedRibbonClipper(),
child: Container(
height: AppDimens.cardGenreRibbonHeight,
padding: const EdgeInsets.only(
left:
AppDimens.coverBadgeRibbonHorizontalPadding +
AppDimens.cardGenreRibbonLeftInset,
right: AppDimens.coverBadgeRibbonHorizontalPadding,
),
alignment: Alignment.center,
color: color,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.cardBadgeFont,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _SlantedRibbonClipper extends CustomClipper<Path> {
const _SlantedRibbonClipper();
@override
Path getClip(Size size) {
return Path()
..moveTo(AppDimens.cardGenreRibbonLeftInset, 0)
..lineTo(size.width, 0)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
}
@override
bool shouldReclip(_SlantedRibbonClipper oldClipper) => false;
}
+1 -2
View File
@@ -87,12 +87,11 @@ class _EmptyMark extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
return Icon(
icon,
key: const Key('empty-state-icon'),
size: compact ? _compactIconSize : _regularIconSize,
color: dark ? AppColors.gold : AppColors.blue,
color: AppColors.blueSoft,
);
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
/// 首页信息卡与浮层控件使用的浅色玻璃面板。
/// 首页信息卡与浮层控件使用的深色玻璃面板。
class GlassPanel extends StatelessWidget {
const GlassPanel({
super.key,
@@ -38,7 +38,7 @@ class GlassPanel extends StatelessWidget {
const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xE6FFFFFF), Color(0xB3FFFDFB)],
colors: [AppColors.glassSurfaceStart, AppColors.glassSurfaceEnd],
),
),
child: Padding(padding: padding ?? EdgeInsets.zero, child: child),
+7 -30
View File
@@ -5,22 +5,19 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
import '../../l10n/app_localizations.dart';
/// 网络分页列表底部状态:加载、重试、手动加载与无更多。
/// 网络分页列表底部状态:仅保留加载圈与错误重试。
class PagedListFooter extends StatelessWidget {
const PagedListFooter({
super.key,
required this.loading,
required this.hasMore,
required this.hasItems,
required this.onRetry,
this.errorMessage,
});
final bool loading;
final bool hasMore;
final bool hasItems;
final String? errorMessage;
final Future<void> Function() onRetry;
@@ -49,38 +46,18 @@ class PagedListFooter extends StatelessWidget {
if (loading) {
return const SizedBox(
key: Key('paged-list-loading'),
height: 56,
height: AppDimens.loadMoreIndicatorHeight,
child: Center(
child: SizedBox.square(
dimension: 20,
dimension: AppDimens.refreshSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.blue,
strokeWidth: AppDimens.refreshSpinnerStroke,
color: AppColors.textPrimary,
),
),
),
);
}
if (hasMore) {
return Center(
child: TextButton(
key: const Key('paged-list-load-more'),
onPressed: () => unawaited(onRetry()),
child: Text(l10n.loadMore),
),
);
}
if (hasItems) {
return Padding(
key: const Key('paged-list-no-more'),
padding: const EdgeInsets.symmetric(vertical: 14),
child: Text(
l10n.noMore,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
);
}
return const SizedBox(height: 16);
return const SizedBox.shrink();
}
}
+7 -25
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
const int _defaultPageSize = 20;
const Duration _defaultLoadDelay = Duration(milliseconds: 300);
@@ -64,53 +65,34 @@ class PagedLocalListController<T> extends ChangeNotifier {
}
}
/// 分页列表底部加载态与到底提示。
/// 分页列表底部加载态。
class PagedLoadFooter extends StatelessWidget {
const PagedLoadFooter({
super.key,
required this.loading,
required this.hasMore,
required this.hasItems,
required this.loadMoreKey,
required this.noMoreKey,
required this.noMoreLabel,
});
final bool loading;
final bool hasMore;
final bool hasItems;
final Key loadMoreKey;
final Key noMoreKey;
final String noMoreLabel;
@override
Widget build(BuildContext context) {
if (loading) {
return SizedBox(
key: loadMoreKey,
height: 56,
height: AppDimens.loadMoreIndicatorHeight,
child: const Center(
child: SizedBox.square(
dimension: 20,
dimension: AppDimens.refreshSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.blue,
strokeWidth: AppDimens.refreshSpinnerStroke,
color: AppColors.textPrimary,
),
),
),
);
}
if (hasItems && !hasMore) {
return Padding(
key: noMoreKey,
padding: const EdgeInsets.symmetric(vertical: 14),
child: Text(
noMoreLabel,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
);
}
return const SizedBox(height: 16);
return const SizedBox.shrink();
}
}
+15 -11
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
import 'subpage_header.dart';
/// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。
@@ -27,17 +28,20 @@ class PlainPageScaffold extends StatelessWidget {
return Scaffold(
key: pageKey,
backgroundColor: AppColors.ink,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSubpageHeader(
title: title,
subtitle: subtitle,
backKey: backKey,
),
Expanded(child: child),
],
body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppTheme.pageGradient),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSubpageHeader(
title: title,
subtitle: subtitle,
backKey: backKey,
),
Expanded(child: child),
],
),
),
),
);
+1 -1
View File
@@ -16,7 +16,7 @@ class RemoteImage extends StatelessWidget {
this.showFallbackIcon = false,
this.fallbackIconKey,
this.fallbackAspectRatio,
this.fallbackColor = AppColors.bar,
this.fallbackColor = AppColors.raised,
});
final String path;
+24 -27
View File
@@ -14,12 +14,7 @@ const double _subtitleFontSize = 12;
const double _actionGap = 8;
const double _iconSize = 22;
const double _iconWeight = 600;
const double _borderWidth = 1;
const double _shadowBlur = 8;
const Color _lightShadowColor = Color(0x14000000);
const Color _darkBackground = Color(0x59000000);
const Color _darkShadowColor = Color(0x33000000);
const Offset _lightShadowOffset = Offset(0, 2);
/// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。
class AppSubpageCircleButton extends StatelessWidget {
@@ -50,24 +45,18 @@ class AppSubpageCircleButton extends StatelessWidget {
key: const Key('subpage-back-shadow'),
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: dark ? _darkShadowColor : _lightShadowColor,
blurRadius: _shadowBlur,
offset: dark ? Offset.zero : _lightShadowOffset,
),
],
boxShadow: dark
? const [
BoxShadow(
color: AppColors.shadowSoft,
blurRadius: _shadowBlur,
),
]
: const [],
),
child: Material(
color: dark ? _darkBackground : AppColors.card,
shape: CircleBorder(
side: dark
? BorderSide.none
: const BorderSide(
color: AppColors.border,
width: _borderWidth,
),
),
color: dark ? AppColors.headerScrim : AppColors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
@@ -75,7 +64,7 @@ class AppSubpageCircleButton extends StatelessWidget {
child: Center(
child: Icon(
icon,
color: dark ? Colors.white : AppColors.onSurface,
color: dark ? AppColors.onSurface : AppColors.textPrimary,
size: _iconSize,
weight: _iconWeight,
),
@@ -88,7 +77,7 @@ class AppSubpageCircleButton extends StatelessWidget {
}
}
/// 二级页面统一返回按钮,浅色页使用白底描边,深色页使用半透明黑底。
/// 二级页面统一返回按钮,普通页透明,沉浸页使用半透明黑底。
class AppSubpageBackButton extends StatelessWidget {
const AppSubpageBackButton({
super.key,
@@ -123,6 +112,7 @@ class AppSubpageHeader extends StatelessWidget {
this.actions,
this.onBack,
this.dark = false,
this.showBack = true,
});
final String title;
@@ -131,11 +121,12 @@ class AppSubpageHeader extends StatelessWidget {
final Widget? actions;
final VoidCallback? onBack;
final bool dark;
final bool showBack;
@override
Widget build(BuildContext context) {
final foreground = dark ? Colors.white : AppColors.onSurface;
final secondary = dark ? Colors.white70 : AppColors.muted;
final foreground = AppColors.textPrimary;
final secondary = AppColors.textSecondary;
return SizedBox(
key: const Key('subpage-header'),
height: subtitle == null ? _height : _heightWithSubtitle,
@@ -143,8 +134,14 @@ class AppSubpageHeader extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: _horizontal),
child: Row(
children: [
AppSubpageBackButton(backKey: backKey, dark: dark, onBack: onBack),
const SizedBox(width: _backGap),
if (showBack) ...[
AppSubpageBackButton(
backKey: backKey,
dark: dark,
onBack: onBack,
),
const SizedBox(width: _backGap),
],
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
+70 -10
View File
@@ -64,7 +64,7 @@
"count": {"type": "String"}
}
},
"topCharts": "Top Charts",
"topCharts": "Hot Ranking",
"newReleases": "New Releases",
"newDramas": "New",
"trendingMicroDramas": "Trending Micro-Dramas",
@@ -115,7 +115,6 @@
"emptyHint": "New content will appear here.",
"demoCatalogNotice": "Three demo dramas share the same local video.",
"myTab": "My",
"localViewer": "Local Viewer",
"localDemoProfile": "Local demo profile",
"continueWatching": "Continue Watching",
"episodeRemaining": "Episode {episode} · {minutes} min left",
@@ -153,7 +152,6 @@
},
"resume": "Resume",
"history": "History",
"watchlist": "Favorites",
"likedTab": "Liked",
"all": "All",
"inProgress": "In Progress",
@@ -162,7 +160,7 @@
"preferences": "Preferences",
"followingTab": "Following",
"animatedTab": "Animated",
"recommendedTab": "Recommended",
"recommendedTab": "Recommend",
"noCategoryDramas": "No dramas in this category yet.",
"allDramas": "All Dramas",
"allLanguages": "All languages",
@@ -346,10 +344,6 @@
}
},
"retry": "Retry",
"pullToRefresh": "Pull to refresh",
"releaseToRefresh": "Release to refresh",
"refreshingLabel": "Refreshing…",
"noMore": "No more",
"backToSeries": "Back to series",
"nextEpisode": "Next episode",
"pause": "Pause",
@@ -639,7 +633,7 @@
"ledgerAdjustment": "Adjustment",
"rankingTab": "Charts",
"rankingTitle": "Top Charts",
"rankingSubtitle": "Ranked from today's live catalog feed.",
"rankingSubtitle": "Ranked live by play heat",
"rankingHot": "Trending",
"rankingNew": "New releases",
"newReleasesAll": "All new releases",
@@ -697,5 +691,71 @@
"danmakuSettings": "Danmaku settings",
"offlineCache": "Offline cache",
"screenCast": "Cast",
"sleepTimer": "Sleep timer"
"sleepTimer": "Sleep timer",
"homeSearchHint": "Search dramas, actors, genres...",
"homeSectionFeatured": "Featured",
"homeSectionNew": "New",
"homeSectionCategories": "Categories",
"homeSectionRanking": "Charts",
"homeSectionCostume": "Costume",
"homeSectionCommunity": "Community",
"homeTagHot": "Hot",
"homeTagUplifting": "Thrilling",
"homeTagSweet": "Sweet",
"homeTagFaceSlap": "Revenge",
"homeTagAngst": "Angst",
"homeBadgeHot": "Hot",
"homeBadgeNew": "New",
"homeBadgeAnime": "Anime",
"homeGridFilterEmpty": "No dramas match these filters",
"homeGridClearFilter": "Clear filters",
"recommendTopTab": "Recommend",
"playlistTopTab": "Playlists",
"allDramasTopTab": "All Dramas",
"weeklyHitsTitle": "This Week's Hits",
"weeklyHitsSubtitle": "Binge the hottest stories of the week!",
"weeklyNewTitle": "New This Week",
"weeklyNewSubtitle": "Fresh releases to start your next binge",
"costumeAwardsTitle": "Costume Drama Awards",
"costumeAwardsSubtitle": "Top-rated picks every costume fan needs",
"localFilter": "Local",
"asiaFilter": "Asia",
"westernFilter": "Western",
"subtitlesOnlyFilter": "Subtitles",
"dubbedFilter": "Dubbed",
"reversalFilter": "Comeback",
"modernFilter": "Modern",
"edit": "Edit",
"done": "Done",
"visitor": "Visitor",
"copyUid": "Copy UID",
"uidCopied": "UID copied",
"offlineDownloads": "Offline downloads",
"watchPreferences": "Watch preferences",
"contactSupport": "Contact support",
"vipBrandName": "Netshort VIP",
"mineVipSubtitle": "Enjoy exclusive benefits",
"favoriteEpisodeProgress": "Episode {episode} / {total} total",
"@favoriteEpisodeProgress": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"watchedToEpisode": "Watched to Episode {episode}",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "Watch all",
"confirmRemove": "Remove this item?",
"followingCountLabel": "Following",
"watchRecordsCountLabel": "Watch records",
"dataUnavailable": "—",
"commonQuestions": "Common questions",
"aboutUs": "About us",
"aboutDescription": "A focused, immersive place to watch short dramas.",
"versionLabel": "Version {version}",
"@versionLabel": {
"placeholders": {
"version": { "type": "String" }
}
}
}
+70 -10
View File
@@ -48,7 +48,7 @@
"authorNoteLabel": "Catatan penulis",
"reserve": "Reservasi",
"heatLabel": "{count} panas",
"topCharts": "Peringkat Teratas",
"topCharts": "Peringkat Populer",
"newReleases": "Rilisan Baru",
"newDramas": "Baru",
"trendingMicroDramas": "Drama Mini Populer",
@@ -94,7 +94,6 @@
"emptyHint": "Konten baru akan muncul di sini.",
"demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.",
"myTab": "Saya",
"localViewer": "Penonton Lokal",
"localDemoProfile": "Profil demo lokal",
"continueWatching": "Lanjut Tonton",
"episodeRemaining": "Episode {episode} · {minutes} menit tersisa",
@@ -132,14 +131,13 @@
},
"resume": "Lanjutkan",
"history": "Riwayat",
"watchlist": "Daftar Tonton",
"likedTab": "Disukai",
"all": "Semua",
"inProgress": "Sedang Berjalan",
"completed": "Selesai",
"noSavedShows": "Belum ada tontonan di sini.",
"preferences": "Preferensi",
"followingTab": "Mengikuti",
"followingTab": "Daftar Tonton",
"animatedTab": "Animasi",
"recommendedTab": "Rekomendasi",
"noCategoryDramas": "Belum ada drama di kategori ini.",
@@ -320,10 +318,6 @@
}
},
"retry": "Coba lagi",
"pullToRefresh": "Tarik untuk menyegarkan",
"releaseToRefresh": "Lepaskan untuk menyegarkan",
"refreshingLabel": "Menyegarkan…",
"noMore": "Tidak ada lagi",
"backToSeries": "Kembali ke serial",
"nextEpisode": "Episode berikutnya",
"pause": "Jeda",
@@ -601,7 +595,7 @@
"ledgerAdjustment": "Penyesuaian",
"rankingTab": "Peringkat",
"rankingTitle": "Peringkat Teratas",
"rankingSubtitle": "Diurutkan dari feed katalog terkini.",
"rankingSubtitle": "Diurutkan langsung berdasarkan jumlah tayangan",
"rankingHot": "Populer",
"rankingNew": "Rilis baru",
"newReleasesAll": "Semua rilis baru",
@@ -654,5 +648,71 @@
"danmakuSettings": "Pengaturan komentar layar",
"offlineCache": "Cache offline",
"screenCast": "Cast",
"sleepTimer": "Timer tidur"
"sleepTimer": "Timer tidur",
"homeSearchHint": "Cari drama, aktor, genre...",
"homeSectionFeatured": "Unggulan",
"homeSectionNew": "Baru",
"homeSectionCategories": "Kategori",
"homeSectionRanking": "Peringkat",
"homeSectionCostume": "Kostum",
"homeSectionCommunity": "Komunitas",
"homeTagHot": "Populer",
"homeTagUplifting": "Membara",
"homeTagSweet": "Manis",
"homeTagFaceSlap": "Balas Dendam",
"homeTagAngst": "Cinta Penuh Luka",
"homeBadgeHot": "Populer",
"homeBadgeNew": "Baru",
"homeBadgeAnime": "Anime",
"homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter",
"homeGridClearFilter": "Hapus filter",
"recommendTopTab": "Rekomendasi",
"playlistTopTab": "Daftar Putar",
"allDramasTopTab": "Semua Drama",
"weeklyHitsTitle": "Hits Minggu Ini",
"weeklyHitsSubtitle": "Drama terpopuler minggu ini, tonton sekaligus!",
"weeklyNewTitle": "Drama Baru Minggu Ini",
"weeklyNewSubtitle": "Rilisan baru untuk memulai maraton berikutnya",
"costumeAwardsTitle": "Drama Kostum Terbaik",
"costumeAwardsSubtitle": "Pilihan terbaik yang wajib ditonton",
"localFilter": "Lokal",
"asiaFilter": "Asia",
"westernFilter": "Barat",
"subtitlesOnlyFilter": "Subtitle",
"dubbedFilter": "Dubbing",
"reversalFilter": "Balas Dendam",
"modernFilter": "Modern",
"edit": "Edit",
"done": "Selesai",
"visitor": "Pengunjung",
"copyUid": "Salin UID",
"uidCopied": "UID disalin",
"offlineDownloads": "Unduhan offline",
"watchPreferences": "Preferensi tontonan",
"contactSupport": "Hubungi dukungan",
"vipBrandName": "Netshort VIP",
"mineVipSubtitle": "Nikmati manfaat eksklusif",
"favoriteEpisodeProgress": "Episode {episode} / total {total}",
"@favoriteEpisodeProgress": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"watchedToEpisode": "Ditonton hingga Episode {episode}",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "Tonton semua",
"confirmRemove": "Hapus item ini?",
"followingCountLabel": "Mengikuti",
"watchRecordsCountLabel": "Riwayat tontonan",
"dataUnavailable": "—",
"commonQuestions": "Pertanyaan umum",
"aboutUs": "Tentang kami",
"aboutDescription": "Tempat fokus dan imersif untuk menonton drama pendek.",
"versionLabel": "Versi {version}",
"@versionLabel": {
"placeholders": {
"version": { "type": "String" }
}
}
}
+327 -39
View File
@@ -391,7 +391,7 @@ abstract class AppLocalizations {
/// No description provided for @topCharts.
///
/// In en, this message translates to:
/// **'Top Charts'**
/// **'Hot Ranking'**
String get topCharts;
/// No description provided for @newReleases.
@@ -568,12 +568,6 @@ abstract class AppLocalizations {
/// **'My'**
String get myTab;
/// No description provided for @localViewer.
///
/// In en, this message translates to:
/// **'Local Viewer'**
String get localViewer;
/// No description provided for @localDemoProfile.
///
/// In en, this message translates to:
@@ -628,12 +622,6 @@ abstract class AppLocalizations {
/// **'History'**
String get history;
/// No description provided for @watchlist.
///
/// In en, this message translates to:
/// **'Favorites'**
String get watchlist;
/// No description provided for @likedTab.
///
/// In en, this message translates to:
@@ -685,7 +673,7 @@ abstract class AppLocalizations {
/// No description provided for @recommendedTab.
///
/// In en, this message translates to:
/// **'Recommended'**
/// **'Recommend'**
String get recommendedTab;
/// No description provided for @noCategoryDramas.
@@ -1354,30 +1342,6 @@ abstract class AppLocalizations {
/// **'Retry'**
String get retry;
/// No description provided for @pullToRefresh.
///
/// In en, this message translates to:
/// **'Pull to refresh'**
String get pullToRefresh;
/// No description provided for @releaseToRefresh.
///
/// In en, this message translates to:
/// **'Release to refresh'**
String get releaseToRefresh;
/// No description provided for @refreshingLabel.
///
/// In en, this message translates to:
/// **'Refreshing…'**
String get refreshingLabel;
/// No description provided for @noMore.
///
/// In en, this message translates to:
/// **'No more'**
String get noMore;
/// No description provided for @backToSeries.
///
/// In en, this message translates to:
@@ -2713,7 +2677,7 @@ abstract class AppLocalizations {
/// No description provided for @rankingSubtitle.
///
/// In en, this message translates to:
/// **'Ranked from today\'s live catalog feed.'**
/// **'Ranked live by play heat'**
String get rankingSubtitle;
/// No description provided for @rankingHot.
@@ -2997,6 +2961,330 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Sleep timer'**
String get sleepTimer;
/// No description provided for @homeSearchHint.
///
/// In en, this message translates to:
/// **'Search dramas, actors, genres...'**
String get homeSearchHint;
/// No description provided for @homeSectionFeatured.
///
/// In en, this message translates to:
/// **'Featured'**
String get homeSectionFeatured;
/// No description provided for @homeSectionNew.
///
/// In en, this message translates to:
/// **'New'**
String get homeSectionNew;
/// No description provided for @homeSectionCategories.
///
/// In en, this message translates to:
/// **'Categories'**
String get homeSectionCategories;
/// No description provided for @homeSectionRanking.
///
/// In en, this message translates to:
/// **'Charts'**
String get homeSectionRanking;
/// No description provided for @homeSectionCostume.
///
/// In en, this message translates to:
/// **'Costume'**
String get homeSectionCostume;
/// No description provided for @homeSectionCommunity.
///
/// In en, this message translates to:
/// **'Community'**
String get homeSectionCommunity;
/// No description provided for @homeTagHot.
///
/// In en, this message translates to:
/// **'Hot'**
String get homeTagHot;
/// No description provided for @homeTagUplifting.
///
/// In en, this message translates to:
/// **'Thrilling'**
String get homeTagUplifting;
/// No description provided for @homeTagSweet.
///
/// In en, this message translates to:
/// **'Sweet'**
String get homeTagSweet;
/// No description provided for @homeTagFaceSlap.
///
/// In en, this message translates to:
/// **'Revenge'**
String get homeTagFaceSlap;
/// No description provided for @homeTagAngst.
///
/// In en, this message translates to:
/// **'Angst'**
String get homeTagAngst;
/// No description provided for @homeBadgeHot.
///
/// In en, this message translates to:
/// **'Hot'**
String get homeBadgeHot;
/// No description provided for @homeBadgeNew.
///
/// In en, this message translates to:
/// **'New'**
String get homeBadgeNew;
/// No description provided for @homeBadgeAnime.
///
/// In en, this message translates to:
/// **'Anime'**
String get homeBadgeAnime;
/// No description provided for @homeGridFilterEmpty.
///
/// In en, this message translates to:
/// **'No dramas match these filters'**
String get homeGridFilterEmpty;
/// No description provided for @homeGridClearFilter.
///
/// In en, this message translates to:
/// **'Clear filters'**
String get homeGridClearFilter;
/// No description provided for @recommendTopTab.
///
/// In en, this message translates to:
/// **'Recommend'**
String get recommendTopTab;
/// No description provided for @playlistTopTab.
///
/// In en, this message translates to:
/// **'Playlists'**
String get playlistTopTab;
/// No description provided for @allDramasTopTab.
///
/// In en, this message translates to:
/// **'All Dramas'**
String get allDramasTopTab;
/// No description provided for @weeklyHitsTitle.
///
/// In en, this message translates to:
/// **'This Week\'s Hits'**
String get weeklyHitsTitle;
/// No description provided for @weeklyHitsSubtitle.
///
/// In en, this message translates to:
/// **'Binge the hottest stories of the week!'**
String get weeklyHitsSubtitle;
/// No description provided for @weeklyNewTitle.
///
/// In en, this message translates to:
/// **'New This Week'**
String get weeklyNewTitle;
/// No description provided for @weeklyNewSubtitle.
///
/// In en, this message translates to:
/// **'Fresh releases to start your next binge'**
String get weeklyNewSubtitle;
/// No description provided for @costumeAwardsTitle.
///
/// In en, this message translates to:
/// **'Costume Drama Awards'**
String get costumeAwardsTitle;
/// No description provided for @costumeAwardsSubtitle.
///
/// In en, this message translates to:
/// **'Top-rated picks every costume fan needs'**
String get costumeAwardsSubtitle;
/// No description provided for @localFilter.
///
/// In en, this message translates to:
/// **'Local'**
String get localFilter;
/// No description provided for @asiaFilter.
///
/// In en, this message translates to:
/// **'Asia'**
String get asiaFilter;
/// No description provided for @westernFilter.
///
/// In en, this message translates to:
/// **'Western'**
String get westernFilter;
/// No description provided for @subtitlesOnlyFilter.
///
/// In en, this message translates to:
/// **'Subtitles'**
String get subtitlesOnlyFilter;
/// No description provided for @dubbedFilter.
///
/// In en, this message translates to:
/// **'Dubbed'**
String get dubbedFilter;
/// No description provided for @reversalFilter.
///
/// In en, this message translates to:
/// **'Comeback'**
String get reversalFilter;
/// No description provided for @modernFilter.
///
/// In en, this message translates to:
/// **'Modern'**
String get modernFilter;
/// No description provided for @edit.
///
/// In en, this message translates to:
/// **'Edit'**
String get edit;
/// No description provided for @done.
///
/// In en, this message translates to:
/// **'Done'**
String get done;
/// No description provided for @visitor.
///
/// In en, this message translates to:
/// **'Visitor'**
String get visitor;
/// No description provided for @copyUid.
///
/// In en, this message translates to:
/// **'Copy UID'**
String get copyUid;
/// No description provided for @uidCopied.
///
/// In en, this message translates to:
/// **'UID copied'**
String get uidCopied;
/// No description provided for @offlineDownloads.
///
/// In en, this message translates to:
/// **'Offline downloads'**
String get offlineDownloads;
/// No description provided for @watchPreferences.
///
/// In en, this message translates to:
/// **'Watch preferences'**
String get watchPreferences;
/// No description provided for @contactSupport.
///
/// In en, this message translates to:
/// **'Contact support'**
String get contactSupport;
/// No description provided for @vipBrandName.
///
/// In en, this message translates to:
/// **'Netshort VIP'**
String get vipBrandName;
/// No description provided for @mineVipSubtitle.
///
/// In en, this message translates to:
/// **'Enjoy exclusive benefits'**
String get mineVipSubtitle;
/// No description provided for @favoriteEpisodeProgress.
///
/// In en, this message translates to:
/// **'Episode {episode} / {total} total'**
String favoriteEpisodeProgress(int episode, int total);
/// No description provided for @watchedToEpisode.
///
/// In en, this message translates to:
/// **'Watched to Episode {episode}'**
String watchedToEpisode(int episode);
/// No description provided for @homeWatchAll.
///
/// In en, this message translates to:
/// **'Watch all'**
String get homeWatchAll;
/// No description provided for @confirmRemove.
///
/// In en, this message translates to:
/// **'Remove this item?'**
String get confirmRemove;
/// No description provided for @followingCountLabel.
///
/// In en, this message translates to:
/// **'Following'**
String get followingCountLabel;
/// No description provided for @watchRecordsCountLabel.
///
/// In en, this message translates to:
/// **'Watch records'**
String get watchRecordsCountLabel;
/// No description provided for @dataUnavailable.
///
/// In en, this message translates to:
/// **'—'**
String get dataUnavailable;
/// No description provided for @commonQuestions.
///
/// In en, this message translates to:
/// **'Common questions'**
String get commonQuestions;
/// No description provided for @aboutUs.
///
/// In en, this message translates to:
/// **'About us'**
String get aboutUs;
/// No description provided for @aboutDescription.
///
/// In en, this message translates to:
/// **'A focused, immersive place to watch short dramas.'**
String get aboutDescription;
/// No description provided for @versionLabel.
///
/// In en, this message translates to:
/// **'Version {version}'**
String versionLabel(String version);
}
class _AppLocalizationsDelegate
+172 -21
View File
@@ -162,7 +162,7 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get topCharts => 'Top Charts';
String get topCharts => 'Hot Ranking';
@override
String get newReleases => 'New Releases';
@@ -261,9 +261,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get myTab => 'My';
@override
String get localViewer => 'Local Viewer';
@override
String get localDemoProfile => 'Local demo profile';
@@ -301,9 +298,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get history => 'History';
@override
String get watchlist => 'Favorites';
@override
String get likedTab => 'Liked';
@@ -329,7 +323,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get animatedTab => 'Animated';
@override
String get recommendedTab => 'Recommended';
String get recommendedTab => 'Recommend';
@override
String get noCategoryDramas => 'No dramas in this category yet.';
@@ -687,18 +681,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get retry => 'Retry';
@override
String get pullToRefresh => 'Pull to refresh';
@override
String get releaseToRefresh => 'Release to refresh';
@override
String get refreshingLabel => 'Refreshing…';
@override
String get noMore => 'No more';
@override
String get backToSeries => 'Back to series';
@@ -1407,7 +1389,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get rankingTitle => 'Top Charts';
@override
String get rankingSubtitle => 'Ranked from today\'s live catalog feed.';
String get rankingSubtitle => 'Ranked live by play heat';
@override
String get rankingHot => 'Trending';
@@ -1565,4 +1547,173 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sleepTimer => 'Sleep timer';
@override
String get homeSearchHint => 'Search dramas, actors, genres...';
@override
String get homeSectionFeatured => 'Featured';
@override
String get homeSectionNew => 'New';
@override
String get homeSectionCategories => 'Categories';
@override
String get homeSectionRanking => 'Charts';
@override
String get homeSectionCostume => 'Costume';
@override
String get homeSectionCommunity => 'Community';
@override
String get homeTagHot => 'Hot';
@override
String get homeTagUplifting => 'Thrilling';
@override
String get homeTagSweet => 'Sweet';
@override
String get homeTagFaceSlap => 'Revenge';
@override
String get homeTagAngst => 'Angst';
@override
String get homeBadgeHot => 'Hot';
@override
String get homeBadgeNew => 'New';
@override
String get homeBadgeAnime => 'Anime';
@override
String get homeGridFilterEmpty => 'No dramas match these filters';
@override
String get homeGridClearFilter => 'Clear filters';
@override
String get recommendTopTab => 'Recommend';
@override
String get playlistTopTab => 'Playlists';
@override
String get allDramasTopTab => 'All Dramas';
@override
String get weeklyHitsTitle => 'This Week\'s Hits';
@override
String get weeklyHitsSubtitle => 'Binge the hottest stories of the week!';
@override
String get weeklyNewTitle => 'New This Week';
@override
String get weeklyNewSubtitle => 'Fresh releases to start your next binge';
@override
String get costumeAwardsTitle => 'Costume Drama Awards';
@override
String get costumeAwardsSubtitle => 'Top-rated picks every costume fan needs';
@override
String get localFilter => 'Local';
@override
String get asiaFilter => 'Asia';
@override
String get westernFilter => 'Western';
@override
String get subtitlesOnlyFilter => 'Subtitles';
@override
String get dubbedFilter => 'Dubbed';
@override
String get reversalFilter => 'Comeback';
@override
String get modernFilter => 'Modern';
@override
String get edit => 'Edit';
@override
String get done => 'Done';
@override
String get visitor => 'Visitor';
@override
String get copyUid => 'Copy UID';
@override
String get uidCopied => 'UID copied';
@override
String get offlineDownloads => 'Offline downloads';
@override
String get watchPreferences => 'Watch preferences';
@override
String get contactSupport => 'Contact support';
@override
String get vipBrandName => 'Netshort VIP';
@override
String get mineVipSubtitle => 'Enjoy exclusive benefits';
@override
String favoriteEpisodeProgress(int episode, int total) {
return 'Episode $episode / $total total';
}
@override
String watchedToEpisode(int episode) {
return 'Watched to Episode $episode';
}
@override
String get homeWatchAll => 'Watch all';
@override
String get confirmRemove => 'Remove this item?';
@override
String get followingCountLabel => 'Following';
@override
String get watchRecordsCountLabel => 'Watch records';
@override
String get dataUnavailable => '—';
@override
String get commonQuestions => 'Common questions';
@override
String get aboutUs => 'About us';
@override
String get aboutDescription =>
'A focused, immersive place to watch short dramas.';
@override
String versionLabel(String version) {
return 'Version $version';
}
}
+175 -21
View File
@@ -162,7 +162,7 @@ class AppLocalizationsId extends AppLocalizations {
}
@override
String get topCharts => 'Peringkat Teratas';
String get topCharts => 'Peringkat Populer';
@override
String get newReleases => 'Rilisan Baru';
@@ -261,9 +261,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get myTab => 'Saya';
@override
String get localViewer => 'Penonton Lokal';
@override
String get localDemoProfile => 'Profil demo lokal';
@@ -301,9 +298,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get history => 'Riwayat';
@override
String get watchlist => 'Daftar Tonton';
@override
String get likedTab => 'Disukai';
@@ -323,7 +317,7 @@ class AppLocalizationsId extends AppLocalizations {
String get preferences => 'Preferensi';
@override
String get followingTab => 'Mengikuti';
String get followingTab => 'Daftar Tonton';
@override
String get animatedTab => 'Animasi';
@@ -690,18 +684,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get retry => 'Coba lagi';
@override
String get pullToRefresh => 'Tarik untuk menyegarkan';
@override
String get releaseToRefresh => 'Lepaskan untuk menyegarkan';
@override
String get refreshingLabel => 'Menyegarkan…';
@override
String get noMore => 'Tidak ada lagi';
@override
String get backToSeries => 'Kembali ke serial';
@@ -1407,7 +1389,8 @@ class AppLocalizationsId extends AppLocalizations {
String get rankingTitle => 'Peringkat Teratas';
@override
String get rankingSubtitle => 'Diurutkan dari feed katalog terkini.';
String get rankingSubtitle =>
'Diurutkan langsung berdasarkan jumlah tayangan';
@override
String get rankingHot => 'Populer';
@@ -1565,4 +1548,175 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get sleepTimer => 'Timer tidur';
@override
String get homeSearchHint => 'Cari drama, aktor, genre...';
@override
String get homeSectionFeatured => 'Unggulan';
@override
String get homeSectionNew => 'Baru';
@override
String get homeSectionCategories => 'Kategori';
@override
String get homeSectionRanking => 'Peringkat';
@override
String get homeSectionCostume => 'Kostum';
@override
String get homeSectionCommunity => 'Komunitas';
@override
String get homeTagHot => 'Populer';
@override
String get homeTagUplifting => 'Membara';
@override
String get homeTagSweet => 'Manis';
@override
String get homeTagFaceSlap => 'Balas Dendam';
@override
String get homeTagAngst => 'Cinta Penuh Luka';
@override
String get homeBadgeHot => 'Populer';
@override
String get homeBadgeNew => 'Baru';
@override
String get homeBadgeAnime => 'Anime';
@override
String get homeGridFilterEmpty => 'Tidak ada drama yang cocok dengan filter';
@override
String get homeGridClearFilter => 'Hapus filter';
@override
String get recommendTopTab => 'Rekomendasi';
@override
String get playlistTopTab => 'Daftar Putar';
@override
String get allDramasTopTab => 'Semua Drama';
@override
String get weeklyHitsTitle => 'Hits Minggu Ini';
@override
String get weeklyHitsSubtitle =>
'Drama terpopuler minggu ini, tonton sekaligus!';
@override
String get weeklyNewTitle => 'Drama Baru Minggu Ini';
@override
String get weeklyNewSubtitle =>
'Rilisan baru untuk memulai maraton berikutnya';
@override
String get costumeAwardsTitle => 'Drama Kostum Terbaik';
@override
String get costumeAwardsSubtitle => 'Pilihan terbaik yang wajib ditonton';
@override
String get localFilter => 'Lokal';
@override
String get asiaFilter => 'Asia';
@override
String get westernFilter => 'Barat';
@override
String get subtitlesOnlyFilter => 'Subtitle';
@override
String get dubbedFilter => 'Dubbing';
@override
String get reversalFilter => 'Balas Dendam';
@override
String get modernFilter => 'Modern';
@override
String get edit => 'Edit';
@override
String get done => 'Selesai';
@override
String get visitor => 'Pengunjung';
@override
String get copyUid => 'Salin UID';
@override
String get uidCopied => 'UID disalin';
@override
String get offlineDownloads => 'Unduhan offline';
@override
String get watchPreferences => 'Preferensi tontonan';
@override
String get contactSupport => 'Hubungi dukungan';
@override
String get vipBrandName => 'Netshort VIP';
@override
String get mineVipSubtitle => 'Nikmati manfaat eksklusif';
@override
String favoriteEpisodeProgress(int episode, int total) {
return 'Episode $episode / total $total';
}
@override
String watchedToEpisode(int episode) {
return 'Ditonton hingga Episode $episode';
}
@override
String get homeWatchAll => 'Tonton semua';
@override
String get confirmRemove => 'Hapus item ini?';
@override
String get followingCountLabel => 'Mengikuti';
@override
String get watchRecordsCountLabel => 'Riwayat tontonan';
@override
String get dataUnavailable => '—';
@override
String get commonQuestions => 'Pertanyaan umum';
@override
String get aboutUs => 'Tentang kami';
@override
String get aboutDescription =>
'Tempat fokus dan imersif untuk menonton drama pendek.';
@override
String versionLabel(String version) {
return 'Versi $version';
}
}
+173 -23
View File
@@ -159,7 +159,7 @@ class AppLocalizationsZh extends AppLocalizations {
}
@override
String get topCharts => '排行榜';
String get topCharts => '热播榜';
@override
String get newReleases => '新剧';
@@ -256,9 +256,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get myTab => '我的';
@override
String get localViewer => '本地观众';
@override
String get localDemoProfile => '本地演示档案';
@@ -287,7 +284,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String watchedPercent(int percent) {
return '已观看 $percent%';
return '已看 $percent%';
}
@override
@@ -296,9 +293,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get history => '历史';
@override
String get watchlist => '收藏';
@override
String get likedTab => '喜欢';
@@ -318,7 +312,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get preferences => '偏好设置';
@override
String get followingTab => '关注';
String get followingTab => '追剧';
@override
String get animatedTab => '漫剧';
@@ -679,18 +673,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get retry => '重试';
@override
String get pullToRefresh => '下拉刷新';
@override
String get releaseToRefresh => '松手刷新';
@override
String get refreshingLabel => '正在刷新…';
@override
String get noMore => '没有更多了';
@override
String get backToSeries => '返回剧集详情';
@@ -857,7 +839,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get freeBonus => '领免费奖励';
@override
String get watchHistory => '观看历史';
String get watchHistory => '播放历史';
@override
String get settings => '设置';
@@ -1385,7 +1367,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get rankingTitle => '热门榜单';
@override
String get rankingSubtitle => '依据目录实时分区排序。';
String get rankingSubtitle => '按播放热度实时排序';
@override
String get rankingHot => '热播';
@@ -1541,4 +1523,172 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get sleepTimer => '定时关闭';
@override
String get homeSearchHint => '搜索短剧、演员、类型…';
@override
String get homeSectionFeatured => '精选';
@override
String get homeSectionNew => '新剧';
@override
String get homeSectionCategories => '分类';
@override
String get homeSectionRanking => '榜单';
@override
String get homeSectionCostume => '古装';
@override
String get homeSectionCommunity => '社群';
@override
String get homeTagHot => '热门';
@override
String get homeTagUplifting => '爽剧';
@override
String get homeTagSweet => '甜宠';
@override
String get homeTagFaceSlap => '打脸虐渣';
@override
String get homeTagAngst => '虐恋';
@override
String get homeBadgeHot => '热剧';
@override
String get homeBadgeNew => '新剧';
@override
String get homeBadgeAnime => '漫剧';
@override
String get homeGridFilterEmpty => '没有符合筛选条件的短剧';
@override
String get homeGridClearFilter => '清除筛选';
@override
String get recommendTopTab => '推荐';
@override
String get playlistTopTab => '剧单';
@override
String get allDramasTopTab => '全部短剧';
@override
String get weeklyHitsTitle => '本週爆款推薦';
@override
String get weeklyHitsSubtitle => '本週話題熱劇,一次追完!';
@override
String get weeklyNewTitle => '本週精選新劇';
@override
String get weeklyNewSubtitle => '新劇來襲!追劇節奏啟動';
@override
String get costumeAwardsTitle => '古裝神劇大賞';
@override
String get costumeAwardsSubtitle => '口碑爆棚!古裝控必收片單';
@override
String get localFilter => '本地';
@override
String get asiaFilter => '亚洲';
@override
String get westernFilter => '欧美';
@override
String get subtitlesOnlyFilter => '仅字幕';
@override
String get dubbedFilter => '配音';
@override
String get reversalFilter => '逆袭';
@override
String get modernFilter => '现代';
@override
String get edit => '编辑';
@override
String get done => '完成';
@override
String get visitor => '访客';
@override
String get copyUid => '复制 UID';
@override
String get uidCopied => 'UID 已复制';
@override
String get offlineDownloads => '离线缓存';
@override
String get watchPreferences => '观看偏好';
@override
String get contactSupport => '联系客服';
@override
String get vipBrandName => 'Netshort VIP';
@override
String get mineVipSubtitle => '享受专属权益';
@override
String favoriteEpisodeProgress(int episode, int total) {
return '第 $episode 集 / 共 $total 集';
}
@override
String watchedToEpisode(int episode) {
return '已看至 第 $episode 集';
}
@override
String get homeWatchAll => '观看全集';
@override
String get confirmRemove => '确定移除这条内容吗?';
@override
String get followingCountLabel => '追剧数';
@override
String get watchRecordsCountLabel => '观看记录';
@override
String get dataUnavailable => '—';
@override
String get commonQuestions => '常见问题';
@override
String get aboutUs => '关于我们';
@override
String get aboutDescription => '专注于短剧观看与沉浸式追剧体验。';
@override
String versionLabel(String version) {
return '版本 $version';
}
}
+72 -12
View File
@@ -48,7 +48,7 @@
"authorNoteLabel": "作者声明",
"reserve": "预约",
"heatLabel": "{count}热度",
"topCharts": "排行榜",
"topCharts": "热播榜",
"newReleases": "新剧",
"newDramas": "新剧",
"trendingMicroDramas": "热门短剧",
@@ -94,7 +94,6 @@
"emptyHint": "有新内容时会显示在这里。",
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
"myTab": "我的",
"localViewer": "本地观众",
"localDemoProfile": "本地演示档案",
"continueWatching": "继续观看",
"episodeRemaining": "第 {episode} 集 · 还剩 {minutes} 分钟",
@@ -122,7 +121,7 @@
"count": {"type": "int"}
}
},
"watchedPercent": "已观看 {percent}%",
"watchedPercent": "已看 {percent}%",
"@watchedPercent": {
"placeholders": {
"percent": {
@@ -132,14 +131,13 @@
},
"resume": "继续播放",
"history": "历史",
"watchlist": "收藏",
"likedTab": "喜欢",
"all": "全部",
"inProgress": "未看完",
"completed": "已看完",
"noSavedShows": "这里还没有剧集。",
"preferences": "偏好设置",
"followingTab": "关注",
"followingTab": "追剧",
"animatedTab": "漫剧",
"recommendedTab": "推荐",
"noCategoryDramas": "这个分类暂时没有剧集。",
@@ -282,10 +280,6 @@
}
},
"retry": "重试",
"pullToRefresh": "下拉刷新",
"releaseToRefresh": "松手刷新",
"refreshingLabel": "正在刷新…",
"noMore": "没有更多了",
"backToSeries": "返回剧集详情",
"nextEpisode": "下一集",
"pause": "暂停",
@@ -341,7 +335,7 @@
"joinVip": "开通会员",
"earnRewards": "赚奖励",
"freeBonus": "领免费奖励",
"watchHistory": "观看历史",
"watchHistory": "播放历史",
"settings": "设置",
"serverAddress": "服务器地址",
"serverSettings": "服务器设置",
@@ -563,7 +557,7 @@
"ledgerAdjustment": "余额调整",
"rankingTab": "榜单",
"rankingTitle": "热门榜单",
"rankingSubtitle": "依据目录实时分区排序。",
"rankingSubtitle": "按播放热度实时排序",
"rankingHot": "热播",
"rankingNew": "新剧",
"newReleasesAll": "全部新剧",
@@ -616,5 +610,71 @@
"danmakuSettings": "弹幕设置",
"offlineCache": "离线缓存",
"screenCast": "投屏",
"sleepTimer": "定时关闭"
"sleepTimer": "定时关闭",
"homeSearchHint": "搜索短剧、演员、类型…",
"homeSectionFeatured": "精选",
"homeSectionNew": "新剧",
"homeSectionCategories": "分类",
"homeSectionRanking": "榜单",
"homeSectionCostume": "古装",
"homeSectionCommunity": "社群",
"homeTagHot": "热门",
"homeTagUplifting": "爽剧",
"homeTagSweet": "甜宠",
"homeTagFaceSlap": "打脸虐渣",
"homeTagAngst": "虐恋",
"homeBadgeHot": "热剧",
"homeBadgeNew": "新剧",
"homeBadgeAnime": "漫剧",
"homeGridFilterEmpty": "没有符合筛选条件的短剧",
"homeGridClearFilter": "清除筛选",
"recommendTopTab": "推荐",
"playlistTopTab": "剧单",
"allDramasTopTab": "全部短剧",
"weeklyHitsTitle": "本週爆款推薦",
"weeklyHitsSubtitle": "本週話題熱劇,一次追完!",
"weeklyNewTitle": "本週精選新劇",
"weeklyNewSubtitle": "新劇來襲!追劇節奏啟動",
"costumeAwardsTitle": "古裝神劇大賞",
"costumeAwardsSubtitle": "口碑爆棚!古裝控必收片單",
"localFilter": "本地",
"asiaFilter": "亚洲",
"westernFilter": "欧美",
"subtitlesOnlyFilter": "仅字幕",
"dubbedFilter": "配音",
"reversalFilter": "逆袭",
"modernFilter": "现代",
"edit": "编辑",
"done": "完成",
"visitor": "访客",
"copyUid": "复制 UID",
"uidCopied": "UID 已复制",
"offlineDownloads": "离线缓存",
"watchPreferences": "观看偏好",
"contactSupport": "联系客服",
"vipBrandName": "Netshort VIP",
"mineVipSubtitle": "享受专属权益",
"favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集",
"@favoriteEpisodeProgress": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"watchedToEpisode": "已看至 第 {episode} 集",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "观看全集",
"confirmRemove": "确定移除这条内容吗?",
"followingCountLabel": "追剧数",
"watchRecordsCountLabel": "观看记录",
"dataUnavailable": "—",
"commonQuestions": "常见问题",
"aboutUs": "关于我们",
"aboutDescription": "专注于短剧观看与沉浸式追剧体验。",
"versionLabel": "版本 {version}",
"@versionLabel": {
"placeholders": {
"version": { "type": "String" }
}
}
}
@@ -51,7 +51,7 @@ class _AccountDeactivatePageState extends State<AccountDeactivatePage> {
final signedIn = context.watch<AuthController>().isSignedIn;
final canShow = signedIn || _logic.scheduledAt != null;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('account-deactivate-page'),
backKey: const Key('account-deactivate-back'),
+1 -1
View File
@@ -51,7 +51,7 @@ class _AccountDevicesPageState extends State<AccountDevicesPage> {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('account-devices-page'),
backKey: const Key('account-devices-back'),
+1 -1
View File
@@ -52,7 +52,7 @@ class _AccountExportPageState extends State<AccountExportPage> {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('account-export-page'),
backKey: const Key('account-export-back'),
+2 -1
View File
@@ -70,7 +70,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('account-profile-page'),
backKey: const Key('account-profile-back'),
@@ -181,6 +181,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
Widget _genderField(AppLocalizations l10n) => DropdownButtonFormField<String>(
key: const Key('account-gender'),
isExpanded: true,
initialValue: _gender,
decoration: InputDecoration(labelText: l10n.accountGender),
items: [
+1 -1
View File
@@ -268,7 +268,7 @@ class _ModeSegment extends StatelessWidget {
duration: _segmentDuration,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.blue : Colors.transparent,
color: selected ? AppColors.blue : AppColors.transparent,
borderRadius: BorderRadius.circular(18),
),
child: Stack(
+1 -1
View File
@@ -258,7 +258,7 @@ class _AuthPageState extends State<AuthPage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.ink,
+2 -2
View File
@@ -76,7 +76,7 @@ class _AuthCard extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
boxShadow: const [AppTheme.cardShadow],
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -141,7 +141,7 @@ class _AuthModeSwitch extends StatelessWidget {
style: TextButton.styleFrom(
minimumSize: const Size(0, _minTapTarget),
padding: EdgeInsets.zero,
overlayColor: Colors.transparent,
overlayColor: AppColors.transparent,
splashFactory: NoSplash.splashFactory,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
+2 -2
View File
@@ -28,8 +28,8 @@ Future<bool?> showPhoneAuthSheet(BuildContext context, {bool signUp = false}) {
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.62),
backgroundColor: AppColors.transparent,
barrierColor: AppColors.mediaBlack.withValues(alpha: 0.62),
builder: (_) => PhoneAuthSheet(initialSignUp: signUp),
);
}
+5 -5
View File
@@ -13,8 +13,8 @@ const double _arrowSize = 36;
const double _illustrationWidth = 156;
const double _illustrationHeight = 88;
const double _mottoLineWidth = 96;
const List<Color> _aiGradientStart = [Color(0xFFFFE3D0), Color(0xFFFFC9AC)];
const List<Color> _uploadGradient = [Color(0xFFFFEAD9), Color(0xFFFFD3B3)];
const List<Color> _aiGradientStart = [AppColors.raised, AppColors.goldCard];
const List<Color> _uploadGradient = [AppColors.card, AppColors.raised];
/// 创作页:AI 制作与上传发布两张主卡,底部品牌标语。
class CreatePage extends StatelessWidget {
@@ -24,7 +24,7 @@ class CreatePage extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: SafeArea(
bottom: false,
child: ListView(
@@ -131,7 +131,7 @@ class _CreateCard extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(_cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
@@ -296,7 +296,7 @@ class _UploadIllustration extends StatelessWidget {
width: 30,
height: 20,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.55),
color: AppColors.onSurface.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
+2 -2
View File
@@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 剧场页与根导航之间的动作。
enum DiscoverPageAction { home, create, my }
/// 推荐页与根导航之间的动作。
enum DiscoverPageAction { home, recommend, following, mine }
/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。
class DiscoverPageCallbacks {
+20 -42
View File
@@ -17,8 +17,8 @@ import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/catalog_debug_info.dart';
import '../../core/widgets/catalog_endpoint_editor.dart';
import '../../core/widgets/empty_state.dart';
@@ -35,8 +35,8 @@ import 'widgets/discover_header.dart';
import 'widgets/discover_search_results.dart';
import 'widgets/drama_grid.dart';
const Color _filterIconColor = Color(0xFF7B61FF);
const Color _newIconColor = Color(0xFF14B8A6);
const Color _filterIconColor = AppColors.blueSoft;
const Color _newIconColor = AppColors.blueSoft;
const double _toolbarHeight = 36;
const double _toolbarGap = 10;
const double _toolbarRadius = 10;
@@ -149,7 +149,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
key: const Key('theater-page-surface'),
color: AppColors.ink,
child: SafeArea(
bottom: !embedded,
bottom: false,
child: Column(
children: [
DiscoverHeader(
@@ -159,15 +159,15 @@ class _DiscoverPageState extends State<DiscoverPage> {
loading: logic.searchLoading,
),
Expanded(child: _feed(l10n, categories)),
if (!embedded) _bottomNav(l10n),
if (!embedded) _bottomNav(),
],
),
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
@@ -228,13 +228,13 @@ class _DiscoverPageState extends State<DiscoverPage> {
color: AppColors.ink,
border: Border(
bottom: BorderSide(
color: pinned ? AppColors.border : Colors.transparent,
color: pinned ? AppColors.border : AppColors.transparent,
),
),
boxShadow: pinned
? const [
BoxShadow(
color: Color(0x14000000),
color: AppColors.shadowSubtle,
blurRadius: 8,
offset: Offset(0, 2),
),
@@ -419,7 +419,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
_categoryUnderlineHeight,
),
painter: CategoryUnderlinePainter(
color: selected ? AppColors.blue : Colors.transparent,
color: selected ? AppColors.blue : AppColors.transparent,
),
),
],
@@ -611,39 +611,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
unawaited(playDramaFromCatalog(context, drama));
}
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('discover-nav-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
onTap: () => _act(DiscoverPageAction.home),
),
AppBottomNavItem(
tabKey: const Key('discover-nav-current'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.theaterTab,
selected: true,
onTap: () {},
),
AppBottomNavItem(
tabKey: const Key('discover-nav-create'),
icon: Icons.add_box_outlined,
selectedIcon: Icons.add_box_rounded,
label: l10n.createTab,
onTap: () => _act(DiscoverPageAction.create),
),
AppBottomNavItem(
tabKey: const Key('discover-nav-my'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
onTap: () => _act(DiscoverPageAction.my),
),
],
Widget _bottomNav() => AppRootBottomNav(
selected: AppRootTab.recommend,
onSelected: (tab) => _act(_actionOf(tab)),
);
DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) {
AppRootTab.home => DiscoverPageAction.home,
AppRootTab.recommend => DiscoverPageAction.recommend,
AppRootTab.following => DiscoverPageAction.following,
AppRootTab.mine => DiscoverPageAction.mine,
};
}
/// 分类 tab 的弧形选中线。
@@ -97,7 +97,7 @@ class _DiscoverSearchFieldState extends State<DiscoverSearchField> {
color: AppColors.card,
borderRadius: BorderRadius.circular(_searchRadius),
border: Border.all(
color: _focused ? AppColors.blue : Colors.transparent,
color: _focused ? AppColors.blue : AppColors.transparent,
),
),
child: Row(
+4 -4
View File
@@ -23,8 +23,8 @@ const double _tagChipFontSize = 11;
const double _heatFontSize = 9;
const double _heatIconSize = 11;
const int _maxTags = 3;
const Color _heatBackground = Color(0x99000000);
const Color _tagChipBackground = Color(0xFFF4F1EE);
const Color _heatBackground = AppColors.overlayMedium;
const Color _tagChipBackground = AppColors.raised;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 剧场/分类页共用的双列剧目网格。
@@ -231,11 +231,11 @@ class _DiscoverCoverImage extends StatelessWidget {
Widget _placeholder() {
return const ColoredBox(
color: AppColors.card,
color: AppColors.raised,
child: Center(
child: Icon(
Icons.image_not_supported_outlined,
color: AppColors.muted,
color: AppColors.subtle,
size: 32,
),
),
@@ -183,8 +183,8 @@ class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: drama == null
? const [Color(0x00000000), Color(0x8A0B0C10)]
: const [Color(0x00000000), Color(0xE6000000)],
? const [AppColors.transparent, AppColors.bannerScrimMid]
: const [AppColors.transparent, AppColors.bannerScrimEnd],
),
),
),
@@ -232,7 +232,7 @@ class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
child: dramaId == null
? card
: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: InkWell(
+13 -12
View File
@@ -56,7 +56,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('downloads-page'),
backKey: const Key('downloads-back'),
@@ -105,7 +105,7 @@ class _DownloadsBody extends StatelessWidget {
child: Text(
l10n.downloadedItems,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
@@ -227,8 +227,8 @@ class _PolicyPanel extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
border: Border.all(color: AppColors.outlineVariantDark),
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -236,7 +236,7 @@ class _PolicyPanel extends StatelessWidget {
Text(
l10n.downloadQuota(policy.remaining, policy.maxPerDevice),
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 17,
fontWeight: FontWeight.w800,
),
@@ -261,7 +261,7 @@ class _PolicyPanel extends StatelessWidget {
child: Text(
l10n.downloadWifiOnly,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: AppDimens.body,
),
),
@@ -279,7 +279,7 @@ class _PolicyPanel extends StatelessWidget {
child: Text(
l10n.downloadQuality,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: AppDimens.body,
),
),
@@ -362,7 +362,8 @@ class _LicenseTile extends StatelessWidget {
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -375,7 +376,7 @@ class _LicenseTile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w700,
),
@@ -418,12 +419,12 @@ class _LicenseTile extends StatelessWidget {
),
Text(
l10n.downloadProgress(license.progressPercent),
style: const TextStyle(color: Colors.white70, fontSize: 12),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
if (license.quality.isNotEmpty)
Text(
license.quality,
style: const TextStyle(color: Colors.white70, fontSize: 12),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
),
@@ -433,7 +434,7 @@ class _LicenseTile extends StatelessWidget {
child: LinearProgressIndicator(
minHeight: AppDimens.progressHeight,
value: progress.clamp(0.0, 1.0),
backgroundColor: Colors.white24,
backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft),
),
),
@@ -88,15 +88,15 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('detail-system-overlay'),
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: AppColors.transparent,
systemNavigationBarColor: AppColors.ink,
systemNavigationBarDividerColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
),
child: Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: ListenableBuilder(
listenable: logic,
builder: (context, _) => _page(context),
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../core/widgets/subpage_header.dart';
import '../../../l10n/app_localizations.dart';
@@ -53,13 +54,7 @@ class DramaDetailHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
key: const Key('detail-hero'),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.surfaceTop, AppColors.ink],
),
),
decoration: const BoxDecoration(gradient: AppTheme.pageGradient),
child: SafeArea(
bottom: false,
child: Padding(
@@ -5,12 +5,13 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
const double _horizontal = 14;
const double _sectionTop = 16;
const double _statsVertical = 12;
const double _statsRadius = 14;
const double _statsRadius = AppDimens.cardRadius;
const double _dividerHeight = 28;
const int _synopsisLines = 3;
const int _gridColumns = 6;
@@ -63,6 +64,7 @@ class DramaStatsCard extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_statsRadius),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
@@ -330,7 +332,7 @@ class DramaEpisodeGrid extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_chipRadius),
side: BorderSide(
color: selected || locked ? Colors.transparent : AppColors.border,
color: selected || locked ? AppColors.transparent : AppColors.border,
),
),
clipBehavior: Clip.antiAlias,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
const int _skeletonCount = 6;
@@ -138,7 +139,8 @@ class DramaEpisodesLoadError extends StatelessWidget {
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
+40 -29
View File
@@ -12,6 +12,7 @@ import '../../core/formatting/count_format.dart';
import '../../core/formatting/error_text.dart';
import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/auth_gate.dart';
@@ -24,7 +25,7 @@ import '../player/open_player.dart';
import 'favorites_binding.dart';
const double _pageHorizontal = 14;
const double _cardRadius = 14;
const double _cardRadius = AppDimens.cardRadius;
const double _cardPadding = 10;
const double _coverWidth = 80;
const double _coverHeight = 106;
@@ -44,14 +45,33 @@ const double _loadMoreThreshold = 240;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 独立我的收藏页。
class FavoritesPage extends StatefulWidget {
class FavoritesPage extends StatelessWidget {
const FavoritesPage({super.key});
@override
State<FavoritesPage> createState() => _FavoritesPageState();
Widget build(BuildContext context) {
return Theme(
data: AppTheme.dark(),
child: Scaffold(
key: const Key('favorites-page'),
backgroundColor: AppColors.ink,
body: const SafeArea(child: FavoritesContent()),
),
);
}
}
class _FavoritesPageState extends State<FavoritesPage> {
/// 收藏列表主体;可独立使用,也可直接嵌入根导航。
class FavoritesContent extends StatefulWidget {
const FavoritesContent({super.key, this.showBack = true});
final bool showBack;
@override
State<FavoritesContent> createState() => _FavoritesContentState();
}
class _FavoritesContentState extends State<FavoritesContent> {
bool _requested = false;
@override
@@ -74,28 +94,21 @@ class _FavoritesPageState extends State<FavoritesPage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return Theme(
data: AppTheme.light(),
child: Scaffold(
key: const Key('favorites-page'),
backgroundColor: AppColors.ink,
body: SafeArea(
child: Column(
children: [
AppSubpageHeader(
title: l10n.myFavorites,
backKey: const Key('favorites-back'),
),
Expanded(
child: signedIn
? _FavoritesBody(
favorites: context.watch<FavoriteController>(),
)
: const AuthGatePanel(),
),
],
return SafeArea(
bottom: false,
child: Column(
children: [
AppSubpageHeader(
title: l10n.myFavorites,
backKey: const Key('favorites-back'),
showBack: widget.showBack,
),
),
Expanded(
child: signedIn
? _FavoritesBody(favorites: context.watch<FavoriteController>())
: const AuthGatePanel(),
),
],
),
);
}
@@ -163,8 +176,6 @@ class _FavoritesBody extends StatelessWidget {
],
PagedListFooter(
loading: favorites.loadingMore,
hasMore: favorites.hasMore,
hasItems: favorites.cards.isNotEmpty,
errorMessage: favorites.loadMoreError == null
? null
: errorText(l10n, favorites.loadMoreError, l10n.favoritesError),
@@ -261,10 +272,10 @@ class _FavoriteCard extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
boxShadow: const [AppTheme.cardShadow],
border: Border.all(color: AppColors.border),
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(_cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
+2 -1
View File
@@ -58,7 +58,7 @@ class _FilterPageState extends State<FilterPage> {
);
final results = _filtered(catalog.dramas);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
value: SystemUiOverlayStyle.light,
child: Scaffold(
key: const Key('filter-page'),
backgroundColor: AppColors.ink,
@@ -255,6 +255,7 @@ class _FilterPageState extends State<FilterPage> {
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -0,0 +1,197 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/cover_play_count.dart';
import '../../core/widgets/remote_image.dart';
class FollowingGridCard extends StatelessWidget {
const FollowingGridCard({
super.key,
required this.dramaId,
required this.title,
required this.posterUrl,
required this.viewCount,
required this.subtitle,
required this.onTap,
this.progress,
this.editing = false,
this.onRemove,
});
final String dramaId;
final String title;
final String posterUrl;
final int viewCount;
final String subtitle;
final VoidCallback onTap;
final double? progress;
final bool editing;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
return InkWell(
key: Key('following-card-$dramaId'),
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
key: Key('following-cover-$dramaId'),
aspectRatio: AppDimens.cardCoverAspectRatio,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.homeCardRadius,
),
child: RemoteImage(
posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
),
),
if (editing)
Positioned(
top: -2,
right: -2,
child: GestureDetector(
key: Key('following-remove-$dramaId'),
onTap: onRemove,
child: const CircleAvatar(
radius: 12,
backgroundColor: AppColors.contentChip,
child: Icon(
Icons.remove_rounded,
color: AppColors.textPrimary,
size: 17,
),
),
),
),
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: CoverBottomScrim(),
),
Positioned(
right: AppDimens.cardPlayCountRight,
bottom: progress == null
? AppDimens.cardPlayCountBottom
: AppDimens.followingProgressHeight + 4,
child: CoverPlayCount(
key: Key('following-views-$dramaId'),
count: viewCount,
),
),
if (progress != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
key: Key('following-progress-$dramaId'),
value: progress!.clamp(0.0, 1.0).toDouble(),
minHeight: AppDimens.followingProgressHeight,
backgroundColor: AppColors.inactiveTrack,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary,
),
),
),
],
),
),
const SizedBox(height: AppDimens.cardCoverTitleGap),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.cardTitleFont,
fontWeight: FontWeight.w500,
height: AppDimens.cardTitleLineHeight,
),
),
const SizedBox(height: AppDimens.followingSubtitleGap),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.followingSubtitleFont,
),
),
],
),
);
}
}
class FollowingAddCard extends StatelessWidget {
const FollowingAddCard({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: const Key('following-add-card'),
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
),
child: const Center(
child: CustomPaint(
key: Key('following-add-icon'),
size: Size.square(AppDimens.followingAddIconSize),
painter: _FollowingPlusPainter(),
),
),
),
),
],
),
);
}
}
class _FollowingPlusPainter extends CustomPainter {
const _FollowingPlusPainter();
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = AppColors.textSecondary
..strokeWidth = AppDimens.followingAddIconStroke
..strokeCap = StrokeCap.round;
final center = size.center(Offset.zero);
canvas
..drawLine(Offset(center.dx, 0), Offset(center.dx, size.height), paint)
..drawLine(Offset(0, center.dy), Offset(size.width, center.dy), paint);
}
@override
bool shouldRepaint(covariant _FollowingPlusPainter oldDelegate) => false;
}
@@ -0,0 +1,234 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/cover_play_count.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
class FollowingHistoryCard extends StatelessWidget {
const FollowingHistoryCard({
super.key,
required this.dramaId,
required this.title,
required this.posterUrl,
required this.viewCount,
required this.subtitle,
required this.onTap,
required this.onContinue,
this.progress,
this.progressLabel,
this.editing = false,
this.onRemove,
});
final String dramaId;
final String title;
final String posterUrl;
final int viewCount;
final String subtitle;
final VoidCallback onTap;
final VoidCallback onContinue;
final double? progress;
final String? progressLabel;
final bool editing;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return InkWell(
key: Key('following-history-card-$dramaId'),
borderRadius: BorderRadius.circular(
AppDimens.followingHistoryCoverRadius,
),
onTap: onTap,
child: SizedBox(
height: AppDimens.followingHistoryCoverHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_cover(),
const SizedBox(width: AppDimens.followingHistoryContentGap),
Expanded(child: _details(l10n)),
],
),
),
);
}
Widget _cover() {
return SizedBox(
key: Key('following-history-cover-$dramaId'),
width: AppDimens.followingHistoryCoverWidth,
height: AppDimens.followingHistoryCoverHeight,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.followingHistoryCoverRadius,
),
child: RemoteImage(
posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
),
),
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: CoverBottomScrim(),
),
Positioned(
right: AppDimens.cardPlayCountRight,
bottom: AppDimens.cardPlayCountBottom,
child: CoverPlayCount(
key: Key('following-history-views-$dramaId'),
count: viewCount,
),
),
],
),
);
}
Widget _details(AppLocalizations l10n) {
final hasProgress = progress != null;
return Stack(
children: [
Align(
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(right: editing ? 28 : 0),
child: Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.cardTitleFont,
fontWeight: FontWeight.w500,
height: AppDimens.cardTitleLineHeight,
),
),
),
const SizedBox(height: AppDimens.followingSubtitleGap),
Row(
children: [
Expanded(
child: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.followingSubtitleFont,
),
),
),
if (!editing && hasProgress) ...[
const SizedBox(width: AppDimens.spaceSm),
_continueButton(l10n),
],
],
),
if (hasProgress) ...[
const SizedBox(height: AppDimens.followingHistoryProgressGap),
ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.followingHistoryProgressRadius,
),
child: LinearProgressIndicator(
key: Key('following-progress-$dramaId'),
value: progress!.clamp(0.0, 1.0).toDouble(),
minHeight: AppDimens.followingHistoryProgressHeight,
backgroundColor: AppColors.inactiveTrack,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary,
),
),
),
if (progressLabel != null) ...[
const SizedBox(height: AppDimens.followingHistoryLabelGap),
Text(
progressLabel!,
key: Key('following-history-percent-$dramaId'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.followingSubtitleFont,
),
),
],
],
],
),
),
),
if (editing)
Positioned(
top: 0,
right: 0,
child: GestureDetector(
key: Key('following-history-remove-$dramaId'),
onTap: onRemove,
child: const CircleAvatar(
radius: 12,
backgroundColor: AppColors.contentChip,
child: Icon(
Icons.remove_rounded,
color: AppColors.textPrimary,
size: 17,
),
),
),
),
],
);
}
Widget _continueButton(AppLocalizations l10n) {
return SizedBox(
height: AppDimens.followingHistoryActionHeight,
child: TextButton(
key: Key('following-history-continue-$dramaId'),
onPressed: onContinue,
style: ButtonStyle(
foregroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.pressed)
? AppColors.primary
: AppColors.textTabInactive,
),
backgroundColor: const WidgetStatePropertyAll(AppColors.transparent),
overlayColor: const WidgetStatePropertyAll(AppColors.transparent),
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
minimumSize: const WidgetStatePropertyAll(Size.zero),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
textStyle: const WidgetStatePropertyAll(
TextStyle(
fontSize: AppDimens.followingSubtitleFont,
fontWeight: FontWeight.w500,
),
),
),
child: Text(l10n.continueWatching),
),
);
}
}
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
class FollowingKeepAliveTab extends StatefulWidget {
const FollowingKeepAliveTab({super.key, required this.child});
final Widget child;
@override
State<FollowingKeepAliveTab> createState() => _FollowingKeepAliveTabState();
}
class _FollowingKeepAliveTabState extends State<FollowingKeepAliveTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
+492
View File
@@ -0,0 +1,492 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/watch_record.dart';
import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/watch_records_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../player/open_player.dart';
import 'following_grid_card.dart';
import 'following_history_card.dart';
import 'following_keep_alive_tab.dart';
import 'following_paged_grid.dart';
enum FollowingTab { favorites, history }
class FollowingPage extends StatefulWidget {
const FollowingPage({
super.key,
required this.onGoHome,
required this.favoriteController,
this.historyRequest = 0,
});
final VoidCallback onGoHome;
final FavoriteController favoriteController;
final int historyRequest;
@override
State<FollowingPage> createState() => _FollowingPageState();
}
class _FollowingPageState extends State<FollowingPage> {
final PageController _pageController = PageController();
final ScrollController _favoriteScroll = ScrollController();
final ScrollController _historyScroll = ScrollController();
final ValueNotifier<FollowingTab> _tab = ValueNotifier<FollowingTab>(
FollowingTab.favorites,
);
final ValueNotifier<bool> _editing = ValueNotifier(false);
late final WatchRecordsController _history;
@override
void initState() {
super.initState();
_history = WatchRecordsController(
history: context.read<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(),
);
unawaited(_history.load());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
@override
void dispose() {
_pageController.dispose();
_favoriteScroll.dispose();
_historyScroll.dispose();
_tab.dispose();
_editing.dispose();
_history.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant FollowingPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.historyRequest == widget.historyRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _selectTab(FollowingTab.history);
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
return ColoredBox(
key: const Key('following-page'),
color: AppColors.appBackground,
child: SafeArea(
bottom: false,
child: Column(
children: [
_header(l10n),
Expanded(
child: !signedIn
? const AuthGatePanel()
: PageView(
key: const Key('following-tab-pages'),
controller: _pageController,
onPageChanged: _onPageChanged,
children: [
FollowingKeepAliveTab(
child: _FavoritesGrid(
controller: widget.favoriteController,
editing: _editing,
scrollController: _favoriteScroll,
onGoHome: widget.onGoHome,
onListEmptied: _exitEditingIfEmpty,
),
),
FollowingKeepAliveTab(
child: _HistoryList(
controller: _history,
editing: _editing,
scrollController: _historyScroll,
onListEmptied: _exitEditingIfEmpty,
),
),
],
),
),
],
),
),
);
}
Widget _header(AppLocalizations l10n) {
return SizedBox(
key: const Key('following-header'),
height: AppDimens.followingHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal,
),
child: ValueListenableBuilder<FollowingTab>(
valueListenable: _tab,
builder: (context, selectedTab, _) {
return ValueListenableBuilder<bool>(
valueListenable: _editing,
builder: (context, editing, _) {
return Row(
children: [
_tabButton(
FollowingTab.favorites,
l10n.followingTab,
'favorites',
selectedTab == FollowingTab.favorites,
),
const SizedBox(width: AppDimens.followingTabGap),
_tabButton(
FollowingTab.history,
l10n.watchHistory,
'history',
selectedTab == FollowingTab.history,
),
const Spacer(),
if (editing)
SizedBox(
width: AppDimens.followingEditHitSize,
height: AppDimens.followingEditHitSize,
child: InkWell(
key: const Key('following-edit'),
onTap: _toggleEditing,
child: Align(
alignment: Alignment.centerRight,
child: Text(
l10n.done,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingTabFont,
fontWeight: FontWeight.w600,
),
),
),
),
)
else
Semantics(
button: true,
label: l10n.edit,
child: SizedBox(
width: AppDimens.followingEditHitSize,
height: AppDimens.followingEditHitSize,
child: InkWell(
key: const Key('following-edit'),
onTap: _toggleEditing,
child: const Align(
alignment: Alignment.centerRight,
child: Icon(
Icons.edit_outlined,
size: AppDimens.followingEditIconSize,
color: AppColors.textTabInactive,
),
),
),
),
),
],
);
},
);
},
),
),
);
}
Widget _tabButton(
FollowingTab tab,
String label,
String keyName,
bool selected,
) {
return InkWell(
key: Key('following-tab-$keyName'),
onTap: () => _selectTab(tab),
child: SizedBox(
height: AppDimens.followingHeaderHeight,
child: Center(
child: AnimatedDefaultTextStyle(
duration: AppDimens.followingTabAnimationDuration,
style: TextStyle(
color: selected
? AppColors.textPrimary
: AppColors.textTabInactive,
fontSize: AppDimens.followingTabFont,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
child: Text(label, maxLines: 1),
),
),
),
);
}
void _selectTab(FollowingTab tab) {
_editing.value = false;
_tab.value = tab;
if (_pageController.hasClients) {
unawaited(
_pageController.animateToPage(
tab.index,
duration: AppDimens.followingPageAnimationDuration,
curve: Curves.easeOutCubic,
),
);
}
}
void _onPageChanged(int index) {
final tab = FollowingTab.values[index];
_editing.value = false;
_tab.value = tab;
}
void _toggleEditing() {
_editing.value = !_editing.value;
}
void _exitEditingIfEmpty() => _editing.value = false;
}
class _FavoritesGrid extends StatelessWidget {
const _FavoritesGrid({
required this.controller,
required this.editing,
required this.scrollController,
required this.onGoHome,
required this.onListEmptied,
});
final FavoriteController controller;
final ValueListenable<bool> editing;
final ScrollController scrollController;
final VoidCallback onGoHome;
final VoidCallback onListEmptied;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: editing,
builder: (context, isEditing, _) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
return FollowingPagedGrid(
key: const Key('following-favorites-grid'),
scrollController: scrollController,
loading: controller.loading,
loadingMore: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.cards.isNotEmpty,
showInitialLoading: false,
errorMessage: controller.error == null
? null
: errorText(l10n, controller.error, l10n.favoritesError),
loadMoreError: controller.loadMoreError == null
? null
: errorText(
l10n,
controller.loadMoreError,
l10n.favoritesError,
),
onRefresh: controller.load,
onLoadMore: controller.loadMore,
itemCount: controller.cards.length + 1,
empty: const SizedBox.shrink(),
itemBuilder: (context, index) {
if (index == controller.cards.length) {
return FollowingAddCard(onTap: onGoHome);
}
final drama = controller.cards[index];
final episode = drama.episodes.isEmpty
? 1
: drama.episodes.first.number;
return FollowingGridCard(
dramaId: drama.id,
title: drama.title,
posterUrl: drama.posterUrl,
viewCount: drama.viewCount,
editing: isEditing,
subtitle: l10n.favoriteEpisodeProgress(
episode,
drama.episodeCount,
),
onTap: () => unawaited(playDramaFromCatalog(context, drama)),
onRemove: () async {
if (await _confirmRemove(context) &&
await controller.remove(drama.id) &&
controller.cards.isEmpty) {
onListEmptied();
}
},
);
},
);
},
);
},
);
}
}
class _HistoryList extends StatelessWidget {
const _HistoryList({
required this.controller,
required this.editing,
required this.scrollController,
required this.onListEmptied,
});
final WatchRecordsController controller;
final ValueListenable<bool> editing;
final ScrollController scrollController;
final VoidCallback onListEmptied;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: editing,
builder: (context, isEditing, _) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
return FollowingPagedGrid(
key: const Key('following-history-grid'),
scrollController: scrollController,
loading: controller.loading,
loadingMore: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.records.isNotEmpty,
singleColumn: true,
errorMessage: controller.error == null
? null
: errorText(l10n, controller.error, l10n.progressError),
loadMoreError: controller.loadMoreError == null
? null
: errorText(
l10n,
controller.loadMoreError,
l10n.progressError,
),
onRefresh: controller.load,
onLoadMore: controller.loadMore,
itemCount: controller.records.length,
empty: EmptyState(
key: const Key('following-history-empty'),
icon: Icons.history_rounded,
title: l10n.noWatchRecords,
subtitle: l10n.emptyHint,
),
itemBuilder: (context, index) {
final record = controller.records[index];
final progress = record.progress.fraction;
final hasProgress = record.progress.duration > Duration.zero;
final watchedPercent = (progress * 100)
.round()
.clamp(0, 100)
.toInt();
void openRecord() {
unawaited(_openHistory(context, controller, record));
}
return FollowingHistoryCard(
dramaId: record.dramaId,
title: _historyTitle(l10n, record),
posterUrl: record.displayCover,
viewCount:
record.drama?.viewCount ?? record.progress.playCount,
progress: hasProgress ? progress : null,
progressLabel: hasProgress
? l10n.watchedPercent(watchedPercent)
: null,
editing: isEditing,
subtitle: _historySubtitle(l10n, record),
onTap: openRecord,
onContinue: openRecord,
onRemove: () async {
if (await _confirmRemove(context) &&
await controller.remove(record.dramaId) &&
controller.records.isEmpty) {
onListEmptied();
}
},
);
},
);
},
);
},
);
}
}
String _historyTitle(AppLocalizations l10n, WatchRecord record) {
return record.displayTitle.isEmpty
? l10n.episodeLabel(record.episodeNumber)
: record.displayTitle;
}
String _historySubtitle(AppLocalizations l10n, WatchRecord record) {
final total =
record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0;
if (record.episodeNumber <= 0 || total <= 0) {
return l10n.watchedToEpisode(record.episodeNumber);
}
return l10n.favoriteEpisodeProgress(record.episodeNumber, total);
}
Future<void> _openHistory(
BuildContext context,
WatchRecordsController controller,
WatchRecord record,
) async {
final drama = record.drama ?? await controller.detailForOpen(record.dramaId);
if (!context.mounted || drama == null) return;
await playDramaFromCatalog(
context,
drama,
episodeId: record.progress.episodeId,
);
}
Future<bool> _confirmRemove(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
content: Text(l10n.confirmRemove),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.remove),
),
],
),
) ??
false;
}
@@ -0,0 +1,159 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/paged_list_footer.dart';
import '../../l10n/app_localizations.dart';
class FollowingPagedGrid extends StatelessWidget {
const FollowingPagedGrid({
super.key,
required this.scrollController,
required this.loading,
required this.loadingMore,
required this.hasMore,
required this.hasItems,
required this.itemCount,
required this.itemBuilder,
required this.empty,
required this.onRefresh,
required this.onLoadMore,
this.showInitialLoading = true,
this.singleColumn = false,
this.errorMessage,
this.loadMoreError,
});
static const double _loadMoreThreshold = 240;
final ScrollController scrollController;
final bool loading;
final bool loadingMore;
final bool hasMore;
final bool hasItems;
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
final Widget empty;
final String? errorMessage;
final String? loadMoreError;
final bool showInitialLoading;
final bool singleColumn;
final Future<void> Function() onRefresh;
final Future<void> Function() onLoadMore;
@override
Widget build(BuildContext context) {
if (showInitialLoading && loading && !hasItems) {
return const Center(child: CircularProgressIndicator());
}
return LayoutBuilder(
builder: (context, constraints) {
final gridWidth =
constraints.maxWidth - AppDimens.feedPageHorizontal * 2;
final itemWidth = (gridWidth - AppDimens.followingGridGap * 2) / 3;
final itemExtent =
itemWidth / AppDimens.cardCoverAspectRatio +
AppDimens.followingCardTextExtent;
final gridDelegate = singleColumn
? const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisSpacing: AppDimens.followingHistoryRowGap,
mainAxisExtent: AppDimens.followingHistoryCoverHeight,
)
: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: AppDimens.followingGridGap,
mainAxisSpacing: AppDimens.followingGridRowGap,
mainAxisExtent: itemExtent,
);
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
onRefresh: onRefresh,
child: CustomScrollView(
key: ValueKey<String>('following-scroll-$runtimeType'),
controller: scrollController,
physics: AppPullToRefresh.physics,
slivers: [
if (itemCount == 0)
SliverFillRemaining(
hasScrollBody: false,
child: errorMessage == null
? empty
: EmptyState(
icon: Icons.cloud_off_rounded,
title: errorMessage!,
actionLabel: AppLocalizations.of(context)!.retry,
onAction: () => unawaited(onRefresh()),
),
)
else ...[
if (errorMessage != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.spaceSm,
AppDimens.feedPageHorizontal,
0,
),
child: Text(
errorMessage!,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.followingSubtitleFont,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
0,
AppDimens.feedPageHorizontal,
AppDimens.spaceSm,
),
sliver: SliverGrid(
gridDelegate: gridDelegate,
delegate: SliverChildBuilderDelegate(
itemBuilder,
childCount: itemCount,
),
),
),
SliverToBoxAdapter(
child: PagedListFooter(
loading: loadingMore,
errorMessage: loadMoreError,
onRetry: onLoadMore,
),
),
const SliverToBoxAdapter(
child: SizedBox(height: AppDimens.spaceXl),
),
],
],
),
),
);
},
);
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
if (notification.metrics.extentAfter > _loadMoreThreshold) return false;
if (notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification) {
unawaited(onLoadMore());
}
return false;
}
}
+15 -4
View File
@@ -20,6 +20,7 @@ class HomeLogic extends ChangeNotifier {
int searchRequest = 0;
int resumeRequest = 0;
int backRequest = 0;
int followingHistoryRequest = 0;
bool drawerOpen = false;
bool detailOpen = false;
bool videoImmersive = false;
@@ -79,9 +80,11 @@ class HomeLogic extends ChangeNotifier {
switch (action) {
case DiscoverPageAction.home:
select(0);
case DiscoverPageAction.create:
case DiscoverPageAction.recommend:
select(1, search: true);
case DiscoverPageAction.following:
select(2);
case DiscoverPageAction.my:
case DiscoverPageAction.mine:
select(3);
}
}
@@ -98,14 +101,22 @@ class HomeLogic extends ChangeNotifier {
).push(MaterialPageRoute<void>(builder: (_) => const WatchRecordsPage()));
}
void openFollowingHistory() {
tab = 2;
followingHistoryRequest += 1;
notify();
}
void myAction(MyPageAction action) {
switch (action) {
case MyPageAction.home:
select(0);
case MyPageAction.discover:
case MyPageAction.recommend:
select(1, search: true);
case MyPageAction.create:
case MyPageAction.following:
select(2);
case MyPageAction.mine:
select(3);
case MyPageAction.resume:
select(0, resume: true);
}
+72 -241
View File
@@ -8,29 +8,33 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/catalog_retry.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/catalog_debug_info.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/catalog_endpoint_editor.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
import '../create/create_view.dart';
import '../following/following_page.dart';
import '../filter/filter_page.dart';
import '../mine/mine_types.dart';
import '../player/widgets/home_feed_header.dart';
import '../player/player_view.dart';
import '../recommend/recommend_page.dart';
import '../mine/mine_view.dart';
import 'home_feed_channel.dart';
import '../store/vip_page.dart';
import '../wallet/wallet_page.dart';
import 'home_binding.dart';
import 'home_logic.dart';
import 'widgets/home_feed_grid.dart';
import 'widgets/home_failure_view.dart';
import 'widgets/home_grid_header.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -100,7 +104,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
final catalog = context.watch<CatalogController>();
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: _buildHome(context, catalog, l10n),
);
}
@@ -115,7 +119,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
}
if (catalog.error != null) {
return Scaffold(
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
body: HomeFailureView(
error: catalog.debugError ?? catalog.error,
onRetry: () => unawaited(_retryHome(catalog)),
@@ -125,7 +129,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
}
if (catalog.dramas.isEmpty) {
return Scaffold(
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
body: EmptyState(
key: const Key('home-empty'),
icon: Icons.movie_outlined,
@@ -189,34 +193,6 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
}
}
/// 首页目录加载失败与重试入口。
class HomeFailureView extends StatelessWidget {
const HomeFailureView({
super.key,
this.error,
required this.onRetry,
this.onEditEndpoint,
});
final String? error;
final VoidCallback onRetry;
final VoidCallback? onEditEndpoint;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return EmptyState(
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: onRetry,
footer: kDebugMode
? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint)
: null,
);
}
}
/// 四个根页面共享同一路由并保持各自状态。
class _HomeTabs extends StatefulWidget {
const _HomeTabs({super.key, required this.feedController});
@@ -229,29 +205,19 @@ class _HomeTabs extends StatefulWidget {
class _HomeTabsState extends State<_HomeTabs> {
late final HomeLogic logic;
List<Drama>? _visibleDramas;
HomeFeedChannel? _visibleChannel;
@override
void initState() {
super.initState();
logic = HomeBinding().create(widget.feedController.dramas);
_visibleDramas = widget.feedController.dramas;
_visibleChannel = widget.feedController.channel;
widget.feedController.addListener(_onFeedChanged);
}
@override
void dispose() {
widget.feedController.removeListener(_onFeedChanged);
logic.dispose();
super.dispose();
}
void _onFeedChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
@@ -261,8 +227,7 @@ class _HomeTabsState extends State<_HomeTabs> {
}
Widget _scaffold(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final feed = logic.tab == 0;
final mediaTab = logic.tab == 0 || logic.tab == 1;
final content = PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
@@ -271,15 +236,15 @@ class _HomeTabsState extends State<_HomeTabs> {
},
child: Scaffold(
key: logic.scaffoldKey,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
resizeToAvoidBottomInset: false,
body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context),
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: feed ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
child: feed
value: SystemUiOverlayStyle.light,
child: mediaTab
? content
: DecoratedBox(
key: const Key('home-page-gradient'),
@@ -293,218 +258,84 @@ class _HomeTabsState extends State<_HomeTabs> {
return IndexedStack(
index: logic.tab,
children: [
_home(context),
_discover(context),
const CreatePage(),
_homeGrid(context),
_recommend(context),
FollowingPage(
onGoHome: () => logic.select(0),
favoriteController: context.read<FavoriteController>(),
historyRequest: logic.followingHistoryRequest,
),
_my(context),
],
);
}
Widget _home(BuildContext context) {
final currentDramas = widget.feedController.dramas;
final currentChannel = widget.feedController.channel;
if (currentDramas.isNotEmpty) {
_visibleDramas = currentDramas;
_visibleChannel = currentChannel;
}
if (currentDramas.isEmpty && !widget.feedController.loadingFirstPage) {
return _feedChannelPlaceholder(context);
}
final visibleDramas = _visibleDramas;
final visibleChannel = _visibleChannel;
final switching =
currentDramas.isEmpty && widget.feedController.loadingFirstPage;
if (visibleDramas == null ||
visibleDramas.isEmpty ||
visibleChannel == null) {
return _feedChannelPlaceholder(context);
}
final playerDramas = currentDramas.isEmpty ? visibleDramas : currentDramas;
final playerChannel = currentDramas.isEmpty
? visibleChannel
: currentChannel;
return Stack(
fit: StackFit.expand,
children: [
PlayerPage(
homeMode: true,
embedded: true,
active: logic.tab == 0,
drama: playerDramas.first,
initialIndex: 0,
feedDramas: playerDramas,
feedChannel: playerChannel,
headerChannel: currentChannel,
onOpenFullPlayer: (drama, episode) =>
logic.playDrama(context, drama, episodeId: episode.id),
onRefreshFeed: widget.feedController.refreshCurrent,
onLoadMore: widget.feedController.loadMore,
onChannelSelected: _selectFeedChannel,
onDramaChanged: logic.onDramaChanged,
onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => logic.select(1, search: true),
onDiscover: () => logic.select(1),
onMy: () => logic.select(3),
),
if (switching)
Positioned.fill(
child: IgnorePointer(
key: const Key('home-feed-channel-loading'),
child: ColoredBox(
color: Colors.black26,
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: Colors.white70,
),
),
),
),
),
),
],
Widget _homeGrid(BuildContext context) {
return HomeFeedGrid(
feedController: widget.feedController,
callbacks: HomeGridCallbacks(
onSearch: () => _openSearch(context),
onOpenVip: () => unawaited(_openVip(context)),
onOpenWallet: () => unawaited(_openWallet(context)),
onOpenFilter: () => _pushPage(context, const FilterPage()),
),
onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)),
);
}
Widget _feedChannelPlaceholder(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
SafeArea(
bottom: false,
child: Column(
children: [
HomeFeedHeader(
l10n: l10n,
channel: widget.feedController.channel,
onChannelSelected: _selectFeedChannel,
onRefresh: () => _refreshPlaceholder(context),
onSearch: () => logic.select(1, search: true),
onMore: () {},
),
Expanded(
child: widget.feedController.loadingFirstPage
? const Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
key: Key('home-feed-channel-loading'),
strokeWidth: 2.4,
color: Colors.white70,
),
),
)
: EmptyState(
icon: Icons.movie_outlined,
title: l10n.noStories,
actionLabel: l10n.retry,
onAction: () =>
unawaited(widget.feedController.refreshCurrent()),
),
),
],
),
),
],
);
}
Future<bool> _refreshPlaceholder(BuildContext context) async {
final ok = await widget.feedController.refreshCurrent();
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)),
);
}
return ok;
}
void _selectFeedChannel(HomeFeedChannel channel) {
unawaited(widget.feedController.selectChannel(channel));
}
Widget _discover(BuildContext context) {
return DiscoverPage(
Widget _recommend(BuildContext context) {
return RecommendPage(
feedController: widget.feedController,
active: logic.tab == 1,
searchRequest: logic.searchRequest,
callbacks: DiscoverPageCallbacks(
onAction: logic.discoverAction,
onDramaSelected: (drama) => logic.playDrama(context, drama),
onProfile: logic.openProfile,
onSearch: () => _openSearch(context),
);
}
void _openSearch(BuildContext context) {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)),
);
}
void _pushPage(BuildContext context, Widget page) {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
}
Future<void> _openVip(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
final wallet = context.read<WalletController>();
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
),
);
}
Future<void> _openWallet(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
}
Widget _my(BuildContext context) {
return MyPage(
active: logic.tab == 3,
callbacks: MyPageCallbacks(
onAction: logic.myAction,
onDramaSelected: (drama) => logic.openDrama(context, drama),
onOpenHistoryTab: logic.openFollowingHistory,
),
);
}
Widget _nav(BuildContext context, AppLocalizations l10n) {
return AppBottomNav(
Widget _nav(BuildContext context) {
return AppRootBottomNav(
barKey: const Key('home-tabs-bar'),
dark: logic.tab == 0,
items: [
_navItem(context, l10n, 0),
_navItem(context, l10n, 1),
_navItem(context, l10n, 2),
_navItem(context, l10n, 3),
],
selected: AppRootTab.values[logic.tab],
onSelected: (tab) => _selectTab(context, tab.index),
);
}
AppBottomNavItem _navItem(
BuildContext context,
AppLocalizations l10n,
int index,
) {
final selected = logic.tab == index;
return switch (index) {
0 => AppBottomNavItem(
tabKey: const Key('tab-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
1 => AppBottomNavItem(
tabKey: const Key('tab-theater'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.theaterTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
2 => AppBottomNavItem(
tabKey: const Key('tab-create'),
icon: Icons.add_box_outlined,
selectedIcon: Icons.add_box_rounded,
label: l10n.createTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
_ => AppBottomNavItem(
tabKey: const Key('tab-mine'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
};
}
void _selectTab(BuildContext context, int tab) {
final same = logic.tab == tab;
if (!same) {
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
import '../../../core/data/models/drama.dart';
const int homeNewReleaseDays = 7;
const int homeHotViewThreshold = 100000;
const int _snowflakeEpochMillis = 1288834974657;
const int _snowflakeTimestampShift = 22;
const Map<String, List<String>> _tagAliases = {
'uplifting': ['爽剧', '逆袭', '复仇', 'revenge', 'balas'],
'sweet': ['甜宠', '甜', 'sweet', 'romantis'],
'faceSlap': ['打脸', '虐渣', 'comeuppance', 'comeback'],
'angst': ['虐恋', '虐心', 'angst', 'heartbreak'],
};
DateTime? publishedAtForDrama(Drama drama) {
if (drama.createdAt != null) return drama.createdAt;
final snowflake = BigInt.tryParse(drama.id);
if (snowflake == null) return null;
final timestamp =
(snowflake >> _snowflakeTimestampShift).toInt() + _snowflakeEpochMillis;
final publishedAt = DateTime.fromMillisecondsSinceEpoch(timestamp);
return publishedAt.year >= 2015 ? publishedAt : null;
}
bool isNewDrama(Drama drama, {DateTime? now}) {
final publishedAt = publishedAtForDrama(drama);
if (publishedAt == null) return false;
final age = (now ?? DateTime.now()).difference(publishedAt);
return !age.isNegative && age <= const Duration(days: homeNewReleaseDays);
}
bool isHotDrama(Drama drama) {
return drama.isHot || drama.viewCount >= homeHotViewThreshold;
}
bool matchesHomeTag(Drama drama, String tagId) {
if (tagId == 'hot') return isHotDrama(drama);
return tagMatchesText(tagId, drama.genre) ||
tagMatchesText(tagId, drama.category);
}
bool tagMatchesText(String tagId, String text) {
if (tagId == 'hot') return false;
final normalized = text.toLowerCase();
final aliases = _tagAliases[tagId] ?? const [];
return aliases.any(normalized.contains);
}
+1 -1
View File
@@ -86,7 +86,7 @@ class HomeDramaCard extends StatelessWidget {
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../core/widgets/catalog_debug_info.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
/// 首页目录加载失败与重试入口。
class HomeFailureView extends StatelessWidget {
const HomeFailureView({
super.key,
this.error,
required this.onRetry,
this.onEditEndpoint,
});
final String? error;
final VoidCallback onRetry;
final VoidCallback? onEditEndpoint;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return EmptyState(
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: onRetry,
footer: kDebugMode
? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint)
: null,
);
}
}
+178
View File
@@ -0,0 +1,178 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/cover_play_count.dart';
import '../../../core/widgets/drama_cover_badge_ribbon.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import 'home_drama_classification.dart';
final RegExp _tagSplitter = RegExp(r'[,/、·]');
enum HomeDramaBadge { hot, newRelease, anime }
class HomeFeedCard extends StatelessWidget {
const HomeFeedCard({
super.key,
required this.drama,
required this.onTap,
this.now,
});
final Drama drama;
final VoidCallback onTap;
final DateTime? now;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Material(
key: Key('home-feed-card-${drama.id}'),
color: AppColors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
_cover(l10n),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
top: AppDimens.cardCoverTitleGap,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
drama.title,
key: Key('home-feed-title-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.cardTitleFont,
fontWeight: FontWeight.w500,
height: AppDimens.cardTitleLineHeight,
),
),
const SizedBox(height: AppDimens.cardTitleTagGap),
_tags(),
],
),
),
),
],
),
),
);
}
Widget _cover(AppLocalizations l10n) {
final badge = _badge();
return ClipRRect(
key: Key('home-feed-cover-clip-${drama.id}'),
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
child: AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
key: Key('home-feed-cover-${drama.id}'),
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
if (badge != null)
Positioned(
top: 0,
right: 0,
child: DramaCoverBadgeRibbon(
label: _badgeLabel(l10n, badge),
color: _badgeColor(badge),
keyPrefix: '${drama.id}-${badge.name}',
),
),
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: CoverBottomScrim(),
),
Positioned(
right: AppDimens.cardPlayCountRight,
bottom: AppDimens.cardPlayCountBottom,
child: CoverPlayCount(
key: Key('home-feed-views-${drama.id}'),
count: drama.viewCount,
),
),
],
),
),
);
}
HomeDramaBadge? _badge() {
if (drama.dramaType.toUpperCase() == 'ANIME') {
return HomeDramaBadge.anime;
}
if (isNewDrama(drama, now: now)) return HomeDramaBadge.newRelease;
if (isHotDrama(drama)) {
return HomeDramaBadge.hot;
}
return null;
}
String _badgeLabel(AppLocalizations l10n, HomeDramaBadge badge) {
return switch (badge) {
HomeDramaBadge.hot => l10n.homeBadgeHot,
HomeDramaBadge.newRelease => l10n.homeBadgeNew,
HomeDramaBadge.anime => l10n.homeBadgeAnime,
};
}
Color _badgeColor(HomeDramaBadge badge) {
return switch (badge) {
HomeDramaBadge.hot => AppColors.badgeHot,
HomeDramaBadge.newRelease => AppColors.badgeNew,
HomeDramaBadge.anime => AppColors.badgeAnime,
};
}
Widget _tags() {
final tags = _cardTags();
if (tags.isEmpty) return const SizedBox.shrink();
return Text(
tags.join(' | '),
key: Key('home-feed-tags-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.cardTagFont,
letterSpacing: 0,
),
);
}
List<String> _cardTags() {
final seen = <String>{};
return [drama.genre, drama.category]
.expand((value) => value.split(_tagSplitter))
.map((value) => value.trim())
.where((value) => value.isNotEmpty && seen.add(value))
.take(3)
.toList(growable: false);
}
}
+387
View File
@@ -0,0 +1,387 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/content_category.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/data/services/category_controller.dart';
import '../../../core/data/services/home_feed_controller.dart';
import '../../../core/data/services/wallet_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_pull_to_refresh.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/paged_list_footer.dart';
import '../../../l10n/app_localizations.dart';
import '../../ranking/ranking_page.dart';
import 'home_drama_classification.dart';
import 'home_feed_card.dart';
import 'home_grid_header.dart';
const double _loadMoreThreshold = 240;
const List<String> _costumeAliases = ['古装', 'costume', 'period'];
const List<String> _communityAliases = ['社群', 'community', 'komunitas'];
class HomeFeedGrid extends StatefulWidget {
const HomeFeedGrid({
super.key,
required this.feedController,
required this.callbacks,
required this.onOpenDrama,
});
final HomeFeedController feedController;
final HomeGridCallbacks callbacks;
final ValueChanged<Drama> onOpenDrama;
@override
State<HomeFeedGrid> createState() => _HomeFeedGridState();
}
class _HomeFeedGridState extends State<HomeFeedGrid> {
HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured;
final PageController _pageController = PageController();
final Map<HomePrimaryTab, ScrollController> _scrollControllers = {
for (final tab in HomePrimaryTab.values) tab: ScrollController(),
};
final Map<HomePrimaryTab, String?> _selectedTags = {
for (final tab in HomePrimaryTab.values) tab: null,
};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _pageController.hasClients) {
_pageController.jumpToPage(_selectedPrimary.index);
}
});
}
@override
void dispose() {
_pageController.dispose();
for (final controller in _scrollControllers.values) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>().categories;
final auth = context.watch<AuthController>();
final wallet = context.watch<WalletController>();
final shortcuts = _categoryShortcuts(categories, l10n);
final tags = _availableTags(l10n);
final activeTag =
tags.any((tag) => tag.id == _selectedTags[_selectedPrimary])
? _selectedTags[_selectedPrimary]
: null;
final walletBadge = auth.isSignedIn && wallet.snapshot != null
? wallet.balance
: null;
return ColoredBox(
key: const Key('home-grid'),
color: AppColors.appBackground,
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeGridHeader(
callbacks: widget.callbacks,
categories: shortcuts,
tags: tags,
selectedPrimary: _selectedPrimary,
selectedTag: activeTag,
onPrimarySelected: _selectPrimary,
onTagSelected: (tag) =>
setState(() => _selectedTags[_selectedPrimary] = tag),
walletBadge: walletBadge,
),
Expanded(
child: ListenableBuilder(
listenable: widget.feedController,
builder: (context, _) => PageView.builder(
key: const Key('home-primary-page-view'),
controller: _pageController,
itemCount: HomePrimaryTab.values.length,
onPageChanged: (index) => setState(
() => _selectedPrimary = HomePrimaryTab.values[index],
),
itemBuilder: (context, index) => _KeepAlivePage(
key: PageStorageKey(
'home-primary-page-${HomePrimaryTab.values[index].name}',
),
child: _tabGrid(
context,
l10n,
tab: HomePrimaryTab.values[index],
shortcuts: shortcuts,
),
),
),
),
),
],
),
),
);
}
void _selectPrimary(HomePrimaryTab tab) {
if (tab == _selectedPrimary) return;
if (!_pageController.hasClients) {
setState(() => _selectedPrimary = tab);
return;
}
unawaited(
_pageController.animateToPage(
tab.index,
duration: AppDimens.homePrimaryTabAnimationDuration,
curve: Curves.easeOut,
),
);
}
Widget _tabGrid(
BuildContext context,
AppLocalizations l10n, {
required HomePrimaryTab tab,
required List<HomeGridCategoryShortcut> shortcuts,
}) {
if (tab == HomePrimaryTab.ranking) {
return const RankingPage(embedded: true);
}
if (widget.feedController.loadingFirstPage) {
return const Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final tag = _selectedTags[tab];
final dramas = _dramasFor(tab, tag, shortcuts);
return LayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.maxWidth -
AppDimens.feedPageHorizontal * 2 -
AppDimens.homeGridGap * 2) /
3;
final itemExtent =
itemWidth / AppDimens.cardCoverAspectRatio +
AppDimens.homeCardBodyExtent;
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
key: const Key('home-grid-refresh'),
onRefresh: _refresh,
child: CustomScrollView(
key: const Key('home-grid-scroll'),
controller: _scrollControllers[tab],
physics: AppPullToRefresh.physics,
slivers: [
if (dramas.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: _emptyState(l10n, tab, tag),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal,
),
sliver: SliverGrid(
key: const Key('home-grid-cards'),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridRowGap,
mainAxisExtent: itemExtent,
),
delegate: SliverChildBuilderDelegate(
(context, index) => HomeFeedCard(
drama: dramas[index],
onTap: () => widget.onOpenDrama(dramas[index]),
),
childCount: dramas.length,
),
),
),
if (dramas.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
child: PagedListFooter(
loading: widget.feedController.loadingMore,
errorMessage: widget.feedController.lastError == null
? null
: l10n.catalogError,
onRetry: widget.feedController.loadMore,
),
),
),
],
),
),
);
},
);
}
Future<void> _refresh() async {
await widget.feedController.refreshCurrent();
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(widget.feedController.loadMore());
}
return false;
}
List<HomeGridCategoryShortcut> _categoryShortcuts(
List<ContentCategory> categories,
AppLocalizations l10n,
) {
final costume = _findCategory(categories, _costumeAliases);
final community = _findCategory(categories, _communityAliases);
return [
HomeGridCategoryShortcut(
label: l10n.homeSectionCostume,
categoryName: costume?.name ?? '',
tab: HomePrimaryTab.costume,
),
HomeGridCategoryShortcut(
label: l10n.homeSectionCommunity,
categoryName: community?.name ?? '',
tab: HomePrimaryTab.community,
),
];
}
ContentCategory? _findCategory(
List<ContentCategory> categories,
List<String> aliases,
) {
for (final category in categories) {
final name = category.name.toLowerCase();
if (aliases.any(name.contains)) return category;
}
return null;
}
List<HomeGridTagOption> _availableTags(AppLocalizations l10n) {
// UI 先完整展示筛选维度;后续接口补齐题材字段时只替换数据来源。
return [
HomeGridTagOption(id: 'hot', label: l10n.homeTagHot),
HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting),
HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet),
HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap),
HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst),
];
}
List<Drama> _dramasFor(
HomePrimaryTab tab,
String? tag,
List<HomeGridCategoryShortcut> shortcuts,
) {
Iterable<Drama> source = widget.feedController.dramas;
if (tab == HomePrimaryTab.costume || tab == HomePrimaryTab.community) {
final categoryName = shortcuts
.firstWhere((shortcut) => shortcut.tab == tab)
.categoryName
.toLowerCase();
if (categoryName.isEmpty) return const [];
source = source.where(
(drama) =>
drama.category.toLowerCase().contains(categoryName) ||
drama.genre.toLowerCase().contains(categoryName),
);
}
if (tag != null) {
source = source.where((drama) => matchesHomeTag(drama, tag));
}
final dramas = source.toList(growable: false);
switch (tab) {
case HomePrimaryTab.newReleases:
return [...dramas]..sort((a, b) {
final left = publishedAtForDrama(a);
final right = publishedAtForDrama(b);
if (left == null && right == null) return b.id.compareTo(a.id);
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
});
case HomePrimaryTab.ranking:
return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount));
case HomePrimaryTab.featured:
case HomePrimaryTab.categories:
case HomePrimaryTab.costume:
case HomePrimaryTab.community:
return dramas;
}
}
Widget _emptyState(AppLocalizations l10n, HomePrimaryTab tab, String? tag) {
if (widget.feedController.lastError != null) {
return EmptyState(
key: const Key('home-grid-error'),
icon: Icons.cloud_off_rounded,
title: l10n.catalogError,
actionLabel: l10n.retry,
onAction: () => unawaited(widget.feedController.refreshCurrent()),
);
}
final filtered =
tab == HomePrimaryTab.costume ||
tab == HomePrimaryTab.community ||
tag != null;
return EmptyState(
key: const Key('home-grid-empty'),
icon: Icons.movie_filter_outlined,
title: filtered ? l10n.homeGridFilterEmpty : l10n.noStories,
subtitle: filtered ? null : l10n.emptyHint,
actionLabel: filtered ? l10n.homeGridClearFilter : null,
onAction: filtered
? () => setState(() => _selectedTags[tab] = null)
: null,
);
}
}
class _KeepAlivePage extends StatefulWidget {
const _KeepAlivePage({super.key, required this.child});
final Widget child;
@override
State<_KeepAlivePage> createState() => _KeepAlivePageState();
}
class _KeepAlivePageState extends State<_KeepAlivePage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
@@ -0,0 +1,441 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
enum HomePrimaryTab {
featured,
newReleases,
categories,
ranking,
costume,
community,
}
class HomeGridCategoryShortcut {
const HomeGridCategoryShortcut({
required this.label,
required this.categoryName,
required this.tab,
});
final String label;
final String categoryName;
final HomePrimaryTab tab;
}
class HomeGridTagOption {
const HomeGridTagOption({required this.id, required this.label});
final String id;
final String label;
}
class HomeGridCallbacks {
const HomeGridCallbacks({
required this.onSearch,
required this.onOpenVip,
required this.onOpenWallet,
required this.onOpenFilter,
});
final VoidCallback onSearch;
final VoidCallback onOpenVip;
final VoidCallback onOpenWallet;
final VoidCallback onOpenFilter;
}
class HomeGridHeader extends StatelessWidget {
const HomeGridHeader({
super.key,
required this.callbacks,
required this.categories,
required this.tags,
required this.selectedPrimary,
required this.selectedTag,
required this.onPrimarySelected,
required this.onTagSelected,
this.walletBadge,
});
final HomeGridCallbacks callbacks;
final List<HomeGridCategoryShortcut> categories;
final List<HomeGridTagOption> tags;
final HomePrimaryTab selectedPrimary;
final String? selectedTag;
final ValueChanged<HomePrimaryTab> onPrimarySelected;
final ValueChanged<String?> onTagSelected;
final int? walletBadge;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
key: const Key('home-grid-header'),
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
_searchRow(l10n),
_primaryRow(l10n),
if (selectedPrimary == HomePrimaryTab.featured)
_tagRow(l10n)
else
const SizedBox(height: AppDimens.homeSecondaryGridGap),
],
);
}
Widget _searchRow(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.homeSearchTopGap,
AppDimens.feedPageHorizontal,
AppDimens.homeSearchPrimaryGap,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
child: InkWell(
key: const Key('home-search-entry'),
borderRadius: BorderRadius.circular(AppDimens.searchRadius),
onTap: callbacks.onSearch,
child: Container(
height: AppDimens.searchHeight,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceMd,
),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.searchRadius),
),
child: Row(
children: [
const Icon(
Icons.search_rounded,
size: AppDimens.homeHeaderIcon,
color: AppColors.textSecondary,
),
const SizedBox(width: AppDimens.spaceSm),
Expanded(
child: Text(
l10n.homeSearchHint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.searchPlaceholderFont,
),
),
),
],
),
),
),
),
const SizedBox(width: AppDimens.spaceSm),
_headerAction(
key: const Key('home-vip-entry'),
frameKey: const Key('home-vip-icon-frame'),
imageKey: const Key('home-vip-image'),
assetPath: 'assets/icon/crown.png',
fallbackIcon: Icons.workspace_premium_outlined,
color: AppColors.homeCrownGold,
tooltip: l10n.vipMembership,
onTap: callbacks.onOpenVip,
),
const SizedBox(width: AppDimens.homeActionGap),
_headerAction(
key: const Key('home-wallet-entry'),
frameKey: const Key('home-wallet-icon-frame'),
imageKey: const Key('home-wallet-image'),
assetPath: 'assets/icon/gift.png',
fallbackIcon: Icons.card_giftcard_rounded,
color: AppColors.homeGiftPink,
tooltip: l10n.wallet,
onTap: callbacks.onOpenWallet,
badge: walletBadge,
),
],
),
);
}
Widget _headerAction({
required Key key,
required Key frameKey,
required Key imageKey,
required String assetPath,
required IconData fallbackIcon,
required Color color,
required String tooltip,
required VoidCallback onTap,
int? badge,
}) {
return SizedBox.square(
dimension: AppDimens.homeActionSlot,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Tooltip(
message: tooltip,
child: Material(
color: AppColors.transparent,
child: InkWell(
key: key,
onTap: onTap,
child: Center(
child: SizedBox.square(
key: frameKey,
dimension: AppDimens.homeActionIcon,
child: Image.asset(
assetPath,
key: imageKey,
width: AppDimens.homeActionIcon,
height: AppDimens.homeActionIcon,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => Icon(
fallbackIcon,
size: AppDimens.homeActionIcon,
color: color,
),
),
),
),
),
),
),
),
if (badge != null && badge > 0)
Positioned(
key: const Key('home-wallet-badge'),
top: 0,
right: 0,
child: Container(
constraints: const BoxConstraints(minWidth: 20, minHeight: 16),
padding: const EdgeInsets.symmetric(horizontal: 5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.badgeHot,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
formatInteractionCount(badge),
style: const TextStyle(
color: AppColors.navSelected,
fontSize: AppDimens.cardBadgeFont,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
Widget _primaryRow(AppLocalizations l10n) {
return SingleChildScrollView(
key: const Key('home-primary-sections'),
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
_primaryItem(
key: const Key('home-section-featured'),
label: l10n.homeSectionFeatured,
tab: HomePrimaryTab.featured,
),
_primaryItem(
key: const Key('home-section-new'),
label: l10n.homeSectionNew,
tab: HomePrimaryTab.newReleases,
),
_primaryItem(
key: const Key('home-section-categories'),
label: l10n.homeSectionCategories,
tab: HomePrimaryTab.categories,
),
_primaryItem(
key: const Key('home-section-ranking'),
label: l10n.homeSectionRanking,
tab: HomePrimaryTab.ranking,
),
for (final category in categories)
_primaryItem(
key: Key(
category.categoryName.isEmpty
? 'home-section-${category.tab.name}'
: 'home-section-category-${category.categoryName}',
),
label: category.label,
tab: category.tab,
),
],
),
);
}
Widget _primaryItem({
required Key key,
required String label,
required HomePrimaryTab tab,
}) {
final selected = selectedPrimary == tab;
return InkWell(
key: key,
onTap: () => onPrimarySelected(tab),
child: Padding(
padding: const EdgeInsets.only(right: AppDimens.homePrimaryCategoryGap),
child: SizedBox(
height: AppDimens.homePrimaryRowHeight,
child: Align(
alignment: Alignment.bottomLeft,
child: AnimatedDefaultTextStyle(
key: Key('home-primary-label-${tab.name}'),
duration: AppDimens.homePrimaryTabAnimationDuration,
curve: Curves.easeOut,
style: TextStyle(
color: selected
? AppColors.navSelected
: AppColors.textTabInactive,
fontSize: selected
? AppDimens.homePrimaryTabSelectedFont
: AppDimens.homePrimaryTabFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
letterSpacing: 0,
),
child: Text(label, textAlign: TextAlign.left),
),
),
),
),
);
}
Widget _tagRow(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.homePrimarySecondaryGap,
AppDimens.feedPageHorizontal,
AppDimens.homeSecondaryGridGap,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
child: SingleChildScrollView(
key: const Key('home-secondary-tags'),
scrollDirection: Axis.horizontal,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
for (final tag in tags) ...[
_tagChip(tag),
const SizedBox(width: AppDimens.homeSecondaryTagGap),
],
],
),
),
),
const SizedBox(width: AppDimens.homeSecondaryFilterGap),
Tooltip(
message: l10n.filter,
child: SizedBox.square(
dimension: AppDimens.homeSecondaryFilterVisualSize,
child: Material(
color: AppColors.transparent,
child: InkWell(
key: const Key('home-grid-filter'),
onTap: callbacks.onOpenFilter,
child: Center(
child: SizedBox.square(
key: const Key('home-secondary-filter-visual'),
dimension: AppDimens.homeSecondaryFilterVisualSize,
child: const Center(
child: CustomPaint(
key: Key('home-secondary-filter-icon'),
size: Size.square(AppDimens.homeSecondaryFilterIcon),
painter: _ThreeLineFilterPainter(),
),
),
),
),
),
),
),
),
],
),
);
}
Widget _tagChip(HomeGridTagOption tag) {
final selected = selectedTag == tag.id;
final label = Text(
tag.label,
style: TextStyle(
color: selected ? AppColors.navSelected : AppColors.textTertiary,
fontSize: AppDimens.homeSecondaryTagFont,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
);
return InkWell(
key: Key('home-tag-${tag.id}'),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
onTap: () => onTagSelected(selected ? null : tag.id),
child: AnimatedContainer(
key: Key('home-tag-animated-${tag.id}'),
duration: AppDimens.homeSecondaryTagAnimationDuration,
curve: Curves.easeOut,
height: AppDimens.homeSecondaryTagHeight,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.homeSecondaryTagHorizontalPadding,
),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.contentSurface : AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.homeSecondaryTagRadius),
),
child: label,
),
);
}
}
class _ThreeLineFilterPainter extends CustomPainter {
const _ThreeLineFilterPainter();
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = AppColors.textFilterIcon
..strokeWidth = AppDimens.homeSecondaryFilterStroke
..strokeCap = StrokeCap.butt;
for (final y in [
size.height * 0.25,
size.height * 0.5,
size.height * 0.75,
]) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(_ThreeLineFilterPainter oldDelegate) => false;
}
+70
View File
@@ -0,0 +1,70 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/plain_page_scaffold.dart';
import '../../l10n/app_localizations.dart';
// package_info is not configured in this client; keep the fallback in sync
// with the version in pubspec.yaml.
const String _fallbackAppVersion = '1.0.0';
class MineAboutPage extends StatelessWidget {
const MineAboutPage({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('mine-about-page'),
backKey: const Key('mine-about-back'),
title: l10n.aboutUs,
child: ListView(
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceXl,
AppDimens.pageHorizontal,
AppDimens.spaceXl,
),
children: [
Text(
l10n.appName,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.textPrimary,
fontSize: AppDimens.detailHeadline,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppDimens.spaceSm),
Text(
l10n.versionLabel(_fallbackAppVersion),
key: const Key('mine-about-version'),
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.caption,
),
),
const SizedBox(height: AppDimens.spaceXl),
Text(
l10n.aboutDescription,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textTabInactive,
fontSize: AppDimens.body,
height: 1.5,
),
),
],
),
),
);
}
}
+3 -1
View File
@@ -14,6 +14,7 @@ class MineLogic extends ChangeNotifier {
final WatchHistoryRepository history;
List<WatchProgress> recent = const [];
int? watchRecordTotal;
bool recentLoading = false;
String? recentError;
bool _disposed = false;
@@ -28,8 +29,9 @@ class MineLogic extends ChangeNotifier {
recentError = null;
notify();
try {
final page = await history.recent(pageIndex: 1, pageSize: 20);
final page = await history.recent(pageIndex: 1, pageSize: 10);
recent = page.list;
watchRecordTotal = page.total;
} catch (_) {
recentError = 'progressError';
} finally {
+4 -6
View File
@@ -2,18 +2,16 @@
import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 「我的」页与根导航之间的返回动作。
enum MyPageAction { home, discover, create, resume }
enum MyPageAction { home, recommend, following, mine, resume }
/// 「我的」页可进入的功能目的地。
enum MineDestination {
watchlist,
history,
downloads,
wallet,
vip,
preferences,
works,
creatorCenter,
notices,
@@ -23,10 +21,10 @@ enum MineDestination {
/// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。
class MyPageCallbacks {
const MyPageCallbacks({this.onAction, this.onDramaSelected});
const MyPageCallbacks({this.onAction, this.onOpenHistoryTab});
final ValueChanged<MyPageAction>? onAction;
final ValueChanged<Drama>? onDramaSelected;
final VoidCallback? onOpenHistoryTab;
bool get embedded => onAction != null;
}
+207 -126
View File
@@ -3,32 +3,38 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/locale_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/plain_page_scaffold.dart';
import '../../l10n/app_localizations.dart';
import '../downloads/downloads_page.dart';
import '../favorites/favorites_page.dart';
import '../account/account_profile_page.dart';
import '../filter/filter_page.dart';
import '../notices/notices_page.dart';
import '../settings/settings_page.dart';
import '../store/recharge_page.dart';
import '../store/vip_page.dart';
import '../wallet/wallet_page.dart';
import '../watch_records/watch_records_page.dart';
import 'about_page.dart';
import 'mine_binding.dart';
import 'mine_logic.dart';
import 'mine_types.dart';
import 'widgets/mine_continue_watching.dart';
import 'widgets/mine_header.dart';
import 'widgets/mine_menu.dart';
import 'widgets/mine_more_services.dart';
import 'widgets/mine_stats_panel.dart';
/// K28-B 我的页。
class MyPage extends StatefulWidget {
@@ -79,96 +85,133 @@ class _MyPageState extends State<MyPage> {
}
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final content = SafeArea(
bottom: !embedded,
child: Column(
final content = DecoratedBox(
key: const Key('mine-page-surface'),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.mineGradientTop,
AppColors.mineGradientMiddle,
AppColors.mineGradientBottom,
],
stops: [0, 0.4, 0.75],
),
),
child: Stack(
fit: StackFit.expand,
children: [
_topBar(l10n),
Expanded(child: _body(context, l10n)),
if (!embedded) _bottomNav(l10n),
const DecoratedBox(
key: Key('mine-page-glow'),
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(0, -0.95),
radius: 0.9,
colors: [AppColors.mineGradientGlow, AppColors.transparent],
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
Expanded(child: _body(context)),
if (!embedded) _bottomNav(),
],
),
),
],
),
);
return Theme(
data: AppTheme.light(),
child: embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
);
}
Widget _topBar(AppLocalizations l10n) {
return SizedBox(
height: AppDimens.contentHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.myTab,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: AppDimens.pageTitle,
fontWeight: FontWeight.w800,
),
),
),
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.dark(),
child: embedded
? content
: Scaffold(backgroundColor: AppColors.appBackground, body: content),
),
);
}
Widget _body(BuildContext context, AppLocalizations l10n) {
Widget _body(BuildContext context) {
final auth = context.watch<AuthController>();
final favorites = context.watch<FavoriteController>();
final wallet = context.watch<WalletController>();
final locale = context.watch<LocaleController>();
final continueWatching = logic.recent.take(10).toList(growable: false);
final followingCount =
auth.isSignedIn &&
favorites.error == null &&
!(favorites.loading && favorites.total == 0)
? favorites.total
: null;
final watchRecordCount = auth.isSignedIn ? logic.watchRecordTotal : null;
final coinBalance = auth.isSignedIn && wallet.snapshot != null
? wallet.balance
: null;
return ListView(
key: const Key('mine-page-scroll'),
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
0,
AppDimens.pageHorizontal,
AppDimens.spaceXl,
),
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
children: [
MineHeader(onTap: () => _openDestination(MineDestination.settings)),
const SizedBox(height: AppDimens.spaceMd),
MineShortcutGrid(onOpen: _openDestination),
const SizedBox(height: AppDimens.spaceMd),
MineWalletCard(
balance: _walletValue(l10n, auth, wallet),
onOpen: () => _openDestination(MineDestination.wallet),
onRecharge: _openRecharge,
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
child: MineHeader(onLogin: _openLogin, onEdit: _openEditActions),
),
const SizedBox(height: AppDimens.spaceMd),
MineVipCard(onOpen: () => _openDestination(MineDestination.vip)),
const SizedBox(height: AppDimens.spaceMd),
MineMenuList(
onOpen: _openDestination,
onLanguage: _openLanguage,
languageLabel: _languageLabel(l10n, locale.locale),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
child: MineVipBanner(
onOpen: () => _openDestination(MineDestination.vip),
),
),
if (continueWatching.isNotEmpty) ...[
const SizedBox(height: AppDimens.mineSectionGap),
MineContinueWatching(
records: continueWatching,
onOpenAll: _openHistoryTab,
),
],
const SizedBox(height: AppDimens.mineSectionGap),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.mineGridHorizontal,
),
child: MineStatsPanel(
followingCount: followingCount,
watchRecordCount: watchRecordCount,
coinBalance: coinBalance,
),
),
const SizedBox(height: AppDimens.mineSectionGap),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.mineGridHorizontal,
),
child: MineMenuList(
onOpen: _openDestination,
onLanguage: _openLanguage,
),
),
const SizedBox(height: AppDimens.mineSectionGap),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.mineGridHorizontal,
),
child: MineMoreServices(
onCommonQuestions: () => _pushPlaceholder(MinePlaceholderKind.help),
onAbout: () => _push(const MineAboutPage()),
),
),
],
);
}
String _walletValue(
AppLocalizations l10n,
AuthController auth,
WalletController wallet,
) {
if (!auth.isSignedIn) return l10n.logInHint;
if (wallet.snapshot == null) return l10n.coins;
return l10n.walletBalanceShort(wallet.balance);
}
void _openDestination(MineDestination destination) {
switch (destination) {
case MineDestination.watchlist:
_push(const FavoritesPage());
case MineDestination.history:
_push(const WatchRecordsPage());
case MineDestination.downloads:
@@ -177,6 +220,8 @@ class _MyPageState extends State<MyPage> {
_openProtected(const WalletPage());
case MineDestination.vip:
_openVip();
case MineDestination.preferences:
_push(const FilterPage());
case MineDestination.works:
_pushPlaceholder(MinePlaceholderKind.works);
case MineDestination.creatorCenter:
@@ -190,6 +235,73 @@ class _MyPageState extends State<MyPage> {
}
}
void _openLogin() {
unawaited(openAuthPage(context));
}
void _openHistoryTab() {
final callback = widget.callbacks.onOpenHistoryTab;
if (callback != null) {
callback();
return;
}
_push(const WatchRecordsPage());
}
Future<void> _openEditActions() async {
final selected = await showModalBottomSheet<String>(
context: context,
backgroundColor: AppColors.transparent,
showDragHandle: false,
builder: (sheetContext) => Material(
color: AppColors.mineCard,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppDimens.mineCardRadius),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('mine-edit-profile'),
title: Text(AppLocalizations.of(sheetContext)!.accountProfile),
onTap: () => Navigator.pop(sheetContext, 'profile'),
),
ListTile(
key: const Key('mine-edit-works'),
title: Text(AppLocalizations.of(sheetContext)!.myWorks),
onTap: () => Navigator.pop(sheetContext, 'works'),
),
ListTile(
key: const Key('mine-edit-creator'),
title: Text(AppLocalizations.of(sheetContext)!.creatorCenter),
onTap: () => Navigator.pop(sheetContext, 'creator'),
),
ListTile(
key: const Key('mine-edit-notices'),
title: Text(AppLocalizations.of(sheetContext)!.notices),
onTap: () => Navigator.pop(sheetContext, 'notices'),
),
],
),
),
),
);
if (!mounted || selected == null) return;
switch (selected) {
case 'profile':
_push(const AccountProfilePage());
case 'works':
_pushPlaceholder(MinePlaceholderKind.works);
case 'creator':
_pushPlaceholder(MinePlaceholderKind.creator);
case 'notices':
_push(const NoticesPage());
}
}
void _push(Widget page) {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
}
@@ -211,19 +323,10 @@ class _MyPageState extends State<MyPage> {
_openProtected(VipPage(onPurchased: () => unawaited(wallet.load())));
}
String _languageLabel(AppLocalizations l10n, Locale? locale) {
return switch (locale?.languageCode) {
'zh' => l10n.simplifiedChinese,
'en' => l10n.english,
'id' => l10n.bahasaIndonesia,
_ => l10n.language,
};
}
Future<void> _openLanguage() async {
final selected = await showModalBottomSheet<Locale>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
builder: (_) =>
_LanguageSheet(selected: context.read<LocaleController>().locale),
@@ -232,47 +335,20 @@ class _MyPageState extends State<MyPage> {
await context.read<LocaleController>().select(selected);
}
void _openRecharge() {
final wallet = context.read<WalletController>();
_openProtected(RechargePage(onPurchased: () => unawaited(wallet.load())));
}
Widget _bottomNav(AppLocalizations l10n) {
return AppBottomNav(
items: [
AppBottomNavItem(
tabKey: const Key('mine-nav-home'),
icon: Icons.home_outlined,
selectedIcon: Icons.home_rounded,
label: l10n.homeTab,
onTap: () => _act(MyPageAction.home),
),
AppBottomNavItem(
tabKey: const Key('mine-nav-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.theaterTab,
onTap: () => _act(MyPageAction.discover),
),
AppBottomNavItem(
tabKey: const Key('mine-nav-create'),
icon: Icons.add_box_outlined,
selectedIcon: Icons.add_box_rounded,
label: l10n.createTab,
onTap: () => _act(MyPageAction.create),
),
AppBottomNavItem(
tabKey: const Key('mine-nav-current'),
icon: Icons.person_outline_rounded,
selectedIcon: Icons.person_rounded,
label: l10n.myTab,
selected: true,
onTap: () {},
),
],
Widget _bottomNav() {
return AppRootBottomNav(
selected: AppRootTab.mine,
onSelected: (tab) => _act(_actionOf(tab)),
);
}
MyPageAction _actionOf(AppRootTab tab) => switch (tab) {
AppRootTab.home => MyPageAction.home,
AppRootTab.recommend => MyPageAction.recommend,
AppRootTab.following => MyPageAction.following,
AppRootTab.mine => MyPageAction.mine,
};
void _act(MyPageAction action) {
logic.act(
context,
@@ -302,8 +378,10 @@ class _LanguageSheet extends StatelessWidget {
];
return Material(
key: const Key('mine-language-sheet'),
color: AppColors.card,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
color: AppColors.contentCard,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppDimens.mineCardRadius),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
@@ -319,9 +397,9 @@ class _LanguageSheet extends StatelessWidget {
child: Text(
l10n.language,
style: const TextStyle(
color: AppColors.onSurface,
color: AppColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
),
),
),
@@ -338,7 +416,10 @@ class _LanguageSheet extends StatelessWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(option.$3),
trailing: selected?.languageCode == option.$1.languageCode
? const Icon(Icons.check_rounded, color: AppColors.blue)
? const Icon(
Icons.check_rounded,
color: AppColors.textPrimary,
)
: null,
onTap: () => Navigator.pop(context, option.$1),
),
@@ -373,7 +454,7 @@ class _MinePlaceholderPage extends StatelessWidget {
child: Text(
l10n.comingSoon,
style: const TextStyle(
color: AppColors.muted,
color: AppColors.textSecondary,
fontSize: AppDimens.body,
),
),
@@ -0,0 +1,169 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/watch_progress.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
class MineContinueWatching extends StatelessWidget {
const MineContinueWatching({
super.key,
required this.records,
required this.onOpenAll,
});
final List<WatchProgress> records;
final VoidCallback onOpenAll;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final visible = records.take(10).toList(growable: false);
if (visible.isEmpty) return const SizedBox.shrink();
return Column(
key: const Key('mine-continue-watching'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.mineGridHorizontal,
),
child: Row(
children: [
Expanded(
child: Text(
l10n.continueWatching,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.mineSectionTitleFont,
fontWeight: FontWeight.w600,
),
),
),
InkWell(
key: const Key('mine-continue-all'),
onTap: onOpenAll,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.all,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.mineSectionLinkFont,
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.textTertiary,
size: 16,
),
],
),
),
],
),
),
const SizedBox(height: AppDimens.spaceSm),
SizedBox(
height: AppDimens.mineContinueRowHeight,
child: ListView.separated(
key: const Key('mine-continue-list'),
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.mineGridHorizontal,
),
itemCount: visible.length,
separatorBuilder: (_, _) =>
const SizedBox(width: AppDimens.spaceMd),
itemBuilder: (_, index) {
final record = visible[index];
return _card(l10n, record);
},
),
),
],
);
}
Widget _card(AppLocalizations l10n, WatchProgress record) {
final title = record.dramaTitle?.trim() ?? '';
final episode = record.episodeNumber;
final hasProgress = record.duration > Duration.zero;
return SizedBox(
width: AppDimens.mineContinueCardWidth,
child: InkWell(
key: Key('mine-continue-${record.dramaId}'),
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
onTap: onOpenAll,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.homeCardRadius,
),
child: RemoteImage(
record.dramaCover ?? '',
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
),
),
if (hasProgress)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: record.fraction,
minHeight: AppDimens.followingProgressHeight,
backgroundColor: AppColors.inactiveTrack,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary,
),
),
),
],
),
),
const SizedBox(height: AppDimens.cardCoverTitleGap),
Text(
title.isEmpty ? l10n.dataUnavailable : title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.mineContinueTitleFont,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
episode == null || episode <= 0
? l10n.dataUnavailable
: l10n.episodeLabel(episode),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.mineContinueMetaFont,
),
),
],
),
),
);
}
}
+130 -63
View File
@@ -1,20 +1,27 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/centered_toast.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 「我的」页顶部身份卡。
class MineHeader extends StatelessWidget {
const MineHeader({super.key, required this.onTap});
const double _buttonHeight = 34;
const double _buttonRadius = 20;
final VoidCallback onTap;
/// 「我的」页用户区。
class MineHeader extends StatelessWidget {
const MineHeader({super.key, required this.onLogin, required this.onEdit});
final VoidCallback onLogin;
final VoidCallback onEdit;
@override
Widget build(BuildContext context) {
@@ -22,41 +29,89 @@ class MineHeader extends StatelessWidget {
final auth = context.watch<AuthController>();
final user = auth.user;
final signedIn = auth.isSignedIn;
final name = signedIn && (user?.nickname.isNotEmpty ?? false)
? user!.nickname
: l10n.localViewer;
final subtitle = signedIn && (user?.uid.isNotEmpty ?? false)
? 'ID: ${user!.uid}'
: l10n.logInHint;
return Container(
final nickname = signedIn && (user?.nickname.trim().isNotEmpty ?? false)
? user!.nickname.trim()
: l10n.visitor;
final uid = signedIn ? user?.uid.trim() ?? '' : '';
return Padding(
key: const Key('mine-user-row'),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(AppDimens.cardPadding),
child: Row(
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceMd),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_avatar(user?.avatarUrl ?? ''),
const SizedBox(width: AppDimens.spaceMd),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_avatar(user?.avatarUrl ?? ''),
const SizedBox(width: AppDimens.spaceMd),
Expanded(child: _labels(name, subtitle)),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 20,
Text(
nickname,
key: const Key('mine-nickname'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.textPrimary,
fontSize: AppDimens.mineUserName,
fontWeight: FontWeight.w700,
height: AppDimens.mineUserNameLineHeight,
),
),
const SizedBox(height: AppDimens.mineUserNameUidGap),
SizedBox(
key: const Key('mine-uid'),
height: AppDimens.mineUidRowHeight,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
'${l10n.accountUid}: ${uid.isEmpty ? '--' : uid}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.mineUid,
),
),
),
Semantics(
button: true,
label: l10n.copyUid,
child: GestureDetector(
key: const Key('mine-uid-copy'),
behavior: HitTestBehavior.opaque,
onTap: uid.isEmpty
? null
: () => unawaited(
_copyUid(context, uid, l10n.uidCopied),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Icon(
Icons.copy_rounded,
color: AppColors.textTertiary,
size: AppDimens.mineCopyIcon,
),
),
),
),
],
),
),
],
),
),
),
const SizedBox(width: AppDimens.spaceSm),
_accountButton(
key: signedIn ? const Key('mine-edit') : const Key('mine-login'),
label: signedIn ? l10n.edit : l10n.logIn,
onTap: signedIn ? onEdit : onLogin,
),
],
),
);
}
@@ -64,49 +119,61 @@ class MineHeader extends StatelessWidget {
Widget _avatar(String path) {
return Container(
key: const Key('mine-avatar'),
width: 64,
height: 64,
width: AppDimens.mineAvatar,
height: AppDimens.mineAvatar,
decoration: const BoxDecoration(
color: AppColors.badgePink,
color: AppColors.contentSurface,
shape: BoxShape.circle,
),
clipBehavior: Clip.antiAlias,
child: path.isEmpty
? const Icon(
Icons.person_rounded,
color: AppColors.badgePinkText,
size: 30,
color: AppColors.textPrimary,
size: 28,
)
: RemoteImage(path, fit: BoxFit.cover),
);
}
Widget _labels(String name, String subtitle) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w800,
),
Widget _accountButton({
required Key key,
required String label,
required VoidCallback onTap,
}) {
return OutlinedButton(
key: key,
onPressed: onTap,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, _buttonHeight),
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
foregroundColor: AppColors.textPrimary,
side: const BorderSide(color: AppColors.mineOutline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_buttonRadius),
),
const SizedBox(height: AppDimens.spaceXs),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: AppDimens.caption,
),
textStyle: const TextStyle(
fontSize: AppDimens.body,
fontWeight: FontWeight.w500,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label),
const SizedBox(width: 2),
const Icon(Icons.chevron_right_rounded, size: 17),
],
),
);
}
Future<void> _copyUid(
BuildContext context,
String uid,
String message,
) async {
await Clipboard.setData(ClipboardData(text: uid));
if (context.mounted) showCenteredToast(context, message);
}
}
+188 -310
View File
@@ -4,186 +4,16 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_theme.dart';
import '../../../l10n/app_localizations.dart';
import '../mine_types.dart';
/// 我的页三宫格快捷入口。
class MineShortcutGrid extends StatelessWidget {
const MineShortcutGrid({super.key, required this.onOpen});
const double _vipRadius = 12;
const double _vipBadgeSize = 36;
const double _vipButtonHeight = 34;
final ValueChanged<MineDestination> onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
Expanded(
child: _ShortcutTile(
key: const Key('mine-watchlist'),
icon: Icons.bookmark_rounded,
label: l10n.watchlist,
onTap: () => onOpen(MineDestination.watchlist),
),
),
const SizedBox(width: 10),
Expanded(
child: _ShortcutTile(
key: const Key('mine-history'),
icon: Icons.history_rounded,
label: l10n.watchHistory,
onTap: () => onOpen(MineDestination.history),
),
),
const SizedBox(width: 10),
Expanded(
child: _ShortcutTile(
key: const Key('mine-downloads'),
icon: Icons.download_rounded,
label: l10n.downloads,
onTap: () => onOpen(MineDestination.downloads),
),
),
],
);
}
}
class _ShortcutTile extends StatelessWidget {
const _ShortcutTile({
super.key,
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
boxShadow: const [AppTheme.cardShadow],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 88,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: AppColors.blue, size: 24),
const SizedBox(height: AppDimens.spaceSm),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.caption,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
}
}
/// 金币余额卡。
class MineWalletCard extends StatelessWidget {
const MineWalletCard({
super.key,
required this.balance,
required this.onOpen,
required this.onRecharge,
});
final String balance;
final VoidCallback onOpen;
final VoidCallback onRecharge;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('mine-wallet'),
padding: const EdgeInsets.all(AppDimens.cardPadding),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.goldCard, AppColors.card],
),
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
boxShadow: const [AppTheme.cardShadow],
),
child: Row(
children: [
const Icon(
Icons.monetization_on_rounded,
color: AppColors.gold,
size: 30,
),
const SizedBox(width: AppDimens.spaceMd),
Expanded(
child: InkWell(
onTap: onOpen,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.coinBalance,
style: const TextStyle(
color: AppColors.muted,
fontSize: AppDimens.caption,
),
),
const SizedBox(height: 3),
Text(
balance,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.pageTitle,
fontWeight: FontWeight.w800,
),
),
],
),
),
),
TextButton(
key: const Key('mine-wallet-recharge'),
onPressed: onRecharge,
style: TextButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
minimumSize: const Size(64, AppDimens.buttonCompact),
shape: const StadiumBorder(),
),
child: Text(l10n.topUpWallet),
),
],
),
);
}
}
/// 会员权益卡。
class MineVipCard extends StatelessWidget {
const MineVipCard({super.key, required this.onOpen});
/// 金色会员横幅。
class MineVipBanner extends StatelessWidget {
const MineVipBanner({super.key, required this.onOpen});
final VoidCallback onOpen;
@@ -193,30 +23,34 @@ class MineVipCard extends StatelessWidget {
return Container(
key: const Key('mine-vip'),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
boxShadow: const [AppTheme.cardShadow],
gradient: const LinearGradient(
colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd],
),
borderRadius: BorderRadius.circular(_vipRadius),
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
color: AppColors.transparent,
borderRadius: BorderRadius.circular(_vipRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onOpen,
child: Padding(
padding: const EdgeInsets.all(AppDimens.cardPadding),
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.cardPadding,
vertical: AppDimens.spaceMd,
),
child: Row(
children: [
Container(
width: AppDimens.buttonLarge,
height: AppDimens.buttonLarge,
width: _vipBadgeSize,
height: _vipBadgeSize,
decoration: const BoxDecoration(
color: AppColors.badgePink,
color: AppColors.vipBadgeSurface,
shape: BoxShape.circle,
),
child: const Icon(
Icons.workspace_premium_rounded,
color: AppColors.badgePinkText,
color: AppColors.onGold,
size: 22,
),
),
@@ -226,33 +60,44 @@ class MineVipCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.vipMembership,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.listTitle,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
l10n.vipUnlockMessage,
l10n.vipBrandName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
color: AppColors.onGold,
fontSize: AppDimens.sectionTitle,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
l10n.mineVipSubtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onGold,
fontSize: AppDimens.caption,
),
),
],
),
),
Text(
l10n.joinVip,
style: const TextStyle(
color: AppColors.blue,
fontSize: AppDimens.caption,
fontWeight: FontWeight.w700,
const SizedBox(width: AppDimens.spaceSm),
TextButton(
key: const Key('mine-vip-open'),
onPressed: onOpen,
style: TextButton.styleFrom(
minimumSize: const Size(0, _vipButtonHeight),
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceMd,
),
backgroundColor: AppColors.onGold,
foregroundColor: AppColors.textPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_vipButtonHeight),
),
),
child: Text(l10n.joinVip),
),
],
),
@@ -263,18 +108,16 @@ class MineVipCard extends StatelessWidget {
}
}
/// 我的页功能菜单。
/// 「我的」页 4 列功能入口。
class MineMenuList extends StatelessWidget {
const MineMenuList({
super.key,
required this.onOpen,
required this.onLanguage,
required this.languageLabel,
});
final ValueChanged<MineDestination> onOpen;
final VoidCallback onLanguage;
final String languageLabel;
@override
Widget build(BuildContext context) {
@@ -282,51 +125,92 @@ class MineMenuList extends StatelessWidget {
return Container(
key: const Key('mine-menu'),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
boxShadow: const [AppTheme.cardShadow],
color: AppColors.mineGridSurface,
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
color: AppColors.transparent,
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
clipBehavior: Clip.antiAlias,
child: Column(
child: Table(
key: const Key('mine-menu-grid'),
defaultColumnWidth: const FlexColumnWidth(),
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
border: const TableBorder(
horizontalInside: BorderSide(
color: AppColors.mineGridDivider,
width: AppDimens.mineGridDividerWidth,
),
verticalInside: BorderSide(
color: AppColors.mineGridDivider,
width: AppDimens.mineGridDividerWidth,
),
),
children: [
_row(
key: const Key('mine-works'),
icon: Icons.video_collection_outlined,
title: l10n.myWorks,
destination: MineDestination.works,
TableRow(
children: [
_cell(
index: 0,
actionKey: const Key('mine-wallet'),
icon: Icons.account_balance_wallet_outlined,
iconColor: AppColors.mineIconWallet,
label: l10n.wallet,
onTap: () => onOpen(MineDestination.wallet),
),
_cell(
index: 1,
actionKey: const Key('mine-downloads'),
icon: Icons.download_outlined,
iconColor: AppColors.mineIconDownloads,
label: l10n.offlineDownloads,
onTap: () => onOpen(MineDestination.downloads),
),
_cell(
index: 2,
actionKey: const Key('mine-history'),
icon: Icons.history_rounded,
iconColor: AppColors.mineIconHistory,
label: l10n.watchHistory,
onTap: () => onOpen(MineDestination.history),
),
_cell(
index: 3,
actionKey: const Key('mine-watch-preferences'),
icon: Icons.tune_rounded,
iconColor: AppColors.mineIconPreferences,
label: l10n.watchPreferences,
onTap: () => onOpen(MineDestination.preferences),
),
],
),
_divider(),
_row(
key: const Key('mine-creator-center'),
icon: Icons.auto_awesome_rounded,
title: l10n.creatorCenter,
destination: MineDestination.creatorCenter,
),
_divider(),
_row(
key: const Key('mine-notices'),
icon: Icons.notifications_none_rounded,
title: l10n.notices,
destination: MineDestination.notices,
),
_divider(),
_row(
key: const Key('mine-settings'),
icon: Icons.settings_outlined,
title: l10n.settings,
destination: MineDestination.settings,
),
_divider(),
_languageRow(context),
_divider(),
_row(
key: const Key('mine-help'),
icon: Icons.help_outline_rounded,
title: l10n.helpFeedback,
destination: MineDestination.help,
TableRow(
children: [
_cell(
index: 4,
actionKey: const Key('mine-help'),
icon: Icons.support_agent_rounded,
iconColor: AppColors.mineIconSupport,
label: l10n.contactSupport,
onTap: () => onOpen(MineDestination.help),
),
_cell(
index: 5,
actionKey: const Key('mine-language'),
icon: Icons.language_rounded,
iconColor: AppColors.mineIconLanguage,
label: l10n.language,
onTap: onLanguage,
),
_cell(
index: 6,
actionKey: const Key('mine-settings'),
icon: Icons.settings_outlined,
iconColor: AppColors.mineIconSettings,
label: l10n.settings,
onTap: () => onOpen(MineDestination.settings),
),
_cell(index: 7),
],
),
],
),
@@ -334,71 +218,65 @@ class MineMenuList extends StatelessWidget {
);
}
Widget _languageRow(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListTile(
key: const Key('mine-language'),
minTileHeight: 48,
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: const Icon(
Icons.language_rounded,
color: AppColors.blue,
size: 20,
),
title: Text(
l10n.language,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.body,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
languageLabel,
style: const TextStyle(color: AppColors.muted, fontSize: 11),
),
const SizedBox(width: 4),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 18,
),
],
),
onTap: onLanguage,
);
}
Widget _row({
required Key key,
required IconData icon,
required String title,
required MineDestination destination,
Widget _cell({
required int index,
Key? actionKey,
IconData? icon,
Color? iconColor,
String? label,
VoidCallback? onTap,
}) {
return ListTile(
key: key,
minTileHeight: 48,
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: Icon(icon, color: AppColors.blue, size: 20),
title: Text(
title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.body,
),
),
trailing: const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 18,
),
onTap: () => onOpen(destination),
return SizedBox(
key: Key('mine-menu-cell-$index'),
height: AppDimens.mineGridCellHeight,
child: actionKey == null
? const SizedBox.shrink()
: InkWell(
key: actionKey,
onTap: onTap,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: AppDimens.mineGridIconSurface,
height: AppDimens.mineGridIconSurface,
decoration: const BoxDecoration(
color: AppColors.mineGridIconSurface,
shape: BoxShape.circle,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: iconColor?.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: iconColor,
size: AppDimens.mineGridIcon,
),
),
),
const SizedBox(height: AppDimens.spaceXs + 2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
label ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.mineGridLabel,
fontSize: AppDimens.mineGridLabel,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
],
),
),
),
);
}
Widget _divider() {
return const Divider(height: 1, indent: 14, endIndent: 14);
}
}
@@ -0,0 +1,93 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
class MineMoreServices extends StatelessWidget {
const MineMoreServices({
super.key,
required this.onCommonQuestions,
required this.onAbout,
});
final VoidCallback onCommonQuestions;
final VoidCallback onAbout;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('mine-more-services'),
decoration: BoxDecoration(
color: AppColors.minePanelSurface,
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
_row(
key: const Key('mine-common-questions'),
label: l10n.commonQuestions,
onTap: onCommonQuestions,
),
const Divider(
height: AppDimens.mineGridDividerWidth,
thickness: AppDimens.mineGridDividerWidth,
color: AppColors.mineMoreDivider,
),
_row(
key: const Key('mine-about'),
label: l10n.aboutUs,
onTap: onAbout,
),
],
),
);
}
Widget _row({
required Key key,
required String label,
required VoidCallback onTap,
}) {
return Material(
color: AppColors.transparent,
child: InkWell(
key: key,
onTap: onTap,
child: SizedBox(
height: AppDimens.mineMoreRowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.minePanelPadding,
),
child: Row(
children: [
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.mineMoreFont,
fontWeight: FontWeight.w400,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.textSecondary,
size: 18,
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,91 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
class MineStatsPanel extends StatelessWidget {
const MineStatsPanel({
super.key,
required this.followingCount,
required this.watchRecordCount,
required this.coinBalance,
});
final int? followingCount;
final int? watchRecordCount;
final int? coinBalance;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('mine-stats'),
padding: const EdgeInsets.all(AppDimens.minePanelPadding),
decoration: BoxDecoration(
color: AppColors.minePanelSurface,
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
),
child: Row(
children: [
_stat(
key: const Key('mine-stat-following'),
value: followingCount,
label: l10n.followingCountLabel,
l10n: l10n,
),
_stat(
key: const Key('mine-stat-watch-records'),
value: watchRecordCount,
label: l10n.watchRecordsCountLabel,
l10n: l10n,
),
_stat(
key: const Key('mine-stat-coins'),
value: coinBalance,
label: l10n.coinBalance,
l10n: l10n,
),
],
),
);
}
Widget _stat({
required Key key,
required int? value,
required String label,
required AppLocalizations l10n,
}) {
return Expanded(
child: Column(
key: key,
children: [
Text(
value?.toString() ?? l10n.dataUnavailable,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.mineStatsValueFont,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppDimens.spaceXs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.mineStatsLabelFont,
),
),
],
),
);
}
}
+13 -25
View File
@@ -10,6 +10,7 @@ import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/formatting/count_format.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/paged_local_list.dart';
import '../../core/widgets/remote_image.dart';
@@ -102,18 +103,12 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
final hero = visible.isEmpty ? null : visible.first;
final more = pager.items.skip(1).take(_moreLimit).toList();
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
value: SystemUiOverlayStyle.light,
child: Scaffold(
key: const Key('new-releases-page'),
backgroundColor: AppColors.ink,
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.surfaceTop, AppColors.ink],
),
),
decoration: const BoxDecoration(gradient: AppTheme.pageGradient),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -197,13 +192,9 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
),
PagedLoadFooter(
loading: pager.loading,
hasMore: pager.hasMore,
hasItems: pager.items.isNotEmpty,
loadMoreKey: const Key(
'new-releases-load-more',
),
noMoreKey: const Key('new-releases-no-more'),
noMoreLabel: l10n.noMore,
),
],
),
@@ -259,7 +250,10 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), Color(0xB3000000)],
colors: [
AppColors.transparent,
AppColors.playerOverlayPill,
],
),
),
),
@@ -282,7 +276,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
Text(
l10n.premiereToday,
style: const TextStyle(
color: Colors.white70,
color: AppColors.muted,
fontSize: 10,
fontWeight: FontWeight.w600,
),
@@ -293,7 +287,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: 20,
fontWeight: FontWeight.w800,
),
@@ -315,7 +309,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
color: AppColors.muted,
fontSize: 11,
),
),
@@ -338,7 +332,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
color: AppColors.onPrimary,
size: 22,
),
),
@@ -404,13 +398,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
boxShadow: const [
BoxShadow(
color: AppColors.cardShadow,
blurRadius: 12,
offset: Offset(0, 4),
),
],
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -490,7 +478,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
child: Text(
l10n.newBadge,
style: TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w800,
),
+51 -27
View File
@@ -18,6 +18,8 @@ import '../../core/widgets/empty_state.dart';
import '../../core/widgets/plain_page_scaffold.dart';
import '../../l10n/app_localizations.dart';
const double _loadMoreThreshold = 240;
/// Anonymous system notice list with pagination and detail dialog.
class NoticesPage extends StatefulWidget {
const NoticesPage({super.key});
@@ -46,7 +48,7 @@ class _NoticesPageState extends State<NoticesPage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: PlainPageScaffold(
pageKey: const Key('notices-page'),
backKey: const Key('notices-back'),
@@ -111,29 +113,55 @@ class _NoticesPageState extends State<NoticesPage> {
}
return AppPullToRefresh(
onRefresh: _controller.refresh,
child: ListView.separated(
key: const Key('notices-list'),
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
itemCount: _controller.notices.length + 1,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
if (index == _controller.notices.length) return _footer(l10n);
final notice = _controller.notices[index];
return _NoticeTile(
notice: notice,
onTap: () => _showDetails(notice, l10n),
);
},
child: NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: ListView.separated(
key: const Key('notices-list'),
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
itemCount: _controller.notices.length + 1,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
if (index == _controller.notices.length) return _footer(l10n);
final notice = _controller.notices[index];
return _NoticeTile(
notice: notice,
onTap: () => _showDetails(notice, l10n),
);
},
),
),
);
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0 ||
!_controller.hasMore ||
_controller.loadingMore) {
return false;
}
if (notification.metrics.extentAfter > _loadMoreThreshold) return false;
if (notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification) {
unawaited(_controller.loadMore());
}
return false;
}
Widget _footer(AppLocalizations l10n) {
if (_controller.loadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()),
return const SizedBox(
height: AppDimens.loadMoreIndicatorHeight,
child: Center(
child: SizedBox.square(
dimension: AppDimens.refreshSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: AppDimens.refreshSpinnerStroke,
color: AppColors.textPrimary,
),
),
),
);
}
if (_controller.loadMoreError != null) {
@@ -143,14 +171,7 @@ class _NoticesPageState extends State<NoticesPage> {
label: Text(l10n.retry),
);
}
if (_controller.hasMore) {
return TextButton(
key: const Key('notices-load-more'),
onPressed: _controller.loadMore,
child: Text(l10n.loadMore),
);
}
return const SizedBox(height: 8);
return const SizedBox.shrink();
}
Future<void> _showDetails(AppNotice notice, AppLocalizations l10n) {
@@ -213,7 +234,10 @@ class _NoticeTile extends StatelessWidget {
return Material(
key: ValueKey('notice-item-${notice.id}'),
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
side: const BorderSide(color: AppColors.border),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
+71 -39
View File
@@ -23,6 +23,7 @@ import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../core/navigation/fade_page_route.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
@@ -68,6 +69,9 @@ class PlayerLogic extends ChangeNotifier {
VideoPlayerPort? trackedPlayer;
Episode? trackedEpisode;
Drama? trackedDrama;
int? _trackedPlayerIndex;
bool _completionHandled = false;
bool _completionProgressSaved = false;
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
/// 进度上报节流:契约建议每 10 秒一次
@@ -316,6 +320,7 @@ class PlayerLogic extends ChangeNotifier {
unawaited(saveCurrentProgress());
final previous = session;
sessionEpisodes = List<Episode>.of(nextEpisodes);
_detachTrackedPlayerListener();
trackedPlayer = null;
trackedEpisode = null;
trackedDrama = null;
@@ -600,6 +605,7 @@ class PlayerLogic extends ChangeNotifier {
_disposed = true;
pictureInPicture.setStateListener(null);
unawaited(saveCurrentProgress());
_detachTrackedPlayerListener();
if (_started) {
session.setRouteVisible(false);
cancelScrub();
@@ -695,46 +701,66 @@ class PlayerLogic extends ChangeNotifier {
await player.controller.setVolume(muted ? 0 : 1);
await player.controller.setPlaybackSpeed(playbackSpeed);
if (session.active != player) return;
_detachTrackedPlayerListener();
trackedPlayer = player;
trackedEpisode = episode;
trackedDrama = drama;
_trackedPlayerIndex = index;
_completionHandled = false;
_completionProgressSaved = false;
lastProgressWrite = DateTime.now();
var completionHandled = false;
var completionProgressSaved = false;
player.controller.addListener(() {
if (session.active != player) return;
final snapshot = _readPlaybackSnapshot(player);
if (snapshot == null) return;
_cachePlaybackPosition(drama, episode, snapshot);
if (snapshot.completed && !completionProgressSaved) {
completionProgressSaved = true;
unawaited(
queueProgress(
drama,
episode,
snapshot.duration,
duration: snapshot.duration,
finished: true,
),
);
}
if (!completionHandled &&
snapshot.completed &&
session.canAutoAdvance &&
session.currentIndex == index) {
completionHandled = true;
unawaited(advanceAfterEnd(player, index));
}
if (!shouldReportProgress(DateTime.now())) return;
player.controller.addListener(_handleTrackedPlayerChanged);
}
void _detachTrackedPlayerListener() {
trackedPlayer?.controller.removeListener(_handleTrackedPlayerChanged);
_trackedPlayerIndex = null;
}
void _handleTrackedPlayerChanged() {
if (_disposed) return;
final player = trackedPlayer;
final episode = trackedEpisode;
final drama = trackedDrama;
final index = _trackedPlayerIndex;
if (player == null ||
episode == null ||
drama == null ||
index == null ||
session.active != player) {
return;
}
final snapshot = _readPlaybackSnapshot(player);
if (snapshot == null) return;
_cachePlaybackPosition(drama, episode, snapshot);
if (snapshot.completed && !_completionProgressSaved) {
_completionProgressSaved = true;
unawaited(
queueProgress(
drama,
episode,
snapshot.position,
snapshot.duration,
duration: snapshot.duration,
finished: true,
),
);
});
}
if (!_completionHandled &&
snapshot.completed &&
session.canAutoAdvance &&
session.currentIndex == index) {
_completionHandled = true;
unawaited(advanceAfterEnd(player, index));
}
if (!shouldReportProgress(DateTime.now())) return;
unawaited(
queueProgress(
drama,
episode,
snapshot.position,
duration: snapshot.duration,
),
);
}
Future<void> advanceAfterEnd(VideoPlayerPort player, int index) async {
@@ -888,15 +914,19 @@ class PlayerLogic extends ChangeNotifier {
void beginScrub(VideoPlayerPort player) {
if (session.active != player) return;
scrubbingPlayer = player;
session.setOverlayOpen(true);
}
void cancelScrub() {
if (scrubbingPlayer == null) return;
scrubbingPlayer = null;
session.setOverlayOpen(false);
}
Future<void> endScrub(
BuildContext context,
VideoPlayerPort player,
Duration position,
) => seekTo(context, player, position);
Future<void> seekTo(
BuildContext context,
VideoPlayerPort player,
@@ -937,8 +967,9 @@ class PlayerLogic extends ChangeNotifier {
} catch (_) {
if (context.mounted && session.active == player) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
}
} finally {
if (scrubbingPlayer == player) cancelScrub();
@@ -1017,7 +1048,7 @@ class PlayerLogic extends ChangeNotifier {
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
isScrollControlled: true,
builder: (_) => EpisodePickerSheet(
drama: drama,
@@ -1079,10 +1110,11 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
final action = await Navigator.of(context)
.push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
final action = await Navigator.of(
context,
).push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
if (!context.mounted) return;
if (action == MyPageAction.discover) {
if (action == MyPageAction.recommend) {
await showDiscover(context);
} else {
session.setRouteVisible(true);
@@ -1114,7 +1146,7 @@ class PlayerLogic extends ChangeNotifier {
MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)),
);
if (!context.mounted) return;
if (action == DiscoverPageAction.my) {
if (action == DiscoverPageAction.mine) {
await showMy(context);
} else {
session.setRouteVisible(true);
@@ -1154,7 +1186,7 @@ class PlayerLogic extends ChangeNotifier {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
isDismissible: false,
enableDrag: false,
+81 -44
View File
@@ -24,6 +24,7 @@ import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/centered_toast.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import '../home/home_feed_channel.dart';
@@ -59,6 +60,7 @@ class PlayerPage extends StatefulWidget {
this.active = true,
this.resumeRequest = 0,
this.backRequest = 0,
this.scrubCancelRequest = 0,
this.onDiscover,
this.onMy,
this.onSearch,
@@ -66,6 +68,7 @@ class PlayerPage extends StatefulWidget {
this.feedDramas,
this.feedChannel,
this.headerChannel,
this.showHomeFeedHeader = true,
this.onDramaChanged,
this.onImmersiveChanged,
this.onOpenFullPlayer,
@@ -83,6 +86,7 @@ class PlayerPage extends StatefulWidget {
final bool active;
final int resumeRequest;
final int backRequest;
final int scrubCancelRequest;
final VoidCallback? onDiscover;
final VoidCallback? onMy;
final VoidCallback? onSearch;
@@ -90,6 +94,7 @@ class PlayerPage extends StatefulWidget {
final List<Drama>? feedDramas;
final HomeFeedChannel? feedChannel;
final HomeFeedChannel? headerChannel;
final bool showHomeFeedHeader;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
@@ -112,6 +117,7 @@ class _PlayerPageState extends State<PlayerPage>
bool _chromeVisible = true;
double _playbackSpeed = 1.0;
bool _boosting = false;
bool _scrubbing = false;
double? _speedBeforeBoost;
bool? _chromeBeforeBoost;
@@ -145,6 +151,9 @@ class _PlayerPageState extends State<PlayerPage>
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted);
if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) {
_cancelFeedScrub();
}
final channelChanged = oldWidget.feedChannel != widget.feedChannel;
final feedChanged =
_feedSignature(oldWidget.feedDramas) !=
@@ -261,6 +270,7 @@ class _PlayerPageState extends State<PlayerPage>
}
void _hideChrome() {
if (!_chromeVisible) return;
setState(() => _chromeVisible = false);
}
@@ -294,6 +304,43 @@ class _PlayerPageState extends State<PlayerPage>
unawaited(logic.setPlaybackSpeed(speed));
}
void _setScrubbing(bool value) {
if (!mounted) return;
if (_scrubbing == value) return;
setState(() => _scrubbing = value);
}
void _finishFeedScrub(bool wasVisible) {
if (!mounted) return;
setState(() {
_scrubbing = false;
_chromeVisible = wasVisible;
});
}
void _cancelFeedScrub() {
logic.cancelScrub();
_scrubbing = false;
_chromeVisible = true;
}
void _restoreFeedChrome() {
if (!mounted) return;
setState(() {
_scrubbing = false;
_chromeVisible = true;
});
}
void _handleFeedSurfaceTap() {
if (_scrubbing) return;
if (!_chromeVisible) {
_restoreFeedChrome();
return;
}
logic.session.setUserWantsPlay(!logic.session.userWantsPlay);
}
/// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
setState(() {
@@ -360,7 +407,13 @@ class _PlayerPageState extends State<PlayerPage>
fit: StackFit.expand,
children: [
content,
if (widget.homeMode && !_boosting && !logic.pip && !logic.pipPreparing)
if (widget.homeMode &&
widget.showHomeFeedHeader &&
!_scrubbing &&
_chromeVisible &&
!_boosting &&
!logic.pip &&
!logic.pipPreparing)
Positioned(
top: 0,
left: 0,
@@ -393,19 +446,19 @@ class _PlayerPageState extends State<PlayerPage>
return AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('player-system-overlay'),
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.black,
systemNavigationBarDividerColor: Colors.transparent,
statusBarColor: AppColors.transparent,
systemNavigationBarColor: AppColors.mediaBlack,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
),
child: Theme(
data: AppTheme.light(),
data: AppTheme.dark(),
child: widget.embedded
? body
: Scaffold(
key: const Key('player-scaffold'),
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
resizeToAvoidBottomInset: false,
body: body,
),
@@ -457,24 +510,14 @@ class _PlayerPageState extends State<PlayerPage>
if (widget.homeMode)
const ColoredBox(
key: Key('home-feed-black-background'),
color: Colors.black,
color: AppColors.mediaBlack,
),
PlayerVideoSurface(
controller: videoPort?.controller,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater,
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
onTap: widget.homeMode ? () {} : _handleFeedSurfaceTap,
),
if (widget.homeMode && _chromeVisible && !logic.chromeHidden)
Positioned.fill(
child: GestureDetector(
key: const Key('home-feed-video-toggle'),
behavior: HitTestBehavior.opaque,
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
),
),
// 票据三态之一:起播前正在向后端要签名地址
if (selected && logic.ticketLoading)
const Center(
@@ -497,14 +540,14 @@ class _PlayerPageState extends State<PlayerPage>
children: [
const Icon(
Icons.wifi_off_rounded,
color: Colors.white70,
color: AppColors.muted,
size: 36,
),
const SizedBox(height: 12),
Text(
errorText(l10n, logic.ticketError, l10n.playbackError),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.muted),
),
const SizedBox(height: 12),
FilledButton(
@@ -524,14 +567,14 @@ class _PlayerPageState extends State<PlayerPage>
children: [
const Icon(
Icons.error_outline_rounded,
color: Colors.white70,
color: AppColors.muted,
size: 36,
),
const SizedBox(height: 12),
Text(
l10n.playbackError,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.muted),
),
],
),
@@ -551,7 +594,7 @@ class _PlayerPageState extends State<PlayerPage>
children: [
const Icon(
Icons.movie_filter_outlined,
color: Colors.white70,
color: AppColors.muted,
size: 36,
),
const SizedBox(height: 12),
@@ -560,7 +603,7 @@ class _PlayerPageState extends State<PlayerPage>
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.muted),
),
],
),
@@ -579,8 +622,7 @@ class _PlayerPageState extends State<PlayerPage>
boosting: _boosting,
onBoostStart: _startBoost,
onBoostEnd: _endBoost,
onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
onTogglePlay: _handleFeedSurfaceTap,
liked: interactions.isEpisodeLiked(episode.id),
likeCount:
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
@@ -590,22 +632,11 @@ class _PlayerPageState extends State<PlayerPage>
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleEpisodeLike(episode.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ??
AppLocalizations.of(context)!.likeFailed,
),
),
);
}
}
await _toggleLike(context, interactions, episode);
},
onDoubleTapLike: (_) => unawaited(
_toggleLike(context, interactions, episode, likeOnly: true),
),
onSave: () => unawaited(_toggleSave(context, drama)),
onComments: () => unawaited(_openComments(context, drama, episode)),
onShare: () => logic.shareDrama(context, drama),
@@ -615,10 +646,14 @@ class _PlayerPageState extends State<PlayerPage>
if (!mounted || !widget.active) return;
logic.session.setUserWantsPlay(true);
},
onEpisodes: () => logic.showEpisodes(context),
onMore: _openCurrentMore,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onScrubEnd: _finishFeedScrub,
onScrubVisibilityChanged: _setScrubbing,
cancelScrubRequest: widget.scrubCancelRequest,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
? (position) => logic.endScrub(context, videoPort!, position)
: null,
thumbnailService: _thumbnailService,
spriteAxisService: _spriteAxisService,
@@ -688,17 +723,19 @@ class _PlayerPageState extends State<PlayerPage>
? null
: () => _openUnlockSheet(context, index, episode),
),
if (widget.homeMode && !_chromeVisible && !_boosting)
if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing)
Positioned(
right: 12,
bottom: 56,
child: ExitClearScreenButton(
key: const Key('home-feed-exit-clear-screen'),
tooltip: l10n.exitClearScreen,
onPressed: () => setState(() => _chromeVisible = true),
onPressed: _restoreFeedChrome,
),
),
if (widget.homeMode &&
!_scrubbing &&
_chromeVisible &&
!logic.chromeHidden &&
selected &&
!logic.session.userWantsPlay)
+18 -12
View File
@@ -6,7 +6,7 @@ extension _PlayerViewActions on _PlayerPageState {
Future<void> _openSpeedSheet(BuildContext context) async {
final selected = await showModalBottomSheet<double>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
builder: (_) => PlayerSpeedSheet(selected: _playbackSpeed),
);
@@ -19,7 +19,7 @@ extension _PlayerViewActions on _PlayerPageState {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
isScrollControlled: true,
builder: (_) => PlayerMoreSheet(
@@ -52,7 +52,7 @@ extension _PlayerViewActions on _PlayerPageState {
if (wallet == null) return;
await showModalBottomSheet<bool>(
context: context,
backgroundColor: Colors.transparent,
backgroundColor: AppColors.transparent,
showDragHandle: false,
isScrollControlled: true,
builder: (_) => PlayerUnlockSheet(
@@ -81,18 +81,24 @@ extension _PlayerViewActions on _PlayerPageState {
Future<void> _toggleLike(
BuildContext context,
InteractionController interactions,
Episode episode,
) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
Episode episode, {
bool likeOnly = false,
}) async {
unawaited(context.read<Haptics>().lightImpact());
if (interactions.isEpisodeBusy(episode.id) ||
(likeOnly && interactions.isEpisodeLiked(episode.id))) {
return;
}
if (!await ensureSignedIn(context) || !context.mounted) return;
if (interactions.isEpisodeBusy(episode.id) ||
(likeOnly && interactions.isEpisodeLiked(episode.id))) {
return;
}
final result = await interactions.toggleEpisodeLike(episode.id);
if (result.isSuccess || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ?? AppLocalizations.of(context)!.likeFailed,
),
),
showCenteredToast(
context,
result.errorMessage ?? AppLocalizations.of(context)!.likeFailed,
);
}
@@ -79,6 +79,15 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
_trackKeyboardInset(_keyboardHeight(context));
}
@override
void didUpdateWidget(covariant CommentComposerSheet oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onControllerChanged);
widget.controller.addListener(_onControllerChanged);
}
}
Future<void> _prepareInput() async {
if (_isAndroid) {
await _keyboardInset.start();
@@ -413,13 +413,16 @@ class _CommentPostSuccessOverlay extends StatelessWidget {
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
color: AppColors.mediaBlack.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(8),
),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 14),
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 14,
),
),
),
),
@@ -40,11 +40,11 @@ class EpisodePickerSheet extends StatelessWidget {
final episodes = drama.episodes;
return AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('player-episodes-system-overlay'),
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: AppColors.transparent,
systemNavigationBarColor: AppColors.card,
systemNavigationBarDividerColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
systemNavigationBarDividerColor: AppColors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
),
child: Container(
@@ -2,6 +2,8 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// Shared floating action used to leave clear-screen mode.
class ExitClearScreenButton extends StatelessWidget {
const ExitClearScreenButton({
@@ -23,8 +25,8 @@ class ExitClearScreenButton extends StatelessWidget {
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0x80000000),
foregroundColor: Colors.white,
backgroundColor: AppColors.controlScrim,
foregroundColor: AppColors.onPrimary,
shape: const CircleBorder(),
),
icon: const Icon(Icons.fullscreen_exit_rounded, size: 18),
+14 -8
View File
@@ -2,6 +2,8 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../home/home_feed_channel.dart';
import '../../../l10n/app_localizations.dart';
@@ -140,8 +142,8 @@ class _TopIconButton extends StatelessWidget {
icon: Icon(
icon,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
color: AppColors.onPrimary,
shadows: const [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
),
),
);
@@ -184,15 +186,17 @@ class _RefreshButton extends StatelessWidget {
child: CircularProgressIndicator(
key: Key('home-feed-refreshing'),
strokeWidth: 2,
color: Colors.white,
color: AppColors.onPrimary,
),
),
)
: Icon(
Icons.refresh_rounded,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
color: AppColors.onPrimary,
shadows: const [
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
],
),
),
),
@@ -232,10 +236,12 @@ class _FeedTabItem extends StatelessWidget {
label,
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : Colors.white70,
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: fontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
shadows: const [
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
],
),
),
const SizedBox(height: 4),
@@ -243,7 +249,7 @@ class _FeedTabItem extends StatelessWidget {
width: underlineWidth,
height: underlineHeight,
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
color: selected ? AppColors.onPrimary : AppColors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
+183 -260
View File
@@ -3,36 +3,33 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/playback_thumbnail_service.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'interaction_rail.dart';
import 'home_feed_recommend_panel.dart';
import 'loading_progress_track.dart';
import 'scrub_preview_card.dart';
import 'player_double_tap_hearts.dart';
import 'seek_progress_bar.dart';
part 'home_feed_overlay_parts.dart';
const Color _overlayPill = Color(0x66000000);
const Color _fullBarBackground = Color(0x4D000000);
const double _titleFontSize = 17;
const double _metaFontSize = 12;
const double _tagFontSize = 11;
const double _fullBarHeight = 44;
const double _progressHeight = 2;
const double _progressHitHeight = 24;
const double _progressHitHeight = 32;
const double _pageHorizontal = 14;
const double _railRight = 8;
const double _railGap = 12;
const double _bottomInfoInset = 32;
const double _railBottomOffset = 60;
const Duration _overlayFadeDuration = Duration(milliseconds: 120);
const List<Shadow> _boostTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
];
const int _maxTags = 4;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。
class HomeFeedOverlay extends StatefulWidget {
@@ -53,16 +50,21 @@ class HomeFeedOverlay extends StatefulWidget {
required this.onShare,
required this.onDetail,
required this.onOpenFullPlayer,
this.onEpisodes,
required this.onTogglePlay,
required this.onBoostStart,
required this.onBoostEnd,
required this.onMore,
this.onDoubleTapLike,
this.likeCount,
this.favoriteCount,
this.commentCount,
this.authorNote = '',
this.onScrubStart,
this.onScrubEnd,
this.onScrubVisibilityChanged,
this.onSeek,
this.cancelScrubRequest = 0,
this.thumbnailService,
this.spriteAxisService,
});
@@ -86,12 +88,17 @@ class HomeFeedOverlay extends StatefulWidget {
final VoidCallback onShare;
final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer;
final VoidCallback? onEpisodes;
final VoidCallback onTogglePlay;
final VoidCallback onBoostStart;
final VoidCallback onBoostEnd;
final VoidCallback onMore;
final ValueChanged<Offset>? onDoubleTapLike;
final VoidCallback? onScrubStart;
final ValueChanged<bool>? onScrubEnd;
final ValueChanged<bool>? onScrubVisibilityChanged;
final ValueChanged<Duration>? onSeek;
final int cancelScrubRequest;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
@@ -100,6 +107,29 @@ class HomeFeedOverlay extends StatefulWidget {
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
static const Duration _singleTapDelay = Duration(milliseconds: 280);
bool _scrubbing = false;
bool _wasVisibleBeforeScrub = true;
Timer? _singleTapTimer;
DateTime? _lastTapAt;
int _nextHeartBurstId = 0;
final List<({int id, Offset position})> _heartBursts = [];
@override
void didUpdateWidget(covariant HomeFeedOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) {
_scrubbing = false;
}
}
@override
void dispose() {
_singleTapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
@@ -114,7 +144,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
child: GestureDetector(
key: const Key('home-feed-gesture'),
behavior: HitTestBehavior.opaque,
onTap: widget.onTogglePlay,
onTapUp: _handleTapUp,
onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => widget.onBoostEnd(),
@@ -123,24 +153,137 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
),
),
if (widget.boosting)
Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()])
else
Column(
children: [
const Spacer(),
if (widget.visible) _bottomInfo(),
_fullSeriesBar(),
],
Positioned(
left: _pageHorizontal,
right: _railRight,
bottom: _bottomInfoInset,
child: AnimatedOpacity(
key: const Key('home-feed-content-opacity'),
duration: _overlayFadeDuration,
opacity: widget.visible && !_scrubbing && !widget.boosting
? 1
: 0,
child: IgnorePointer(
key: const Key('home-feed-content-ignore'),
ignoring: !widget.visible || _scrubbing || widget.boosting,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: _bottomInfo()),
const SizedBox(width: _railGap),
Padding(
padding: const EdgeInsets.only(bottom: _railBottomOffset),
child: _rail(),
),
],
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 20,
child: AnimatedOpacity(
duration: _overlayFadeDuration,
opacity: widget.boosting ? 1 : 0,
child: IgnorePointer(
ignoring: !widget.boosting,
child: _boostHint(),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _FeedProgressBar(
controller: widget.controller,
locked: widget.locked,
height: _progressHeight,
hitHeight: _progressHitHeight,
cancelScrubRequest: widget.cancelScrubRequest,
onScrubStart: _startScrub,
onScrubEnd: _endScrub,
onSeek: widget.onSeek,
),
),
if (_heartBursts.isNotEmpty)
Positioned.fill(
child: IgnorePointer(
child: Stack(
clipBehavior: Clip.none,
children: [
for (final burst in _heartBursts)
Positioned(
left: burst.position.dx,
top: burst.position.dy - 10,
child: PlayerDoubleTapHeartBurst(
key: ValueKey(burst.id),
onCompleted: () => _removeHeartBurst(burst.id),
),
),
],
),
),
),
if (widget.visible && !widget.boosting)
Positioned(right: 10, bottom: 56, child: _rail()),
],
),
);
}
void _startScrub() {
if (!mounted) return;
_wasVisibleBeforeScrub = widget.visible;
if (!_scrubbing) setState(() => _scrubbing = true);
widget.onScrubVisibilityChanged?.call(true);
widget.onScrubStart?.call();
}
void _endScrub() {
if (!mounted) return;
final wasVisible = _wasVisibleBeforeScrub;
if (_scrubbing) setState(() => _scrubbing = false);
widget.onScrubVisibilityChanged?.call(false);
widget.onScrubEnd?.call(wasVisible);
}
void _handleTapUp(TapUpDetails details) {
final now = DateTime.now();
final lastTap = _lastTapAt;
if (_singleTapTimer != null &&
lastTap != null &&
now.difference(lastTap) <= _singleTapDelay) {
_singleTapTimer?.cancel();
_singleTapTimer = null;
_lastTapAt = null;
_showHearts(details.localPosition);
return;
}
_lastTapAt = now;
_singleTapTimer?.cancel();
_singleTapTimer = Timer(_singleTapDelay, () {
_singleTapTimer = null;
_lastTapAt = null;
if (mounted) widget.onTogglePlay();
});
}
void _showHearts(Offset position) {
final id = _nextHeartBurstId++;
setState(() => _heartBursts.add((id: id, position: position)));
widget.onDoubleTapLike?.call(position);
}
void _removeHeartBurst(int id) {
if (!mounted) return;
setState(() => _heartBursts.removeWhere((burst) => burst.id == id));
}
void _handleLongPressStart(LongPressStartDetails details, double width) {
_singleTapTimer?.cancel();
_singleTapTimer = null;
_lastTapAt = null;
final third = width / 3;
final dx = details.localPosition.dx;
if (dx < third || dx > width - third) {
@@ -179,244 +322,24 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
}
Widget _rail() {
return InteractionRail(
items: [
InteractionRailItem(
key: const Key('rail-save'),
asset: 'assets/icon/rail/rail_save.png',
label: widget.l10n.favorite,
count: widget.favoriteCount,
countKey: const Key('rail-save-count'),
selected: widget.saved,
onTap: widget.onSave,
),
InteractionRailItem(
key: const Key('rail-comments'),
asset: 'assets/icon/rail/rail_comment.png',
label: widget.l10n.comments,
count: widget.commentCount,
countKey: const Key('rail-comments-count'),
onTap: widget.onComments,
),
InteractionRailItem(
key: const Key('rail-like'),
asset: 'assets/icon/rail/rail_like.png',
label: widget.l10n.like,
count: widget.likeCount,
countKey: const Key('rail-like-count'),
selected: widget.liked,
onTap: widget.onLike,
),
InteractionRailItem(
key: const Key('rail-share'),
asset: 'assets/icon/rail/rail_share.png',
label: widget.l10n.share,
onTap: widget.onShare,
),
],
return HomeFeedActionRail(
key: const Key('home-feed-action-rail'),
saved: widget.saved,
liked: widget.liked,
favoriteCount: widget.favoriteCount,
likeCount: widget.likeCount,
onSave: widget.onSave,
onLike: widget.onLike,
onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer,
);
}
Widget _bottomInfo() {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 56, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_seriesMetaRow(),
const SizedBox(height: 2),
_titleRow(),
const SizedBox(height: 6),
_tagsRow(),
if (widget.authorNote.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'${widget.l10n.authorNoteLabel}: ${widget.authorNote}',
key: const Key('home-feed-author-note'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
],
),
);
}
Widget _seriesMetaRow() {
return GestureDetector(
key: const Key('home-feed-series-meta'),
onTap: widget.onDetail,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
widget.l10n.seriesMeta(
'${widget.drama.episodeCount}',
formatInteractionCount(widget.drama.viewCount),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
fontSize: _metaFontSize,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white70,
size: 16,
),
],
),
);
}
Widget _titleRow() {
return GestureDetector(
onTap: widget.onDetail,
child: Row(
children: [
Flexible(
child: Text(
localizedDramaTitle(widget.l10n, widget.drama),
key: const Key('home-feed-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: Colors.white,
fontSize: _titleFontSize,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
size: 20,
),
],
),
);
}
Widget _tagsRow() {
final tags = [
widget.l10n.episodeLabel(widget.episode.number),
..._tagValues(),
].take(_maxTags).toList();
return Wrap(
key: const Key('home-feed-tags'),
spacing: 6,
runSpacing: 4,
children: [
for (final tag in tags)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _overlayPill,
borderRadius: BorderRadius.circular(6),
),
child: Text(
tag,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: _tagFontSize,
),
),
),
],
);
}
List<String> _tagValues() {
final result = <String>[];
for (final value in [widget.drama.genre, widget.drama.category]) {
for (final part in value.split(_tagSplitter)) {
final tag = part.trim();
if (tag.isEmpty || result.contains(tag)) continue;
result.add(tag);
}
}
return result;
}
Widget _fullSeriesBar() {
return GestureDetector(
key: const Key('home-feed-full-series'),
onTap: widget.onOpenFullPlayer,
child: SizedBox(
height: _fullBarHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Container(
key: const Key('home-feed-full-series-bg'),
color: _fullBarBackground,
),
),
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 14,
),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.l10n.watchFullSeries(
'${widget.drama.episodeCount}',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
size: 18,
),
],
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _FeedProgressBar(
controller: widget.controller,
locked: widget.locked,
height: _progressHeight,
hitHeight: _progressHitHeight,
onScrubStart: widget.onScrubStart,
onSeek: widget.onSeek,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
posterUrl: widget.drama.posterUrl,
),
),
],
),
),
return HomeFeedInfoPanel(
drama: widget.drama,
episode: widget.episode,
onDetail: widget.onDetail,
onOpenFullPlayer: widget.onOpenFullPlayer,
);
}
}
@@ -6,6 +6,9 @@ const double _thumbSize = 6;
const double _thumbActiveSize = 14;
const double _trackActiveHeight = 6;
const double _lineCenterFromBottom = 8;
const Duration _trackResizeDuration = Duration(milliseconds: 120);
const double _timeBubbleWidth = 96;
const double _timeBubbleMargin = 12;
const Key _inactiveTrackKey = Key('home-feed-progress-inactive');
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
@@ -15,26 +18,20 @@ class _FeedProgressBar extends StatefulWidget {
required this.locked,
required this.height,
required this.hitHeight,
this.cancelScrubRequest = 0,
this.onScrubStart,
this.onScrubEnd,
this.onSeek,
this.thumbnailService,
this.spriteAxisService,
this.thumbnailsUrl,
required this.episodeId,
required this.posterUrl,
});
final ValueListenable<VideoPlayerValue>? controller;
final bool locked;
final double height;
final double hitHeight;
final int cancelScrubRequest;
final VoidCallback? onScrubStart;
final VoidCallback? onScrubEnd;
final ValueChanged<Duration>? onSeek;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
final String? thumbnailsUrl;
final String episodeId;
final String posterUrl;
@override
State<_FeedProgressBar> createState() => _FeedProgressBarState();
@@ -46,6 +43,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
double? _preview;
Timer? _bufferTimer;
bool _buffering = false;
bool _dragging = false;
@override
void initState() {
@@ -61,15 +59,18 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
_bufferTimer?.cancel();
_bufferTimer = null;
_buffering = false;
widget.thumbnailService?.cancelPending();
_attachSource();
}
if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) {
_dragging = false;
_preview = null;
}
if (oldWidget.locked != widget.locked) {
_bufferTimer?.cancel();
_bufferTimer = null;
_buffering = false;
if (widget.locked) {
widget.thumbnailService?.cancelPending();
_dragging = false;
_preview = null;
} else {
_syncBufferState(notify: false);
@@ -81,7 +82,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
void dispose() {
_detachSource(widget.controller);
_bufferTimer?.cancel();
if (_preview != null) widget.thumbnailService?.cancelPending();
super.dispose();
}
@@ -91,7 +91,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
builder: (context, constraints) {
final width = constraints.maxWidth;
final source = widget.controller;
final scrubbing = _preview != null;
final scrubbing = _dragging;
final loading = !widget.locked && !scrubbing && _buffering;
final track = widget.locked
? _inactiveTrack()
@@ -118,49 +118,42 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
!loading &&
(source?.value.isInitialized ?? false);
final video = source?.value;
final previewPosition = _preview == null || video == null
final previewPosition = !_dragging || _preview == null || video == null
? null
: seekPositionForFraction(_preview!, video.duration);
final showPreview =
previewPosition != null &&
widget.thumbnailService != null &&
widget.episodeId.isNotEmpty;
return GestureDetector(
key: const Key('home-feed-progress'),
final showPreview = previewPosition != null;
return Listener(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: interactive
? (details) {
widget.onScrubStart?.call();
_update(details.localPosition.dx / width);
}
: null,
onHorizontalDragUpdate: interactive
? (details) => _update(details.localPosition.dx / width)
: null,
onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null,
onHorizontalDragCancel: interactive ? _cancelPreview : null,
child: Stack(
clipBehavior: Clip.none,
children: [
track,
if (showPreview)
Positioned(
left: scrubPreviewLeft(
trackWidth: width,
fraction: _preview!,
onPointerUp: (_) => _finishScrub(),
onPointerCancel: (_) => _finishScrub(),
child: GestureDetector(
key: const Key('home-feed-progress'),
behavior: HitTestBehavior.opaque,
dragStartBehavior: DragStartBehavior.down,
onHorizontalDragStart: interactive
? (details) => _startScrub(details.localPosition.dx / width)
: null,
onHorizontalDragUpdate: interactive
? (details) => _update(details.localPosition.dx / width)
: null,
onHorizontalDragEnd: interactive ? (_) => _finishScrub() : null,
onHorizontalDragCancel: interactive ? _finishScrub : null,
child: Stack(
clipBehavior: Clip.none,
children: [
track,
if (showPreview)
Positioned(
left: _timeBubbleLeft(width, _preview!),
bottom: _lineCenterFromBottom + 16,
child: _ScrubTimeBubble(
key: const Key('scrub-preview-time-bubble'),
position: previewPosition,
duration: video!.duration,
),
),
bottom: widget.hitHeight + 8,
child: ScrubPreviewCard(
service: widget.thumbnailService!,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.thumbnailsUrl,
episodeId: widget.episodeId,
posterUrl: widget.posterUrl,
position: previewPosition,
duration: video!.duration,
),
),
],
],
),
),
);
},
@@ -251,7 +244,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
bottom: bottom,
child: SizedBox(
height: widget.height,
child: ColoredBox(key: _inactiveTrackKey, color: Colors.white24),
child: ColoredBox(
key: _inactiveTrackKey,
color: AppColors.border,
),
),
),
],
@@ -259,36 +255,43 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
);
}
void _startScrub(double fraction) {
if (!mounted || _dragging) return;
setState(() {
_dragging = true;
_preview = fraction.clamp(0.0, 1.0).toDouble();
});
widget.onScrubStart?.call();
}
void _update(double fraction) {
if (!mounted || !_dragging) return;
setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
}
void _commit(double? fraction) {
void _finishScrub() {
if (!mounted || !_dragging) return;
final fraction = _preview;
setState(() {
_dragging = false;
_preview = null;
});
widget.onScrubEnd?.call();
if (widget.locked || fraction == null) return;
final total = widget.controller?.value.duration.inMilliseconds ?? 0;
if (total > 0) {
widget.onSeek?.call(
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
);
}
widget.thumbnailService?.cancelPending();
setState(() => _preview = null);
}
void _cancelPreview() {
widget.thumbnailService?.cancelPending();
setState(() => _preview = null);
if (total <= 0) return;
widget.onSeek?.call(Duration(milliseconds: (total * fraction).round()));
}
Widget _track(double fraction, double width) {
final dragging = _preview != null;
final trackHeight = dragging ? _trackActiveHeight : widget.height;
final size = dragging ? _thumbActiveSize : _thumbSize;
final left = (width * fraction - size / 2)
.clamp(0.0, width - size)
final thumbSlotLeft = (width * fraction - _thumbActiveSize / 2)
.clamp(0.0, width - _thumbActiveSize)
.toDouble();
final lineBottom = _lineCenterFromBottom - trackHeight / 2;
final thumbBottom = _lineCenterFromBottom - size / 2;
final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2;
final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2;
return SizedBox(
height: widget.hitHeight,
child: Stack(
@@ -297,32 +300,53 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
Positioned(
left: 0,
right: 0,
bottom: lineBottom,
child: Container(
key: const Key('home-feed-progress-track'),
height: trackHeight,
color: Colors.white24,
bottom: trackSlotBottom,
child: SizedBox(
height: _trackActiveHeight,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
key: const Key('home-feed-progress-track'),
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: width,
height: trackHeight,
color: AppColors.border,
),
),
Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: width * fraction,
height: trackHeight,
color: AppColors.blue,
),
),
],
),
),
),
Positioned(
left: 0,
bottom: lineBottom,
child: Container(
width: width * fraction,
height: trackHeight,
color: AppColors.blue,
),
),
Positioned(
left: left,
bottom: thumbBottom,
child: Container(
key: const Key('home-feed-progress-thumb'),
width: size,
height: size,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
left: thumbSlotLeft,
bottom: thumbSlotBottom,
child: SizedBox.square(
dimension: _thumbActiveSize,
child: Center(
child: AnimatedContainer(
key: const Key('home-feed-progress-thumb'),
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: size,
height: size,
decoration: const BoxDecoration(
color: AppColors.onPrimary,
shape: BoxShape.circle,
),
),
),
),
),
@@ -330,4 +354,56 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
),
);
}
double _timeBubbleLeft(double width, double fraction) {
final maxLeft = width - _timeBubbleWidth - _timeBubbleMargin;
if (maxLeft < _timeBubbleMargin) {
return ((width - _timeBubbleWidth) / 2)
.clamp(0.0, width - _timeBubbleWidth)
.toDouble();
}
return (width * fraction - _timeBubbleWidth / 2)
.clamp(_timeBubbleMargin, maxLeft)
.toDouble();
}
}
class _ScrubTimeBubble extends StatelessWidget {
const _ScrubTimeBubble({
super.key,
required this.position,
required this.duration,
});
final Duration position;
final Duration duration;
@override
Widget build(BuildContext context) {
return Container(
width: _timeBubbleWidth,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: AppColors.timeBubbleScrim,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${_formatTime(position)} / ${_formatTime(duration)}',
key: const Key('scrub-preview-time'),
textAlign: TextAlign.center,
maxLines: 1,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
String _formatTime(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
}
@@ -0,0 +1,444 @@
// Author: fruit.com.cn
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart';
const double _headlineFont = 26;
const double _seriesFont = 19;
const double _tagFont = 13;
const double _synopsisFont = 13;
const double _watchAllHeight = 44;
const double _actionGap = 28;
const double _actionIcon = 32;
const double _actionWidth = 64;
const List<Shadow> _textShadows = [
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
];
final RegExp _tagSplitter = RegExp(r'[,/、·]');
class HomeFeedInfoPanel extends StatefulWidget {
const HomeFeedInfoPanel({
super.key,
required this.drama,
required this.episode,
required this.onDetail,
required this.onOpenFullPlayer,
});
final Drama drama;
final Episode episode;
final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer;
@override
State<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState();
}
class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
bool _synopsisExpanded = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
final episodeTitle = widget.episode.title.trim();
final headline = _headline(l10n, dramaTitle, episodeTitle);
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
final genres = _genreValues();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
key: const Key('home-feed-title-open'),
behavior: HitTestBehavior.opaque,
onTap: widget.onOpenFullPlayer,
child: Text(
headline,
key: const Key('home-feed-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _headlineFont,
fontWeight: FontWeight.w700,
height: 1.2,
letterSpacing: 0,
shadows: _textShadows,
),
),
),
const SizedBox(height: 8),
GestureDetector(
key: const Key('home-feed-series-name'),
behavior: HitTestBehavior.opaque,
onTap: widget.onOpenFullPlayer,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
dramaTitle,
key: const Key('home-feed-series-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _seriesFont,
fontWeight: FontWeight.w600,
shadows: _textShadows,
),
),
),
const SizedBox(width: 4),
const Icon(
Icons.chevron_right_rounded,
size: 20,
color: AppColors.textPrimary,
),
],
),
),
const SizedBox(height: 8),
_tagMetaRow(l10n, genres),
if (synopsis.isNotEmpty) ...[
const SizedBox(height: 8),
_synopsis(l10n, synopsis),
],
const SizedBox(height: 12),
_watchAll(context, l10n),
],
);
}
String _headline(
AppLocalizations l10n,
String dramaTitle,
String episodeTitle,
) {
if (episodeTitle.isEmpty) return dramaTitle;
final normalized = episodeTitle.toLowerCase().replaceAll(' ', '');
final localizedEpisode = l10n
.episodeLabel(widget.episode.number)
.toLowerCase()
.replaceAll(' ', '');
final episodeOnly = RegExp(r'^(第?\d+集|episode\d+)$').hasMatch(normalized);
return normalized == localizedEpisode || episodeOnly
? dramaTitle
: episodeTitle;
}
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
return Row(
key: const Key('home-feed-tags'),
children: [
if (isHotDrama(widget.drama)) ...[
Container(
key: const Key('home-feed-hot-badge'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(3),
),
child: Text(
l10n.homeBadgeHot,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
],
Flexible(
fit: FlexFit.loose,
child: Text(
genres.join(' '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.overlayLabel,
fontSize: _tagFont,
),
),
),
const SizedBox(width: 8),
Text(
l10n.favoriteEpisodeProgress(
widget.episode.number,
widget.drama.episodeCount,
),
key: const Key('home-feed-episode-progress'),
maxLines: 1,
softWrap: false,
style: const TextStyle(
color: AppColors.overlayLabel,
fontSize: _tagFont,
),
),
],
);
}
Widget _synopsis(AppLocalizations l10n, String synopsis) {
const style = TextStyle(
color: AppColors.overlaySecondary,
fontSize: _synopsisFont,
height: 1.35,
shadows: _textShadows,
);
return LayoutBuilder(
builder: (context, constraints) {
final painter = TextPainter(
text: TextSpan(text: synopsis, style: style),
maxLines: 2,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: constraints.maxWidth);
final overflows = painter.didExceedMaxLines;
if (!overflows) {
return Text(
synopsis,
key: const Key('home-feed-synopsis'),
style: style,
);
}
if (_synopsisExpanded) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
synopsis,
key: const Key('home-feed-synopsis'),
style: style,
),
const SizedBox(height: 2),
_synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.showLess,
),
],
);
}
return Stack(
children: [
Padding(
padding: const EdgeInsets.only(right: 40),
child: Text(
synopsis,
key: const Key('home-feed-synopsis'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: style,
),
),
Positioned(
right: 0,
bottom: 0,
child: _synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.expandSynopsis,
),
),
],
);
},
);
}
Widget _synopsisToggle({required Key key, required String label}) {
return GestureDetector(
key: key,
onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded),
child: Text(
label,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _synopsisFont,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _watchAll(BuildContext context, AppLocalizations l10n) {
final screenWidth = MediaQuery.sizeOf(context).width;
return LayoutBuilder(
builder: (context, constraints) {
final buttonWidth = math.min(screenWidth * 0.7, constraints.maxWidth);
return Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: buttonWidth,
height: _watchAllHeight,
child: DecoratedBox(
key: const Key('home-feed-full-series-bg'),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.actionRed, AppColors.blue],
),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: AppColors.transparent,
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: widget.onOpenFullPlayer,
borderRadius: BorderRadius.circular(8),
child: Center(
child: Text(
l10n.homeWatchAll,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
);
},
);
}
List<String> _genreValues() {
final result = <String>[];
for (final value in [widget.drama.genre, widget.drama.category]) {
for (final part in value.split(_tagSplitter)) {
final tag = part.trim();
if (tag.isEmpty || result.contains(tag)) continue;
result.add(tag);
}
}
return result.take(3).toList(growable: false);
}
}
class HomeFeedActionRail extends StatelessWidget {
const HomeFeedActionRail({
super.key,
required this.saved,
required this.liked,
required this.onSave,
required this.onLike,
required this.onEpisodes,
this.favoriteCount,
this.likeCount,
});
final bool saved;
final bool liked;
final int? favoriteCount;
final int? likeCount;
final VoidCallback onSave;
final VoidCallback onLike;
final VoidCallback onEpisodes;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
width: _actionWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_action(
key: const Key('rail-save'),
icon: saved
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
label: favoriteCount == null
? l10n.favorite
: formatInteractionCount(favoriteCount!),
labelKey: const Key('rail-save-count'),
onTap: onSave,
),
const SizedBox(height: _actionGap),
_action(
key: const Key('rail-like'),
icon: liked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
iconColor: liked
? AppColors.doubleTapHeartRed
: AppColors.textPrimary,
label: likeCount == null
? l10n.like
: formatInteractionCount(likeCount!),
labelKey: const Key('rail-like-count'),
onTap: onLike,
),
const SizedBox(height: _actionGap),
_action(
key: const Key('rail-episodes'),
icon: Icons.layers_rounded,
label: l10n.episodes,
labelKey: const Key('rail-episodes-label'),
labelFontSize: 11,
onTap: onEpisodes,
),
],
),
);
}
Widget _action({
required Key key,
required IconData icon,
required String label,
required Key labelKey,
required VoidCallback onTap,
Color iconColor = AppColors.textPrimary,
double labelFontSize = 12,
}) {
return GestureDetector(
key: key,
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: _actionIcon,
color: iconColor,
shadows: const [
Shadow(
color: AppColors.shadowSoft,
blurRadius: 4,
offset: Offset(0, 1),
),
],
),
const SizedBox(height: 2),
Text(
label,
key: labelKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textPrimary,
fontSize: labelFontSize,
fontWeight: FontWeight.w500,
height: 1,
shadows: _textShadows,
),
),
],
),
);
}
}
@@ -19,6 +19,7 @@ class InteractionRailItem {
this.count,
this.countKey,
this.selected = false,
this.selectedColor = AppColors.blue,
});
final Key key;
@@ -28,6 +29,7 @@ class InteractionRailItem {
final int? count;
final Key? countKey;
final bool selected;
final Color selectedColor;
}
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
@@ -71,7 +73,7 @@ class _RailButton extends StatelessWidget {
width: _iconSize,
height: _iconSize,
fit: BoxFit.contain,
color: item.selected ? AppColors.blue : Colors.white,
color: item.selected ? item.selectedColor : AppColors.onPrimary,
colorBlendMode: BlendMode.srcIn,
),
),
@@ -83,11 +85,11 @@ class _RailButton extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
color: AppColors.onPrimary,
height: 1,
fontSize: 12,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
shadows: [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
),
),
],
@@ -2,13 +2,15 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// 播放未就绪时沿进度轨道循环移动的加载高光。
class LoadingProgressTrack extends StatefulWidget {
const LoadingProgressTrack({
super.key = const Key('progress-loading'),
required this.height,
this.trackColor = Colors.white24,
this.highlightColor = Colors.white,
this.trackColor = AppColors.inactiveTrack,
this.highlightColor = AppColors.onPrimary,
});
final double height;
@@ -6,9 +6,9 @@ import '../../../core/theme/app_colors.dart';
const double _toggleSize = 68;
const double _iconSize = 36;
const Color _toggleBackground = Colors.black26;
const Color _toggleBackground = AppColors.controlSoftScrim;
const List<Shadow> _iconShadows = [
Shadow(color: Colors.black54, blurRadius: 12),
Shadow(color: AppColors.shadow, blurRadius: 12),
];
/// 首页沉浸流与全屏播放器共用的播放/暂停按钮。
@@ -0,0 +1,132 @@
// Author: fruit.com.cn
import 'dart:math';
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
class PlayerDoubleTapHeartBurst extends StatefulWidget {
const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted});
final VoidCallback onCompleted;
@override
State<PlayerDoubleTapHeartBurst> createState() =>
_PlayerDoubleTapHeartBurstState();
}
class _PlayerDoubleTapHeartBurstState extends State<PlayerDoubleTapHeartBurst>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final List<_HeartParticle> _particles;
bool _completed = false;
@override
void initState() {
super.initState();
final random = Random();
var delay = 0;
_particles = List.generate(6 + random.nextInt(3), (index) {
if (index > 0) delay += 40 + random.nextInt(21);
return _HeartParticle(
delay: delay,
duration: 700 + random.nextInt(201),
travel: 120 + random.nextInt(41),
drift: -30 + random.nextInt(61),
size: (22 + random.nextInt(13)).toDouble(),
color: random.nextBool()
? AppColors.doubleTapHeartRed
: AppColors.doubleTapHeartPink,
);
});
final total = _particles
.map((particle) => particle.delay + particle.duration)
.reduce(max);
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: total),
)..addStatusListener(_onStatus);
_controller.forward();
}
void _onStatus(AnimationStatus status) {
if (status != AnimationStatus.completed || _completed) return;
_completed = true;
widget.onCompleted();
}
@override
void dispose() {
_controller
..removeStatusListener(_onStatus)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: SizedBox.shrink(
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final elapsed =
_controller.value * _controller.duration!.inMilliseconds;
return Stack(
clipBehavior: Clip.none,
children: [
for (final particle in _particles) _particle(particle, elapsed),
],
);
},
),
),
);
}
Widget _particle(_HeartParticle particle, double elapsed) {
final progress = ((elapsed - particle.delay) / particle.duration).clamp(
0.0,
1.0,
);
if (progress <= 0) return const SizedBox.shrink();
final move = Curves.easeOutCubic.transform(progress);
final scale = progress < 0.3
? 0.55 + (1.15 - 0.55) * (progress / 0.3)
: 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7);
return Positioned(
left: particle.drift * move - particle.size / 2,
top: -particle.travel * move,
child: Opacity(
opacity: 1 - Curves.easeIn.transform(progress),
child: Transform.scale(
scale: scale,
child: Icon(
Icons.favorite_rounded,
color: particle.color,
size: particle.size,
),
),
),
);
}
}
class _HeartParticle {
const _HeartParticle({
required this.delay,
required this.duration,
required this.travel,
required this.drift,
required this.size,
required this.color,
});
final int delay;
final int duration;
final int travel;
final int drift;
final double size;
final Color color;
}
@@ -271,7 +271,7 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
}) {
return Material(
key: key,
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
@@ -347,7 +347,7 @@ class _SegmentButton extends StatelessWidget {
? AppColors.muted
: AppColors.subtle;
return Material(
color: Colors.transparent,
color: AppColors.transparent,
borderRadius: BorderRadius.circular(_segmentRadius),
child: InkWell(
onTap: onTap,
@@ -357,12 +357,12 @@ class _SegmentButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: _segmentPadding),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.card : Colors.transparent,
color: selected ? AppColors.card : AppColors.transparent,
borderRadius: BorderRadius.circular(_segmentRadius),
boxShadow: selected
? const [
BoxShadow(
color: Color(0x14000000),
color: AppColors.shadowSubtle,
blurRadius: 4,
offset: Offset(0, 1),
),
+4 -3
View File
@@ -18,7 +18,7 @@ const double _topBarHeight = 58;
const double _boostHintIconSize = 16;
const double _boostHintFontSize = 13;
const List<Shadow> _playerTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
];
/// K29 竖屏播放页浮层。
@@ -170,6 +170,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
count: data.likeCount,
countKey: const Key('player-rail-like-count'),
selected: data.liked,
selectedColor: AppColors.doubleTapHeartRed,
onTap: actions.onLike,
),
InteractionRailItem(
@@ -270,7 +271,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
thumbnailsUrl: data.episode.thumbnailsUrl,
episodeId: data.episode.id,
posterUrl: data.drama.posterUrl,
inactiveTrackColor: Colors.white24,
inactiveTrackColor: AppColors.inactiveTrack,
onScrubStart: actions.onScrubStart,
onSeek: actions.onSeek,
),
@@ -279,7 +280,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
Widget _detailRow(String series) {
return Material(
color: Colors.transparent,
color: AppColors.transparent,
child: InkWell(
key: const Key('player-open-detail'),
onTap: actions.onDetail,
@@ -22,7 +22,7 @@ class _TopButton extends StatelessWidget {
tooltip: tooltip,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0x80000000),
backgroundColor: AppColors.controlScrim,
foregroundColor: AppColors.onPrimary,
),
onPressed: onPressed,
@@ -41,7 +41,7 @@ class _SpeedEntry extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0x80000000),
color: AppColors.controlScrim,
borderRadius: BorderRadius.circular(999),
child: InkWell(
onTap: onTap,
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart';
const Duration _videoFadeDuration = Duration(milliseconds: 200);
const double _errorHorizontal = 32;
const int _errorMaxLines = 3;
@@ -55,7 +57,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
children: [
const ColoredBox(
key: Key('player-fallback-background'),
color: Colors.black,
color: AppColors.mediaBlack,
),
AnimatedOpacity(
key: const Key('player-video-opacity'),
@@ -81,7 +83,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
maxLines: _errorMaxLines,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70, fontSize: 13),
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
),
),
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more