Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20e7ba7c5 | ||
|
|
8375d8b255 | ||
|
|
972e542e5c | ||
|
|
e11f55777e | ||
|
|
325f7c1f5d | ||
|
|
da69a058b4 | ||
|
|
1adcc16727 | ||
|
|
5da836dded | ||
|
|
f7782aef14 | ||
|
|
c9fce7745e | ||
|
|
e16faa258a | ||
|
|
3abf69e886 | ||
|
|
ce310afa89 | ||
|
|
d3fdb3c631 | ||
|
|
9543d850c8 | ||
|
|
81dd33e8c7 | ||
|
|
4f6116896f | ||
|
|
7446431511 | ||
|
|
aa4870cce6 | ||
|
|
10477562d4 | ||
|
|
d7a59e5a27 | ||
|
|
d03dcf162e | ||
|
|
9cf832b318 | ||
|
|
5cead7ce00 | ||
|
|
79bdef4b43 | ||
|
|
cbc6d80cd4 | ||
|
|
5aa8170951 | ||
|
|
d06967c6ab | ||
|
|
af6b9bb3db | ||
|
|
97e3f4fc47 | ||
|
|
73380ee090 | ||
|
|
f9aec827cf | ||
|
|
f1b5418954 | ||
|
|
0ef2438100 | ||
|
|
e40f4779c0 | ||
|
|
83129acae4 | ||
|
|
47bf6458e8 | ||
|
|
1bb227e083 | ||
|
|
dd96792e33 | ||
|
|
6dea9d479e | ||
|
|
5362f97744 | ||
|
|
81787dc3aa | ||
|
|
667fdc5f0a | ||
|
|
729c226c95 | ||
|
|
91e869d589 | ||
|
|
ec3cb8f163 | ||
|
|
4bc04fd8ae | ||
|
|
295c21513c | ||
|
|
5b2d8445dc | ||
|
|
1924b8626f |
No files matched your search
@@ -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` 漂移。
|
||||
- 不启停服务、不操作真机。
|
||||
@@ -730,3 +730,390 @@ $ 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 首页布局对齐修正
|
||||
|
||||
- 一级分类页内切换 6 个列表,未选 18/w400 `#B0B0B0`、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。
|
||||
- 四类卡片去底板/描边,封面 8 圆角、行距 10、封底标题间距 6;热剧/新剧/漫剧角标为右上角 16 高紧凑异形丝带。
|
||||
- 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。
|
||||
- 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。
|
||||
- 播放量回到封面右下角,使用 spark 图标与 12/w400 纯文字、`w` 格式,并加极淡底部渐变;四类列表共用。
|
||||
- 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。
|
||||
+10
-8
@@ -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(),
|
||||
|
||||
@@ -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>>()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
+112
-43
@@ -2,106 +2,175 @@
|
||||
|
||||
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 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 blue = Color(0xFFFF6B2C);
|
||||
static const primary = blue;
|
||||
|
||||
/// 主操作按下态。
|
||||
static const bluePressed = Color(0xFFFF6A00);
|
||||
static const bluePressed = Color(0xFFE55A1F);
|
||||
|
||||
/// 粉色辅助强调色。
|
||||
static const blueSoft = Color(0xFFFF6A8A);
|
||||
/// 链接与次要高亮(禁止大面积铺色)。
|
||||
static const blueSoft = Color(0xFF22D3EE);
|
||||
static const secondary = blueSoft;
|
||||
static const lavender = blueSoft;
|
||||
|
||||
/// 金币与奖励。
|
||||
static const gold = Color(0xFFF3B34C);
|
||||
static const goldDeep = Color(0xFFD9A23A);
|
||||
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 success = Color(0xFF2FA873);
|
||||
|
||||
/// 收藏和喜欢使用粉色强调。
|
||||
static const favorite = secondary;
|
||||
/// 错误与危险操作。
|
||||
static const error = Color(0xFFFF6B6B);
|
||||
|
||||
/// 收藏与喜欢(非主色铺色,独立强调)。
|
||||
static const favorite = Color(0xFFFF6A8A);
|
||||
|
||||
/// 金币底色上的文字。
|
||||
static const onGold = Color(0xFF5A3511);
|
||||
static const onGold = Color(0xFF3D2408);
|
||||
|
||||
/// 主色上的文字与图标。
|
||||
static const onPrimary = Color(0xFFFFFFFF);
|
||||
|
||||
/// 页面背景。
|
||||
static const ink = Color(0xFFFFF8F0);
|
||||
static const ink = Color(0xFF0B1020);
|
||||
|
||||
/// 首页/追剧/我的统一的纯黑内容背景。
|
||||
static const appBackground = Color(0xFF000000);
|
||||
|
||||
/// 中性深灰卡片与控件表面。
|
||||
static const contentCard = Color(0xFF171717);
|
||||
static const contentSurface = Color(0xFF1C1C1E);
|
||||
static const contentChip = Color(0xFF2C2C2E);
|
||||
static const contentBorder = Color(0x1AFFFFFF);
|
||||
|
||||
/// 「我的」页卡片与描边。
|
||||
static const mineCard = Color(0xFF16161A);
|
||||
static const mineOutline = Color(0xFF4A4A4A);
|
||||
|
||||
/// 次级文字与列表 chevron。
|
||||
static const textPrimary = Color(0xFFFFFFFF);
|
||||
static const textSecondary = Color(0xFF8E8E93);
|
||||
static const textTertiary = Color(0xFF9E9E9E);
|
||||
static const textTabInactive = Color(0xFFB0B0B0);
|
||||
|
||||
/// 原生开屏与冷启动过渡底色。
|
||||
static const splashInk = Color(0xFF1F1B16);
|
||||
static const splashInk = ink;
|
||||
|
||||
/// 页面顶部暖色渐变起点。
|
||||
static const surfaceTop = Color(0xFFFFE9D6);
|
||||
/// 顶部与页面底同色,靠容器层次区分。
|
||||
static const surfaceTop = ink;
|
||||
|
||||
/// 底部导航底色。
|
||||
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(0xFF151B2E);
|
||||
|
||||
/// 次要卡片表面。
|
||||
static const cardAlt = Color(0xFFFFF4E9);
|
||||
static const cardAlt = card;
|
||||
|
||||
/// 浮层、输入框和嵌套区域。
|
||||
static const raised = Color(0xFFFFF1E5);
|
||||
static const quick = Color(0xFFFFF0E1);
|
||||
static const input = Color(0xFFFFF4EA);
|
||||
/// 浮层、弹窗、输入与嵌套区域。
|
||||
static const raised = Color(0xFF1C2436);
|
||||
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);
|
||||
/// 分割线 rgba(255,255,255,0.06)。
|
||||
static const border = Color(0x0FFFFFFF);
|
||||
|
||||
/// 玻璃面板描边。
|
||||
static const glassBorder = Color(0x66FFFFFF);
|
||||
static const glassBorder = Color(0x33FFFFFF);
|
||||
static const glassSurfaceStart = Color(0xEB1C2436);
|
||||
static const glassSurfaceEnd = Color(0xD1151B2E);
|
||||
|
||||
/// 粉色角标。
|
||||
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 cardBottomScrimStart = Color(0x00000000);
|
||||
static const cardBottomScrimEnd = Color(0x40000000);
|
||||
|
||||
/// 进度条未激活轨道。
|
||||
static const inactiveTrack = Color(0x24FFFFFF);
|
||||
|
||||
/// 横幅/封面底部渐变。
|
||||
static const bannerScrimMid = Color(0x8A0B1020);
|
||||
static const bannerScrimEnd = Color(0xE6000000);
|
||||
|
||||
/// 票券虚线等主色描边。
|
||||
static const primaryBorder = Color(0x66FF6B2C);
|
||||
|
||||
static const outlineDark = border;
|
||||
static const outlineVariantDark = border;
|
||||
|
||||
@@ -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 = 11;
|
||||
static const double navLabelGap = 4;
|
||||
|
||||
static const double buttonHeight = 48;
|
||||
static const double primaryButtonHeight = 52;
|
||||
@@ -45,13 +48,15 @@ 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 = 56;
|
||||
static const double navIcon = 26;
|
||||
static const double progressHeight = 3;
|
||||
static const double progressRadius = 2;
|
||||
static const double progressLineHeight = 3;
|
||||
|
||||
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 +66,80 @@ 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 = 2;
|
||||
static const double cardPlayCountBottom = 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 = 5 / 6;
|
||||
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 = 10;
|
||||
static const double homeSecondaryTagRadius = 13;
|
||||
static const double homePrimaryCategoryGap = 24;
|
||||
static const double homeSecondaryTagGap = 14;
|
||||
static const double homeSearchTopGap = 0;
|
||||
static const double homeSearchPrimaryGap = 8;
|
||||
static const double homePrimarySecondaryGap = 8;
|
||||
static const double homeSecondaryGridGap = 10;
|
||||
static const double homeIconSlot = 44;
|
||||
static const double homeHeaderIcon = 22;
|
||||
|
||||
/// 推荐分区。
|
||||
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 filterChipFont = 13;
|
||||
static const double filterChipHeight = 30;
|
||||
static const double filterChipRadius = 15;
|
||||
static const double followingTabFont = 16;
|
||||
static const double followingGridAspectRatio = 0.5;
|
||||
|
||||
/// 「我的」页中性视觉。
|
||||
static const double mineCardRadius = 16;
|
||||
static const double mineRowHeight = 56;
|
||||
static const double minePageTitle = 17;
|
||||
static const double mineSectionTitle = 15;
|
||||
static const double mineAvatar = 56;
|
||||
static const double mineUserName = 20;
|
||||
static const double mineUid = 13;
|
||||
static const double mineMenuIcon = 22;
|
||||
}
|
||||
+140
-24
@@ -6,9 +6,9 @@ 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,
|
||||
@@ -16,36 +16,50 @@ abstract final class AppTheme {
|
||||
stops: [0, 0.24],
|
||||
);
|
||||
|
||||
/// 卡片轻阴影。
|
||||
/// 卡片阴影关闭,改用底色与分割线。
|
||||
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 +75,23 @@ abstract final class AppTheme {
|
||||
unselectedLabelColor: AppColors.muted,
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.blue),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.secondary,
|
||||
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,7 +100,9 @@ abstract final class AppTheme {
|
||||
minimumSize: const WidgetStatePropertyAll(
|
||||
Size(0, AppDimens.buttonHeight),
|
||||
),
|
||||
shape: const WidgetStatePropertyAll(StadiumBorder()),
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: _buttonRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
@@ -82,14 +114,46 @@ abstract final class AppTheme {
|
||||
side: const WidgetStatePropertyAll(
|
||||
BorderSide(color: AppColors.border),
|
||||
),
|
||||
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,
|
||||
backgroundColor: AppColors.card,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
elevation: 0,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
scrolledUnderElevation: 0,
|
||||
surfaceTintColor: AppColors.transparent,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
titleTextStyle: TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontFamily: 'Outfit',
|
||||
@@ -98,19 +162,47 @@ abstract final class AppTheme {
|
||||
),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.onSurface),
|
||||
dividerTheme: const DividerThemeData(color: AppColors.border),
|
||||
cardTheme: const CardThemeData(
|
||||
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 +216,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 +239,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);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
@@ -26,53 +24,32 @@ class AppBottomNavItem {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -104,18 +81,22 @@ class _NavDestination extends StatelessWidget {
|
||||
child: Icon(
|
||||
item.selected ? item.selectedIcon : item.icon,
|
||||
size: AppDimens.navIcon,
|
||||
color: item.selected ? AppColors.blue : unselectedColor,
|
||||
color: item.selected
|
||||
? AppColors.navSelected
|
||||
: unselectedColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const SizedBox(height: AppDimens.navLabelGap),
|
||||
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,
|
||||
fontWeight: item.selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: item.selected
|
||||
? AppColors.navSelected
|
||||
: unselectedColor,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'app_bottom_nav.dart';
|
||||
|
||||
/// 客户端根导航的固定顺序。
|
||||
enum AppRootTab { home, recommend, following, mine }
|
||||
|
||||
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.home_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.home_rounded,
|
||||
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,
|
||||
label: tab.label(l10n),
|
||||
selected: tab == selected,
|
||||
onTap: () => onSelected(tab),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.auto_awesome_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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,9 +16,6 @@ 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);
|
||||
|
||||
/// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。
|
||||
@@ -52,14 +49,14 @@ class AppSubpageCircleButton extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: dark ? _darkShadowColor : _lightShadowColor,
|
||||
color: dark ? AppColors.shadowSoft : AppColors.shadowSubtle,
|
||||
blurRadius: _shadowBlur,
|
||||
offset: dark ? Offset.zero : _lightShadowOffset,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: dark ? _darkBackground : AppColors.card,
|
||||
color: dark ? AppColors.headerScrim : AppColors.card,
|
||||
shape: CircleBorder(
|
||||
side: dark
|
||||
? BorderSide.none
|
||||
@@ -75,7 +72,7 @@ class AppSubpageCircleButton extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Icon(
|
||||
icon,
|
||||
color: dark ? Colors.white : AppColors.onSurface,
|
||||
color: AppColors.onSurface,
|
||||
size: _iconSize,
|
||||
weight: _iconWeight,
|
||||
),
|
||||
@@ -123,6 +120,7 @@ class AppSubpageHeader extends StatelessWidget {
|
||||
this.actions,
|
||||
this.onBack,
|
||||
this.dark = false,
|
||||
this.showBack = true,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -131,11 +129,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.onSurface;
|
||||
final secondary = AppColors.muted;
|
||||
return SizedBox(
|
||||
key: const Key('subpage-header'),
|
||||
height: subtitle == null ? _height : _heightWithSubtitle,
|
||||
@@ -143,8 +142,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,
|
||||
|
||||
+55
-4
@@ -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",
|
||||
@@ -697,5 +695,58 @@
|
||||
"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" } } },
|
||||
"confirmRemove": "Remove this item?"
|
||||
}
|
||||
+55
-4
@@ -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.",
|
||||
@@ -654,5 +652,58 @@
|
||||
"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" } } },
|
||||
"confirmRemove": "Hapus item ini?"
|
||||
}
|
||||
+277
-13
@@ -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.
|
||||
@@ -2997,6 +2985,282 @@ 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 @confirmRemove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove this item?'**
|
||||
String get confirmRemove;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -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.';
|
||||
@@ -1565,4 +1559,146 @@ 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 confirmRemove => 'Remove this item?';
|
||||
}
|
||||
@@ -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';
|
||||
@@ -1565,4 +1559,148 @@ 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 confirmRemove => 'Hapus item ini?';
|
||||
}
|
||||
@@ -256,9 +256,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get myTab => '我的';
|
||||
|
||||
@override
|
||||
String get localViewer => '本地观众';
|
||||
|
||||
@override
|
||||
String get localDemoProfile => '本地演示档案';
|
||||
|
||||
@@ -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 => '漫剧';
|
||||
@@ -1541,4 +1535,146 @@ 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 confirmRemove => '确定移除这条内容吗?';
|
||||
}
|
||||
+55
-4
@@ -94,7 +94,6 @@
|
||||
"emptyHint": "有新内容时会显示在这里。",
|
||||
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
|
||||
"myTab": "我的",
|
||||
"localViewer": "本地观众",
|
||||
"localDemoProfile": "本地演示档案",
|
||||
"continueWatching": "继续观看",
|
||||
"episodeRemaining": "第 {episode} 集 · 还剩 {minutes} 分钟",
|
||||
@@ -132,14 +131,13 @@
|
||||
},
|
||||
"resume": "继续播放",
|
||||
"history": "历史",
|
||||
"watchlist": "收藏",
|
||||
"likedTab": "喜欢",
|
||||
"all": "全部",
|
||||
"inProgress": "未看完",
|
||||
"completed": "已看完",
|
||||
"noSavedShows": "这里还没有剧集。",
|
||||
"preferences": "偏好设置",
|
||||
"followingTab": "关注",
|
||||
"followingTab": "追剧",
|
||||
"animatedTab": "漫剧",
|
||||
"recommendedTab": "推荐",
|
||||
"noCategoryDramas": "这个分类暂时没有剧集。",
|
||||
@@ -616,5 +614,58 @@
|
||||
"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" } } },
|
||||
"confirmRemove": "确定移除这条内容吗?"
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
@@ -375,7 +375,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 +418,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 +433,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),
|
||||
|
||||
@@ -330,7 +330,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,
|
||||
|
||||
@@ -44,14 +44,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 +93,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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -264,7 +276,7 @@ class _FavoriteCard extends StatelessWidget {
|
||||
boxShadow: const [AppTheme.cardShadow],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(_cardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// 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.tags,
|
||||
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 List<String> tags;
|
||||
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.cardCoverRadius),
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
key: Key('following-cover-$dramaId'),
|
||||
aspectRatio: AppDimens.cardCoverAspectRatio,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.cardCoverRadius,
|
||||
),
|
||||
child: RemoteImage(
|
||||
posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
fallbackColor: AppColors.contentSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (editing)
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: GestureDetector(
|
||||
key: Key('following-remove-$dramaId'),
|
||||
onTap: onRemove,
|
||||
child: const CircleAvatar(
|
||||
radius: 13,
|
||||
backgroundColor: AppColors.contentChip,
|
||||
child: Icon(
|
||||
Icons.remove_rounded,
|
||||
color: AppColors.textPrimary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CoverBottomScrim(),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: AppDimens.cardPlayCountBottom,
|
||||
child: CoverPlayCount(
|
||||
key: Key('following-views-$dramaId'),
|
||||
count: viewCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
if (tags.isNotEmpty) ...[
|
||||
const SizedBox(height: AppDimens.cardTitleTagGap),
|
||||
_tags(),
|
||||
],
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTertiary,
|
||||
fontSize: AppDimens.cardViewsFont,
|
||||
),
|
||||
),
|
||||
if (progress != null) ...[
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.progressRadius),
|
||||
child: LinearProgressIndicator(
|
||||
key: Key('following-progress-$dramaId'),
|
||||
value: progress!.clamp(0.0, 1.0).toDouble(),
|
||||
minHeight: 2,
|
||||
backgroundColor: AppColors.inactiveTrack,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tags() {
|
||||
final visible = tags.take(3).toList(growable: false);
|
||||
return Text(
|
||||
visible.join(' | '),
|
||||
key: Key('following-tags-$dramaId'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTertiary,
|
||||
fontSize: AppDimens.cardTagFont,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.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/app_pull_to_refresh.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/paged_list_footer.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../player/open_player.dart';
|
||||
import 'following_grid_card.dart';
|
||||
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
enum FollowingTab { favorites, history, reserve }
|
||||
|
||||
class FollowingPage extends StatefulWidget {
|
||||
const FollowingPage({
|
||||
super.key,
|
||||
required this.onGoHome,
|
||||
required this.favoriteController,
|
||||
});
|
||||
|
||||
final VoidCallback onGoHome;
|
||||
final FavoriteController favoriteController;
|
||||
|
||||
@override
|
||||
State<FollowingPage> createState() => _FollowingPageState();
|
||||
}
|
||||
|
||||
class _FollowingPageState extends State<FollowingPage> {
|
||||
late final WatchRecordsController _history;
|
||||
late final ScrollController _favoriteScroll;
|
||||
late final ScrollController _historyScroll;
|
||||
FollowingTab _tab = FollowingTab.favorites;
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_favoriteScroll = ScrollController();
|
||||
_historyScroll = ScrollController();
|
||||
_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() {
|
||||
_favoriteScroll.dispose();
|
||||
_historyScroll.dispose();
|
||||
_history.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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()
|
||||
: IndexedStack(
|
||||
index: _tab.index,
|
||||
children: [
|
||||
_FavoritesGrid(
|
||||
controller: widget.favoriteController,
|
||||
editing: _editing,
|
||||
scrollController: _favoriteScroll,
|
||||
onListEmptied: _exitEditingIfEmpty,
|
||||
),
|
||||
_HistoryGrid(
|
||||
controller: _history,
|
||||
editing: _editing,
|
||||
scrollController: _historyScroll,
|
||||
onListEmptied: _exitEditingIfEmpty,
|
||||
),
|
||||
_reserveEmpty(l10n),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
final tabs = [
|
||||
(FollowingTab.favorites, l10n.followingTab, 'favorites'),
|
||||
(FollowingTab.history, l10n.watchHistory, 'history'),
|
||||
(FollowingTab.reserve, l10n.reserveTitle, 'reserve'),
|
||||
];
|
||||
return SizedBox(
|
||||
key: const Key('following-header'),
|
||||
height: 52,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.feedPageHorizontal,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final tab in tabs)
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
key: Key('following-tab-${tab.$3}'),
|
||||
onTap: () => setState(() {
|
||||
_tab = tab.$1;
|
||||
_editing = false;
|
||||
}),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
tab.$2,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: _tab == tab.$1
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textTertiary,
|
||||
fontSize: AppDimens.followingTabFont,
|
||||
fontWeight: _tab == tab.$1
|
||||
? FontWeight.w700
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Container(
|
||||
width: _tab == tab.$1 ? 20 : 0,
|
||||
height: 2,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('following-edit'),
|
||||
tooltip: _editing ? l10n.done : l10n.edit,
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
color: AppColors.textPrimary,
|
||||
iconSize: 24,
|
||||
icon: Icon(_editing ? Icons.check_rounded : Icons.edit_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _reserveEmpty(AppLocalizations l10n) {
|
||||
return EmptyState(
|
||||
key: const Key('following-reserve-empty'),
|
||||
icon: Icons.notifications_none_rounded,
|
||||
title: l10n.reserveEmptyTitle,
|
||||
subtitle: l10n.reserveEmptyHint,
|
||||
actionLabel: l10n.homeTab,
|
||||
onAction: widget.onGoHome,
|
||||
);
|
||||
}
|
||||
|
||||
void _exitEditingIfEmpty() {
|
||||
if (_editing && mounted) setState(() => _editing = false);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoritesGrid extends StatelessWidget {
|
||||
const _FavoritesGrid({
|
||||
required this.controller,
|
||||
required this.editing,
|
||||
required this.scrollController,
|
||||
required this.onListEmptied,
|
||||
});
|
||||
|
||||
final FavoriteController controller;
|
||||
final bool editing;
|
||||
final ScrollController scrollController;
|
||||
final VoidCallback onListEmptied;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return _PagedGrid(
|
||||
key: const Key('following-favorites-grid'),
|
||||
scrollController: scrollController,
|
||||
loading: controller.loading,
|
||||
loadingMore: controller.loadingMore,
|
||||
hasMore: controller.hasMore,
|
||||
hasItems: controller.cards.isNotEmpty,
|
||||
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,
|
||||
empty: EmptyState(
|
||||
key: const Key('following-favorites-empty'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
title: l10n.noFavorites,
|
||||
subtitle: l10n.emptyHint,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
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,
|
||||
tags: _dramaTags(drama),
|
||||
editing: editing,
|
||||
subtitle: l10n.favoriteEpisodeProgress(
|
||||
episode,
|
||||
drama.episodeCount,
|
||||
),
|
||||
onTap: () => unawaited(playDramaFromCatalog(context, drama)),
|
||||
onRemove: () async {
|
||||
if (await _confirmRemove(context)) {
|
||||
await controller.remove(drama.id);
|
||||
if (controller.cards.isEmpty) onListEmptied();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryGrid extends StatelessWidget {
|
||||
const _HistoryGrid({
|
||||
required this.controller,
|
||||
required this.editing,
|
||||
required this.scrollController,
|
||||
required this.onListEmptied,
|
||||
});
|
||||
|
||||
final WatchRecordsController controller;
|
||||
final bool editing;
|
||||
final ScrollController scrollController;
|
||||
final VoidCallback onListEmptied;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return _PagedGrid(
|
||||
key: const Key('following-history-grid'),
|
||||
scrollController: scrollController,
|
||||
loading: controller.loading,
|
||||
loadingMore: controller.loadingMore,
|
||||
hasMore: controller.hasMore,
|
||||
hasItems: controller.records.isNotEmpty,
|
||||
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 title = record.displayTitle.isEmpty
|
||||
? l10n.episodeLabel(record.episodeNumber)
|
||||
: record.displayTitle;
|
||||
return FollowingGridCard(
|
||||
dramaId: record.dramaId,
|
||||
title: title,
|
||||
posterUrl: record.displayCover,
|
||||
viewCount: record.drama?.viewCount ?? 0,
|
||||
tags: _dramaTags(record.drama),
|
||||
progress: record.progress.fraction,
|
||||
editing: editing,
|
||||
subtitle: l10n.watchedToEpisode(record.episodeNumber),
|
||||
onTap: () => unawaited(_openHistory(context, controller, record)),
|
||||
onRemove: () async {
|
||||
if (await _confirmRemove(context)) {
|
||||
await controller.remove(record.dramaId);
|
||||
if (controller.records.isEmpty) onListEmptied();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _dramaTags(Drama? drama) {
|
||||
if (drama == null) return const [];
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
class _PagedGrid extends StatelessWidget {
|
||||
const _PagedGrid({
|
||||
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.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 Future<void> Function() onRefresh;
|
||||
final Future<void> Function() onLoadMore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (loading && !hasItems) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final emptyContent = errorMessage == null
|
||||
? empty
|
||||
: EmptyState(
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: errorMessage!,
|
||||
actionLabel: AppLocalizations.of(context)!.retry,
|
||||
onAction: () => unawaited(onRefresh()),
|
||||
);
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: AppPullToRefresh(
|
||||
onRefresh: onRefresh,
|
||||
child: CustomScrollView(
|
||||
key: ValueKey<String>('following-scroll-$runtimeType'),
|
||||
controller: scrollController,
|
||||
physics: AppPullToRefresh.physics,
|
||||
slivers: [
|
||||
if (!hasItems)
|
||||
SliverFillRemaining(hasScrollBody: false, child: emptyContent)
|
||||
else ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.feedPageHorizontal,
|
||||
AppDimens.spaceMd,
|
||||
AppDimens.feedPageHorizontal,
|
||||
AppDimens.spaceSm,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: AppDimens.homeGridGap,
|
||||
mainAxisSpacing: AppDimens.homeGridRowGap,
|
||||
childAspectRatio: AppDimens.followingGridAspectRatio,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
itemBuilder,
|
||||
childCount: itemCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: PagedListFooter(
|
||||
loading: loadingMore,
|
||||
hasMore: hasMore,
|
||||
hasItems: hasItems,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -79,9 +79,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);
|
||||
}
|
||||
}
|
||||
@@ -102,10 +104,12 @@ class HomeLogic extends ChangeNotifier {
|
||||
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);
|
||||
}
|
||||
|
||||
+71
-238
@@ -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,15 +205,11 @@ 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);
|
||||
}
|
||||
|
||||
@@ -261,8 +233,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 +242,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 +264,80 @@ 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>(),
|
||||
),
|
||||
_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),
|
||||
onOpenFilter: () => _pushPage(context, const FilterPage()),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
callbacks: MyPageCallbacks(onAction: logic.myAction),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: 0,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// 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 '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;
|
||||
late final Map<HomePrimaryTab, ScrollController> _scrollControllers;
|
||||
final Map<HomePrimaryTab, String?> _selectedTags = {
|
||||
for (final tab in HomePrimaryTab.values) tab: null,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollControllers = {
|
||||
for (final tab in HomePrimaryTab.values) tab: ScrollController(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void 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: (tab) =>
|
||||
setState(() => _selectedPrimary = tab),
|
||||
onTagSelected: (tag) =>
|
||||
setState(() => _selectedTags[_selectedPrimary] = tag),
|
||||
walletBadge: walletBadge,
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _selectedPrimary.index,
|
||||
children: [
|
||||
for (final tab in HomePrimaryTab.values)
|
||||
_tabGrid(context, l10n, tab: tab, shortcuts: shortcuts),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabGrid(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n, {
|
||||
required HomePrimaryTab tab,
|
||||
required List<HomeGridCategoryShortcut> shortcuts,
|
||||
}) {
|
||||
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 * 6 / 5 + 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 (widget.feedController.loadingFirstPage)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else 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,
|
||||
hasMore: widget.feedController.hasMore,
|
||||
hasItems: dramas.isNotEmpty,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// 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.start,
|
||||
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'),
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
tooltip: l10n.vipMembership,
|
||||
onTap: callbacks.onOpenVip,
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceXs),
|
||||
_headerAction(
|
||||
key: const Key('home-wallet-entry'),
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
tooltip: l10n.wallet,
|
||||
onTap: callbacks.onOpenWallet,
|
||||
badge: walletBadge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerAction({
|
||||
required Key key,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback onTap,
|
||||
int? badge,
|
||||
}) {
|
||||
return SizedBox.square(
|
||||
dimension: AppDimens.homeIconSlot,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: AppColors.contentSurface,
|
||||
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
|
||||
child: InkWell(
|
||||
key: key,
|
||||
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
|
||||
onTap: onTap,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: AppDimens.homeHeaderIcon,
|
||||
color: AppColors.navSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (badge != null && badge > 0)
|
||||
Positioned(
|
||||
key: const Key('home-wallet-badge'),
|
||||
top: -5,
|
||||
right: -6,
|
||||
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.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
key: const Key('home-secondary-tags'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
for (final tag in tags) ...[
|
||||
_tagChip(tag),
|
||||
const SizedBox(width: AppDimens.homeSecondaryTagGap),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceXs),
|
||||
Tooltip(
|
||||
message: l10n.filter,
|
||||
child: SizedBox.square(
|
||||
dimension: AppDimens.tapTarget,
|
||||
child: Material(
|
||||
color: AppColors.contentSurface,
|
||||
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
|
||||
child: InkWell(
|
||||
key: const Key('home-grid-filter'),
|
||||
borderRadius: BorderRadius.circular(AppDimens.inputRadius),
|
||||
onTap: callbacks.onOpenFilter,
|
||||
child: const Icon(
|
||||
Icons.filter_list_rounded,
|
||||
color: AppColors.navSelected,
|
||||
size: AppDimens.homeHeaderIcon,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
);
|
||||
return InkWell(
|
||||
key: Key('home-tag-${tag.id}'),
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
onTap: () => onTagSelected(selected ? null : tag.id),
|
||||
child: selected
|
||||
? Container(
|
||||
height: AppDimens.homeSecondaryTagHeight,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.homeSecondaryTagHorizontalPadding,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.contentSurface,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.homeSecondaryTagRadius,
|
||||
),
|
||||
),
|
||||
child: label,
|
||||
)
|
||||
: SizedBox(
|
||||
height: AppDimens.homeSecondaryTagHeight,
|
||||
child: Align(alignment: Alignment.centerLeft, child: label),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,9 @@ enum MineDestination {
|
||||
|
||||
/// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。
|
||||
class MyPageCallbacks {
|
||||
const MyPageCallbacks({this.onAction, this.onDramaSelected});
|
||||
const MyPageCallbacks({this.onAction});
|
||||
|
||||
final ValueChanged<MyPageAction>? onAction;
|
||||
final ValueChanged<Drama>? onDramaSelected;
|
||||
|
||||
bool get embedded => onAction != null;
|
||||
}
|
||||
+105
-86
@@ -12,15 +12,15 @@ 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';
|
||||
@@ -80,21 +80,25 @@ class _MyPageState extends State<MyPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final content = SafeArea(
|
||||
bottom: !embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(l10n),
|
||||
Expanded(child: _body(context, l10n)),
|
||||
if (!embedded) _bottomNav(l10n),
|
||||
],
|
||||
final content = ColoredBox(
|
||||
key: const Key('mine-page-surface'),
|
||||
color: AppColors.appBackground,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(l10n),
|
||||
Expanded(child: _body(context, l10n)),
|
||||
if (!embedded) _bottomNav(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return Theme(
|
||||
data: AppTheme.light(),
|
||||
data: AppTheme.dark(),
|
||||
child: embedded
|
||||
? content
|
||||
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
||||
: Scaffold(backgroundColor: AppColors.appBackground, body: content),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,9 +115,9 @@ class _MyPageState extends State<MyPage> {
|
||||
l10n.myTab,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: AppDimens.pageTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.minePageTitle,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -122,8 +126,6 @@ class _MyPageState extends State<MyPage> {
|
||||
}
|
||||
|
||||
Widget _body(BuildContext context, AppLocalizations l10n) {
|
||||
final auth = context.watch<AuthController>();
|
||||
final wallet = context.watch<WalletController>();
|
||||
final locale = context.watch<LocaleController>();
|
||||
return ListView(
|
||||
key: const Key('mine-page-scroll'),
|
||||
@@ -134,17 +136,8 @@ class _MyPageState extends State<MyPage> {
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
MineVipCard(onOpen: () => _openDestination(MineDestination.vip)),
|
||||
MineHeader(onLogin: _openLogin, onEdit: _openEditActions),
|
||||
MineVipBanner(onOpen: () => _openDestination(MineDestination.vip)),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
MineMenuList(
|
||||
onOpen: _openDestination,
|
||||
@@ -155,20 +148,8 @@ class _MyPageState extends State<MyPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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 +158,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 +173,64 @@ class _MyPageState extends State<MyPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _openLogin() {
|
||||
unawaited(openAuthPage(context));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -223,7 +264,7 @@ class _MyPageState extends State<MyPage> {
|
||||
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 +273,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 +316,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 +335,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 +354,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 +392,7 @@ class _MinePlaceholderPage extends StatelessWidget {
|
||||
child: Text(
|
||||
l10n.comingSoon,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.body,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
// 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;
|
||||
const double _copyIconSize = 15;
|
||||
|
||||
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 +30,80 @@ 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(
|
||||
children: [
|
||||
_avatar(user?.avatarUrl ?? ''),
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
Expanded(
|
||||
child: Column(
|
||||
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,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.mineUserName,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${l10n.accountUid}: ${uid.isEmpty ? '--' : uid}',
|
||||
key: const Key('mine-uid'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.mineUid,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceXs),
|
||||
IconButton(
|
||||
key: const Key('mine-uid-copy'),
|
||||
tooltip: l10n.copyUid,
|
||||
onPressed: uid.isEmpty
|
||||
? null
|
||||
: () => unawaited(
|
||||
_copyUid(context, uid, l10n.uidCopied),
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: AppDimens.tapTarget,
|
||||
height: AppDimens.tapTarget,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(
|
||||
Icons.copy_rounded,
|
||||
color: AppColors.textSecondary,
|
||||
size: _copyIconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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 +111,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);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +108,7 @@ class MineVipCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的页功能菜单。
|
||||
/// 「我的」页单列功能菜单。
|
||||
class MineMenuList extends StatelessWidget {
|
||||
const MineMenuList({
|
||||
super.key,
|
||||
@@ -282,74 +127,81 @@ 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.mineCard,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
border: Border.all(color: AppColors.contentBorder),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineCardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_row(
|
||||
key: const Key('mine-works'),
|
||||
icon: Icons.video_collection_outlined,
|
||||
title: l10n.myWorks,
|
||||
destination: MineDestination.works,
|
||||
key: const Key('mine-wallet'),
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
title: l10n.wallet,
|
||||
destination: MineDestination.wallet,
|
||||
),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-creator-center'),
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
title: l10n.creatorCenter,
|
||||
destination: MineDestination.creatorCenter,
|
||||
key: const Key('mine-downloads'),
|
||||
icon: Icons.download_outlined,
|
||||
title: l10n.offlineDownloads,
|
||||
destination: MineDestination.downloads,
|
||||
),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-notices'),
|
||||
icon: Icons.notifications_none_rounded,
|
||||
title: l10n.notices,
|
||||
destination: MineDestination.notices,
|
||||
key: const Key('mine-history'),
|
||||
icon: Icons.history_rounded,
|
||||
title: l10n.watchHistory,
|
||||
destination: MineDestination.history,
|
||||
),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-watch-preferences'),
|
||||
icon: Icons.tune_rounded,
|
||||
title: l10n.watchPreferences,
|
||||
destination: MineDestination.preferences,
|
||||
),
|
||||
_divider(),
|
||||
_row(
|
||||
key: const Key('mine-help'),
|
||||
icon: Icons.support_agent_rounded,
|
||||
title: l10n.contactSupport,
|
||||
destination: MineDestination.help,
|
||||
),
|
||||
_divider(),
|
||||
_languageRow(l10n),
|
||||
_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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _languageRow(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
Widget _languageRow(AppLocalizations l10n) {
|
||||
return ListTile(
|
||||
key: const Key('mine-language'),
|
||||
minTileHeight: 48,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
minTileHeight: AppDimens.mineRowHeight,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg),
|
||||
leading: const Icon(
|
||||
Icons.language_rounded,
|
||||
color: AppColors.blue,
|
||||
size: 20,
|
||||
color: AppColors.textPrimary,
|
||||
size: AppDimens.mineMenuIcon,
|
||||
),
|
||||
title: Text(
|
||||
l10n.language,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: AppDimens.body,
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.listTitle,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
@@ -357,12 +209,15 @@ class MineMenuList extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
languageLabel,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 11),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: AppDimens.spaceXs),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.subtle,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
@@ -379,19 +234,24 @@ class MineMenuList extends StatelessWidget {
|
||||
}) {
|
||||
return ListTile(
|
||||
key: key,
|
||||
minTileHeight: 48,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
leading: Icon(icon, color: AppColors.blue, size: 20),
|
||||
minTileHeight: AppDimens.mineRowHeight,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg),
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: AppColors.textPrimary,
|
||||
size: AppDimens.mineMenuIcon,
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: AppDimens.body,
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.listTitle,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.subtle,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
onTap: () => onOpen(destination),
|
||||
@@ -399,6 +259,11 @@ class MineMenuList extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _divider() {
|
||||
return const Divider(height: 1, indent: 14, endIndent: 14);
|
||||
return const Divider(
|
||||
height: 1,
|
||||
indent: AppDimens.spaceLg,
|
||||
endIndent: AppDimens.spaceLg,
|
||||
color: AppColors.contentSurface,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ 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,
|
||||
@@ -259,7 +259,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 +285,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 +296,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 +318,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
color: AppColors.muted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
@@ -338,7 +341,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
@@ -490,7 +493,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,
|
||||
),
|
||||
|
||||
@@ -46,7 +46,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'),
|
||||
|
||||
@@ -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';
|
||||
@@ -937,8 +938,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 +1019,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 +1081,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 +1117,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 +1157,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundColor: AppColors.transparent,
|
||||
showDragHandle: false,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
|
||||
@@ -66,6 +66,7 @@ class PlayerPage extends StatefulWidget {
|
||||
this.feedDramas,
|
||||
this.feedChannel,
|
||||
this.headerChannel,
|
||||
this.showHomeFeedHeader = true,
|
||||
this.onDramaChanged,
|
||||
this.onImmersiveChanged,
|
||||
this.onOpenFullPlayer,
|
||||
@@ -90,6 +91,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;
|
||||
@@ -360,7 +362,11 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
content,
|
||||
if (widget.homeMode && !_boosting && !logic.pip && !logic.pipPreparing)
|
||||
if (widget.homeMode &&
|
||||
widget.showHomeFeedHeader &&
|
||||
!_boosting &&
|
||||
!logic.pip &&
|
||||
!logic.pipPreparing)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -393,19 +399,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,7 +463,7 @@ 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,
|
||||
@@ -497,14 +503,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 +530,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 +557,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 +566,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,8 +20,8 @@ import 'seek_progress_bar.dart';
|
||||
|
||||
part 'home_feed_overlay_parts.dart';
|
||||
|
||||
const Color _overlayPill = Color(0x66000000);
|
||||
const Color _fullBarBackground = Color(0x4D000000);
|
||||
const Color _overlayPill = AppColors.overlaySoft;
|
||||
const Color _fullBarBackground = AppColors.overlayBar;
|
||||
const double _titleFontSize = 17;
|
||||
const double _metaFontSize = 12;
|
||||
const double _tagFontSize = 11;
|
||||
@@ -29,7 +29,7 @@ const double _fullBarHeight = 44;
|
||||
const double _progressHeight = 2;
|
||||
const double _progressHitHeight = 24;
|
||||
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'[,/、·]');
|
||||
@@ -236,7 +236,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
key: const Key('home-feed-author-note'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -260,14 +260,14 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
color: AppColors.muted,
|
||||
fontSize: _metaFontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white70,
|
||||
color: AppColors.muted,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
@@ -288,16 +288,16 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: _titleFontSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
shadows: [Shadow(blurRadius: 8, color: AppColors.mediaBlack)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
@@ -326,7 +326,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
tag,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: _tagFontSize,
|
||||
),
|
||||
),
|
||||
@@ -369,7 +369,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
@@ -381,7 +381,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -389,7 +389,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -251,7 +251,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -301,7 +304,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
child: Container(
|
||||
key: const Key('home-feed-progress-track'),
|
||||
height: trackHeight,
|
||||
color: Colors.white24,
|
||||
color: AppColors.border,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -321,7 +324,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -71,7 +71,7 @@ class _RailButton extends StatelessWidget {
|
||||
width: _iconSize,
|
||||
height: _iconSize,
|
||||
fit: BoxFit.contain,
|
||||
color: item.selected ? AppColors.blue : Colors.white,
|
||||
color: item.selected ? AppColors.blue : AppColors.onPrimary,
|
||||
colorBlendMode: BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
@@ -83,11 +83,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),
|
||||
];
|
||||
|
||||
/// 首页沉浸流与全屏播放器共用的播放/暂停按钮。
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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 竖屏播放页浮层。
|
||||
@@ -270,7 +270,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 +279,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -164,7 +164,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
border: Border.all(color: AppColors.onPrimary, width: 1),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
@@ -174,7 +174,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
color: Colors.black87,
|
||||
color: AppColors.scrubPreviewScrim,
|
||||
child: Text(
|
||||
'${_format(widget.position)} / ${_format(widget.duration)}',
|
||||
key: const Key('scrub-preview-time'),
|
||||
@@ -182,7 +182,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
|
||||
@@ -233,7 +233,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
'${_format(previewPosition)} / ${_format(video.duration)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
|
||||
@@ -27,14 +27,16 @@ const double _rankColumnWidth = 56;
|
||||
const double _listCoverWidth = 72;
|
||||
const double _listCoverHeight = 96;
|
||||
const double _loadMoreThreshold = 200;
|
||||
const Color _goldStart = Color(0xFFF3B34C);
|
||||
const Color _goldEnd = Color(0xFFD9A23A);
|
||||
const Color _bronzeStart = Color(0xFFCD7F32);
|
||||
const Color _bronzeEnd = Color(0xFFA9713A);
|
||||
const Color _goldStart = AppColors.gold;
|
||||
const Color _goldEnd = AppColors.goldDeep;
|
||||
const Color _bronzeStart = AppColors.bronze;
|
||||
const Color _bronzeEnd = AppColors.bronzeDeep;
|
||||
|
||||
/// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。
|
||||
class RankingPage extends StatefulWidget {
|
||||
const RankingPage({super.key});
|
||||
const RankingPage({super.key, this.embedded = false});
|
||||
|
||||
final bool embedded;
|
||||
|
||||
@override
|
||||
State<RankingPage> createState() => _RankingPageState();
|
||||
@@ -109,63 +111,71 @@ class _RankingPageState extends State<RankingPage> {
|
||||
final top = pager.visibleItems;
|
||||
final podium = top.take(3).toList();
|
||||
final rest = top.skip(3).toList();
|
||||
final content = NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('ranking-refresh'),
|
||||
onRefresh: _refresh,
|
||||
child: ListView(
|
||||
key: const Key('ranking-list'),
|
||||
controller: _scroll,
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 24),
|
||||
children: top.isEmpty
|
||||
? [
|
||||
SizedBox(
|
||||
height: 420,
|
||||
child: EmptyState(
|
||||
icon: Icons.leaderboard_outlined,
|
||||
title: l10n.noStories,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
if (podium.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: _Podium(podium: podium, l10n: l10n),
|
||||
),
|
||||
if (rest.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
for (var index = 0; index < rest.length; index++) ...[
|
||||
if (index > 0) const SizedBox(height: _rowGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: _RankingRow(
|
||||
index: index + 3,
|
||||
drama: rest[index],
|
||||
l10n: l10n,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
PagedLoadFooter(
|
||||
loading: pager.loading,
|
||||
hasMore: pager.hasMore,
|
||||
hasItems: pager.items.isNotEmpty,
|
||||
loadMoreKey: const Key('ranking-load-more'),
|
||||
noMoreKey: const Key('ranking-no-more'),
|
||||
noMoreLabel: l10n.noMore,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (widget.embedded) {
|
||||
return ColoredBox(
|
||||
key: const Key('ranking-page'),
|
||||
color: AppColors.appBackground,
|
||||
child: SafeArea(bottom: false, child: content),
|
||||
);
|
||||
}
|
||||
return PlainPageScaffold(
|
||||
pageKey: const Key('ranking-page'),
|
||||
backKey: const Key('ranking-back'),
|
||||
title: l10n.topCharts,
|
||||
subtitle: l10n.rankingSubtitle,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('ranking-refresh'),
|
||||
onRefresh: _refresh,
|
||||
child: ListView(
|
||||
key: const Key('ranking-list'),
|
||||
controller: _scroll,
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 24),
|
||||
children: top.isEmpty
|
||||
? [
|
||||
SizedBox(
|
||||
height: 420,
|
||||
child: EmptyState(
|
||||
icon: Icons.leaderboard_outlined,
|
||||
title: l10n.noStories,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
if (podium.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: _Podium(podium: podium, l10n: l10n),
|
||||
),
|
||||
if (rest.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
for (var index = 0; index < rest.length; index++) ...[
|
||||
if (index > 0) const SizedBox(height: _rowGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: _RankingRow(
|
||||
index: index + 3,
|
||||
drama: rest[index],
|
||||
l10n: l10n,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
PagedLoadFooter(
|
||||
loading: pager.loading,
|
||||
hasMore: pager.hasMore,
|
||||
hasItems: pager.items.isNotEmpty,
|
||||
loadMoreKey: const Key('ranking-load-more'),
|
||||
noMoreKey: const Key('ranking-no-more'),
|
||||
noMoreLabel: l10n.noMore,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -265,7 +275,7 @@ class _PodiumCard extends StatelessWidget {
|
||||
l10n.topLabel(rank),
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
@@ -335,7 +345,7 @@ class _RankingRow extends StatelessWidget {
|
||||
boxShadow: const [AppTheme.cardShadow],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
import '../../core/data/services/download_controller.dart';
|
||||
import '../../core/data/services/home_feed_controller.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/navigation/fade_page_route.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../player/player_view.dart';
|
||||
import 'widgets/all_dramas_filter_grid.dart';
|
||||
import 'widgets/recommend_sections_view.dart';
|
||||
|
||||
enum RecommendInnerTab { feed, playlists, allDramas }
|
||||
|
||||
class RecommendPage extends StatefulWidget {
|
||||
const RecommendPage({
|
||||
super.key,
|
||||
required this.feedController,
|
||||
required this.active,
|
||||
required this.onSearch,
|
||||
required this.onOpenFilter,
|
||||
});
|
||||
|
||||
final HomeFeedController feedController;
|
||||
final bool active;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onOpenFilter;
|
||||
|
||||
@override
|
||||
State<RecommendPage> createState() => _RecommendPageState();
|
||||
}
|
||||
|
||||
class _RecommendPageState extends State<RecommendPage> {
|
||||
RecommendInnerTab _tab = RecommendInnerTab.feed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final categories = context.watch<CategoryController>();
|
||||
return ColoredBox(
|
||||
key: const Key('recommend-page'),
|
||||
color: AppColors.appBackground,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_header(l10n),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _tab.index,
|
||||
children: [
|
||||
_recommendFeed(context),
|
||||
RecommendSectionsView(
|
||||
feedController: widget.feedController,
|
||||
categories: categories,
|
||||
onOpenDrama: _openDrama,
|
||||
onMore: _handleMore,
|
||||
),
|
||||
AllDramasFilterGrid(
|
||||
feedController: widget.feedController,
|
||||
onOpenDrama: _openDrama,
|
||||
onOpenFilter: widget.onOpenFilter,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _recommendFeed(BuildContext context) {
|
||||
final dramas = widget.feedController.dramas;
|
||||
if (dramas.isEmpty) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (widget.feedController.loadingFirstPage) {
|
||||
return const ColoredBox(
|
||||
color: AppColors.mediaBlack,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('recommend-feed-loading'),
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ColoredBox(
|
||||
color: AppColors.mediaBlack,
|
||||
child: EmptyState(
|
||||
key: const Key('recommend-feed-empty'),
|
||||
icon: Icons.movie_outlined,
|
||||
title: l10n.noStories,
|
||||
actionLabel: l10n.retry,
|
||||
onAction: () => unawaited(widget.feedController.refreshCurrent()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return PlayerPage(
|
||||
key: const Key('recommend-feed-player'),
|
||||
homeMode: true,
|
||||
embedded: true,
|
||||
active: widget.active && _tab == RecommendInnerTab.feed,
|
||||
showHomeFeedHeader: false,
|
||||
drama: dramas.first,
|
||||
initialIndex: 0,
|
||||
feedDramas: dramas,
|
||||
feedChannel: widget.feedController.channel,
|
||||
onOpenFullPlayer: (drama, episode) =>
|
||||
_openDrama(drama, episodeId: episode.id),
|
||||
onRefreshFeed: widget.feedController.refreshCurrent,
|
||||
onLoadMore: widget.feedController.loadMore,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
final items = [
|
||||
(RecommendInnerTab.feed, l10n.recommendTopTab, 'recommend'),
|
||||
(RecommendInnerTab.playlists, l10n.playlistTopTab, 'playlist'),
|
||||
(RecommendInnerTab.allDramas, l10n.allDramasTopTab, 'all'),
|
||||
];
|
||||
return SizedBox(
|
||||
key: const Key('recommend-top-nav'),
|
||||
height: 56,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.feedPageHorizontal,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in items)
|
||||
Expanded(
|
||||
child: _tabButton(
|
||||
key: Key('recommend-tab-${item.$3}'),
|
||||
label: item.$2,
|
||||
selected: _tab == item.$1,
|
||||
onTap: () => setState(() => _tab = item.$1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
IconButton(
|
||||
key: const Key('recommend-search'),
|
||||
tooltip: l10n.search,
|
||||
onPressed: widget.onSearch,
|
||||
iconSize: 24,
|
||||
color: AppColors.textPrimary,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabButton({
|
||||
required Key key,
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
key: key,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: AppDimens.spaceSm),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textTertiary,
|
||||
fontSize: AppDimens.homePrimaryTabFont,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Container(
|
||||
width: selected ? 20 : 0,
|
||||
height: 2,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMore(RecommendSectionKind section) {
|
||||
setState(() => _tab = RecommendInnerTab.allDramas);
|
||||
}
|
||||
|
||||
Future<void> _openDrama(Drama drama, {String? episodeId}) async {
|
||||
final catalog = context.read<CatalogController>();
|
||||
final wallet = context.read<WalletController>();
|
||||
final downloads = context.read<DownloadController>();
|
||||
var playable = drama;
|
||||
try {
|
||||
playable = await catalog.requireDetail(drama);
|
||||
} catch (_) {
|
||||
// 卡片预览仍可直接进入播放器。
|
||||
}
|
||||
if (!mounted) return;
|
||||
final episodeIndex = episodeId == null
|
||||
? 0
|
||||
: playable.episodes.indexWhere((episode) => episode.id == episodeId);
|
||||
await Navigator.of(context).push<void>(
|
||||
FadePageRoute(
|
||||
builder: (_) => PlayerPage(
|
||||
drama: playable,
|
||||
initialIndex: episodeIndex < 0 ? 0 : episodeIndex,
|
||||
homeMode: true,
|
||||
feedDramas: [playable],
|
||||
wallet: wallet,
|
||||
downloads: downloads,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/home_feed_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 '../../home/widgets/home_drama_classification.dart';
|
||||
import '../../home/widgets/home_feed_card.dart';
|
||||
|
||||
const double _loadMoreThreshold = 240;
|
||||
const List<String> _modernTerms = ['现代', 'modern', 'moderno'];
|
||||
const List<String> _reversalTerms = ['逆袭', 'comeback', 'balas'];
|
||||
|
||||
class AllDramasFilterGrid extends StatefulWidget {
|
||||
const AllDramasFilterGrid({
|
||||
super.key,
|
||||
required this.feedController,
|
||||
required this.onOpenDrama,
|
||||
required this.onOpenFilter,
|
||||
});
|
||||
|
||||
final HomeFeedController feedController;
|
||||
final ValueChanged<Drama> onOpenDrama;
|
||||
final VoidCallback onOpenFilter;
|
||||
|
||||
@override
|
||||
State<AllDramasFilterGrid> createState() => _AllDramasFilterGridState();
|
||||
}
|
||||
|
||||
class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
|
||||
String _region = 'all';
|
||||
String _audio = 'all';
|
||||
String? _genre;
|
||||
bool _newest = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final dramas = _filtered();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final itemWidth =
|
||||
(constraints.maxWidth -
|
||||
AppDimens.feedPageHorizontal * 2 -
|
||||
AppDimens.homeGridGap * 2) /
|
||||
3;
|
||||
final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent;
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('all-dramas-refresh'),
|
||||
onRefresh: () async {
|
||||
await widget.feedController.refreshCurrent();
|
||||
},
|
||||
child: CustomScrollView(
|
||||
key: const Key('all-dramas-scroll'),
|
||||
physics: AppPullToRefresh.physics,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _filters(l10n)),
|
||||
if (dramas.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: EmptyState(
|
||||
key: const Key('all-dramas-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.homeGridFilterEmpty,
|
||||
actionLabel: l10n.homeGridClearFilter,
|
||||
onAction: () => setState(() {
|
||||
_region = 'all';
|
||||
_audio = 'all';
|
||||
_genre = null;
|
||||
}),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.feedPageHorizontal,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
key: const Key('all-dramas-grid'),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
|
||||
child: PagedListFooter(
|
||||
loading: widget.feedController.loadingMore,
|
||||
hasMore: widget.feedController.hasMore,
|
||||
hasItems: dramas.isNotEmpty,
|
||||
errorMessage: widget.feedController.lastError == null
|
||||
? null
|
||||
: l10n.catalogError,
|
||||
onRetry: widget.feedController.loadMore,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _filters(AppLocalizations l10n) {
|
||||
final genres = _availableGenres(l10n);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.feedPageHorizontal,
|
||||
AppDimens.spaceSm,
|
||||
AppDimens.feedPageHorizontal,
|
||||
AppDimens.spaceMd,
|
||||
),
|
||||
child: Column(
|
||||
key: const Key('all-dramas-filters'),
|
||||
children: [
|
||||
_selectableRow(
|
||||
0,
|
||||
[
|
||||
(id: 'all', label: l10n.all),
|
||||
(id: 'local', label: l10n.localFilter),
|
||||
(id: 'asia', label: l10n.asiaFilter),
|
||||
(id: 'western', label: l10n.westernFilter),
|
||||
],
|
||||
selected: _region,
|
||||
onSelected: (value) => setState(() => _region = value),
|
||||
),
|
||||
_selectableRow(
|
||||
1,
|
||||
[
|
||||
(id: 'all', label: l10n.all),
|
||||
(id: 'subtitles', label: l10n.subtitlesOnlyFilter),
|
||||
(id: 'dubbed', label: l10n.dubbedFilter),
|
||||
],
|
||||
selected: _audio,
|
||||
onSelected: (value) => setState(() => _audio = value),
|
||||
),
|
||||
_genreRow(2, genres, l10n),
|
||||
Row(
|
||||
key: const Key('all-filter-row-3'),
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
_chip(
|
||||
key: const Key('all-sort-hot'),
|
||||
label: l10n.hot,
|
||||
selected: !_newest,
|
||||
onTap: () => setState(() => _newest = false),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
_chip(
|
||||
key: const Key('all-sort-new'),
|
||||
label: l10n.newest,
|
||||
selected: _newest,
|
||||
onTap: () => setState(() => _newest = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('all-dramas-filter-page'),
|
||||
tooltip: l10n.filter,
|
||||
onPressed: widget.onOpenFilter,
|
||||
color: AppColors.textPrimary,
|
||||
iconSize: AppDimens.tapTarget / 2,
|
||||
icon: const Icon(Icons.filter_list_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selectableRow(
|
||||
int row,
|
||||
List<({String id, String label})> options, {
|
||||
required String selected,
|
||||
required ValueChanged<String> onSelected,
|
||||
}) {
|
||||
return Padding(
|
||||
key: Key('all-filter-row-$row'),
|
||||
padding: const EdgeInsets.only(bottom: AppDimens.spaceSm),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final option in options) ...[
|
||||
_chip(
|
||||
key: Key('all-filter-${option.id}'),
|
||||
label: option.label,
|
||||
selected: selected == option.id,
|
||||
onTap: () => onSelected(option.id),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _genreRow(
|
||||
int row,
|
||||
List<({String id, String label})> options,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return _selectableRow(
|
||||
row,
|
||||
[
|
||||
(id: 'all', label: l10n.all),
|
||||
for (final genre in options) (id: genre.id, label: genre.label),
|
||||
],
|
||||
selected: _genre ?? 'all',
|
||||
onSelected: (value) =>
|
||||
setState(() => _genre = value == 'all' ? null : value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip({
|
||||
Key? key,
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
key: key,
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDimens.filterChipRadius),
|
||||
child: Container(
|
||||
height: AppDimens.filterChipHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.contentSurface : AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.filterChipRadius),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.textPrimary : AppColors.textTertiary,
|
||||
fontSize: AppDimens.filterChipFont,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<({String id, String label})> _availableGenres(AppLocalizations l10n) {
|
||||
final source = widget.feedController.dramas;
|
||||
final definitions = [
|
||||
(id: 'reversal', label: l10n.reversalFilter, terms: _reversalTerms),
|
||||
(id: 'uplifting', label: l10n.homeTagUplifting, terms: <String>[]),
|
||||
(id: 'modern', label: l10n.modernFilter, terms: _modernTerms),
|
||||
(id: 'sweet', label: l10n.homeTagSweet, terms: <String>[]),
|
||||
(id: 'angst', label: l10n.homeTagAngst, terms: <String>[]),
|
||||
];
|
||||
return definitions
|
||||
.where(
|
||||
(definition) => source.any(
|
||||
(drama) => _matchesGenre(drama, definition.id, definition.terms),
|
||||
),
|
||||
)
|
||||
.map((definition) => (id: definition.id, label: definition.label))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<Drama> _filtered() {
|
||||
if (_region != 'all' || _audio != 'all') return const [];
|
||||
final dramas = widget.feedController.dramas
|
||||
.where((drama) {
|
||||
return _genre == null || _matchesGenre(drama, _genre!, const []);
|
||||
})
|
||||
.toList(growable: false);
|
||||
if (_newest) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount));
|
||||
}
|
||||
|
||||
bool _matchesGenre(Drama drama, String id, List<String> terms) {
|
||||
if (id == 'reversal' ||
|
||||
id == 'uplifting' ||
|
||||
id == 'sweet' ||
|
||||
id == 'angst') {
|
||||
return matchesHomeTag(drama, id == 'reversal' ? 'uplifting' : id);
|
||||
}
|
||||
final text = '${drama.genre} ${drama.category}'.toLowerCase();
|
||||
return terms.any(text.contains);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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/widgets/home_drama_classification.dart';
|
||||
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
class RecommendSectionCard extends StatelessWidget {
|
||||
const RecommendSectionCard({
|
||||
super.key,
|
||||
required this.sectionKey,
|
||||
required this.drama,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String sectionKey;
|
||||
final Drama drama;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return SizedBox(
|
||||
width: AppDimens.recommendCardWidth,
|
||||
child: InkWell(
|
||||
key: Key('recommend-card-$sectionKey-${drama.id}'),
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
||||
child: AspectRatio(
|
||||
aspectRatio: AppDimens.cardCoverAspectRatio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
fallbackColor: AppColors.contentSurface,
|
||||
),
|
||||
if (isHotDrama(drama))
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: DramaCoverBadgeRibbon(
|
||||
label: l10n.homeBadgeHot,
|
||||
color: AppColors.badgeHot,
|
||||
keyPrefix: '$sectionKey-${drama.id}-hot',
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CoverBottomScrim(),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: AppDimens.cardPlayCountBottom,
|
||||
child: CoverPlayCount(
|
||||
key: Key('recommend-views-$sectionKey-${drama.id}'),
|
||||
count: drama.viewCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||
Text(
|
||||
drama.title,
|
||||
key: Key('recommend-title-$sectionKey-${drama.id}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.cardTitleFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: AppDimens.cardTitleLineHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.cardTitleTagGap),
|
||||
_tags(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Widget _tags() {
|
||||
final tags = _cardTags();
|
||||
if (tags.isEmpty) return const SizedBox.shrink();
|
||||
return Text(
|
||||
tags.join(' | '),
|
||||
key: Key('recommend-tags-$sectionKey-${drama.id}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTertiary,
|
||||
fontSize: AppDimens.cardTagFont,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/content_category.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/category_controller.dart';
|
||||
import '../../../core/data/services/home_feed_controller.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/app_pull_to_refresh.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../home/widgets/home_drama_classification.dart';
|
||||
import 'recommend_section_card.dart';
|
||||
|
||||
const List<String> _costumeNames = ['古装', 'costume', 'kostum'];
|
||||
|
||||
enum RecommendSectionKind { hits, newReleases, costume }
|
||||
|
||||
class RecommendSectionsView extends StatelessWidget {
|
||||
const RecommendSectionsView({
|
||||
super.key,
|
||||
required this.feedController,
|
||||
required this.categories,
|
||||
required this.onOpenDrama,
|
||||
required this.onMore,
|
||||
});
|
||||
|
||||
final HomeFeedController feedController;
|
||||
final CategoryController categories;
|
||||
final ValueChanged<Drama> onOpenDrama;
|
||||
final ValueChanged<RecommendSectionKind> onMore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final source = feedController.dramas;
|
||||
final hits = [...source]
|
||||
..sort((a, b) => b.viewCount.compareTo(a.viewCount));
|
||||
final newest = [...source]
|
||||
..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);
|
||||
});
|
||||
return AppPullToRefresh(
|
||||
key: const Key('recommend-sections-refresh'),
|
||||
onRefresh: () async {
|
||||
await Future.wait([feedController.refreshCurrent(), categories.load()]);
|
||||
},
|
||||
child: ListView(
|
||||
key: const Key('recommend-sections-scroll'),
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
|
||||
children: [
|
||||
_section(
|
||||
context,
|
||||
l10n,
|
||||
key: 'hits',
|
||||
title: l10n.weeklyHitsTitle,
|
||||
subtitle: l10n.weeklyHitsSubtitle,
|
||||
dramas: hits.take(10).toList(),
|
||||
onMore: () => onMore(RecommendSectionKind.hits),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXl),
|
||||
_section(
|
||||
context,
|
||||
l10n,
|
||||
key: 'new',
|
||||
title: l10n.weeklyNewTitle,
|
||||
subtitle: l10n.weeklyNewSubtitle,
|
||||
dramas: newest.take(10).toList(),
|
||||
onMore: () => onMore(RecommendSectionKind.newReleases),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXl),
|
||||
_section(
|
||||
context,
|
||||
l10n,
|
||||
key: 'costume',
|
||||
title: l10n.costumeAwardsTitle,
|
||||
subtitle: l10n.costumeAwardsSubtitle,
|
||||
dramas: _costumeDramas(source),
|
||||
onMore: () => onMore(RecommendSectionKind.costume),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Drama> _costumeDramas(List<Drama> source) {
|
||||
final category = _costumeCategory();
|
||||
if (category != null) {
|
||||
final fromCategory = categories.dramasOf(category.id);
|
||||
if (fromCategory.isNotEmpty) return fromCategory.take(10).toList();
|
||||
}
|
||||
return source
|
||||
.where(
|
||||
(drama) =>
|
||||
_containsCostume(drama.category) || _containsCostume(drama.genre),
|
||||
)
|
||||
.take(10)
|
||||
.toList();
|
||||
}
|
||||
|
||||
ContentCategory? _costumeCategory() {
|
||||
for (final category in categories.categories) {
|
||||
if (_containsCostume(category.name)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _containsCostume(String value) {
|
||||
final normalized = value.toLowerCase();
|
||||
return _costumeNames.any(normalized.contains);
|
||||
}
|
||||
|
||||
Widget _section(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n, {
|
||||
required String key,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required List<Drama> dramas,
|
||||
required VoidCallback onMore,
|
||||
}) {
|
||||
return Column(
|
||||
key: Key('recommend-section-$key'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.feedPageHorizontal,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.recommendSectionTitle,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.recommendSectionSubtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('recommend-more-$key'),
|
||||
onPressed: onMore,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textSecondary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
minimumSize: const Size(0, 30),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.more,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.recommendSectionMore,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
SizedBox(
|
||||
height: AppDimens.recommendRowHeight,
|
||||
child: ListView.separated(
|
||||
key: Key('recommend-row-$key'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.feedPageHorizontal,
|
||||
),
|
||||
itemCount: dramas.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: AppDimens.homeGridGap),
|
||||
itemBuilder: (context, index) => RecommendSectionCard(
|
||||
sectionKey: key,
|
||||
drama: dramas[index],
|
||||
onTap: () => onOpenDrama(dramas[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import '../new_releases/new_releases_page.dart';
|
||||
|
||||
const double _ticketRadius = 20;
|
||||
const double _notchSize = 24;
|
||||
const Color _ticketDash = Color(0x66FF7A1A);
|
||||
const Color _ticketDash = AppColors.primaryBorder;
|
||||
|
||||
/// 预约页:档期周历 + 电影票式空态 + 预约权益说明。
|
||||
class ReservePage extends StatefulWidget {
|
||||
@@ -131,7 +131,7 @@ class _ReservePageState extends State<ReservePage> {
|
||||
Text(
|
||||
weekdays[date.weekday - 1],
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : AppColors.muted,
|
||||
color: selected ? AppColors.onPrimary : AppColors.muted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -140,7 +140,7 @@ class _ReservePageState extends State<ReservePage> {
|
||||
Text(
|
||||
'${date.day}',
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : AppColors.onSurface,
|
||||
color: selected ? AppColors.onPrimary : AppColors.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
@@ -207,7 +207,7 @@ class _ReservePageState extends State<ReservePage> {
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -285,7 +285,7 @@ class _ReservePageState extends State<ReservePage> {
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
|
||||
@@ -23,7 +23,7 @@ class SettingsPage extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final auth = context.watch<AuthController>();
|
||||
return Theme(
|
||||
data: AppTheme.light(),
|
||||
data: AppTheme.dark(),
|
||||
child: PlainPageScaffold(
|
||||
pageKey: const Key('settings-page'),
|
||||
backKey: const Key('settings-back'),
|
||||
|
||||
@@ -57,7 +57,7 @@ class _StorePageState extends State<StorePage> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final isVip = widget.type == StoreController.vipType;
|
||||
return Theme(
|
||||
data: AppTheme.light(),
|
||||
data: AppTheme.dark(),
|
||||
child: PlainPageScaffold(
|
||||
pageKey: const Key('store-page'),
|
||||
backKey: const Key('store-back'),
|
||||
|
||||
@@ -46,7 +46,7 @@ class _WalletPageState extends State<WalletPage> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final wallet = context.watch<WalletController>();
|
||||
return Theme(
|
||||
data: AppTheme.light(),
|
||||
data: AppTheme.dark(),
|
||||
child: PlainPageScaffold(
|
||||
pageKey: const Key('wallet-page'),
|
||||
backKey: const Key('wallet-back'),
|
||||
|
||||
@@ -38,7 +38,7 @@ class WatchRecordsPage extends StatelessWidget {
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Theme(
|
||||
data: AppTheme.light(),
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
key: const Key('watch-records-page'),
|
||||
backgroundColor: AppColors.ink,
|
||||
|
||||
@@ -170,7 +170,7 @@ class WatchRecordCard extends StatelessWidget {
|
||||
key: ValueKey('watch-record-${record.dramaId}'),
|
||||
decoration: _cardDecoration(),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(_cardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
import 'package:fruitshorts/core/platform/haptics.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/auth/auth_view.dart';
|
||||
@@ -131,7 +132,10 @@ void main() {
|
||||
final card = tester.widget<Container>(find.byKey(const Key('auth-card')));
|
||||
final cardDecoration = card.decoration! as BoxDecoration;
|
||||
expect(cardDecoration.color, AppColors.card);
|
||||
expect(cardDecoration.borderRadius, BorderRadius.circular(20));
|
||||
expect(
|
||||
cardDecoration.borderRadius,
|
||||
BorderRadius.circular(AppDimens.authCardRadius),
|
||||
);
|
||||
expect(cardDecoration.boxShadow, contains(AppTheme.cardShadow));
|
||||
expect(card.padding, const EdgeInsets.all(20));
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ void main() {
|
||||
|
||||
expect(l10n.homeTab, 'Beranda');
|
||||
expect(l10n.watchNow, 'Tonton gratis');
|
||||
expect(l10n.watchlist, 'Daftar Tonton');
|
||||
expect(l10n.coins, 'Koin');
|
||||
expect(l10n.signIn, 'Masuk');
|
||||
});
|
||||
|
||||
@@ -10,6 +10,13 @@ void main() {
|
||||
expect(formatInteractionCount(9999), '9999');
|
||||
});
|
||||
|
||||
test('should_format_play_count_with_w_and_trim_integer_decimals', () {
|
||||
expect(formatPlayCount(8500), '8500');
|
||||
expect(formatPlayCount(10000), '1w');
|
||||
expect(formatPlayCount(500000), '50w');
|
||||
expect(formatPlayCount(444500), '44.5w');
|
||||
});
|
||||
|
||||
test('should_use_one_decimal_wan_from_ten_thousand', () {
|
||||
expect(formatInteractionCount(10000), '1.0万');
|
||||
expect(formatInteractionCount(12400), '1.2万');
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/core/widgets/subpage_header.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_unified_light_header_dimensions', (tester) async {
|
||||
testWidgets('should_render_unified_dark_header_dimensions', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
@@ -47,7 +47,7 @@ void main() {
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(shadow.boxShadow!.single.color, const Color(0x14000000));
|
||||
expect(shadow.boxShadow!.single.color, AppColors.shadowSubtle);
|
||||
expect(shadow.boxShadow!.single.blurRadius, 8);
|
||||
expect(shadow.boxShadow!.single.offset, const Offset(0, 2));
|
||||
});
|
||||
@@ -75,7 +75,7 @@ void main() {
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: AppColors.mediaBlack,
|
||||
body: AppSubpageHeader(
|
||||
title: 'Title',
|
||||
backKey: Key('sample-back'),
|
||||
@@ -97,8 +97,8 @@ void main() {
|
||||
matching: find.byIcon(Icons.chevron_left_rounded),
|
||||
),
|
||||
);
|
||||
expect(material.color, const Color(0x59000000));
|
||||
expect(icon.color, Colors.white);
|
||||
expect(material.color, AppColors.headerScrim);
|
||||
expect(icon.color, AppColors.onSurface);
|
||||
expect(icon.size, 22);
|
||||
expect(icon.weight, 600);
|
||||
final shadow =
|
||||
@@ -108,7 +108,7 @@ void main() {
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
expect(shadow.boxShadow!.single.color, const Color(0x33000000));
|
||||
expect(shadow.boxShadow!.single.color, AppColors.shadowSoft);
|
||||
expect(shadow.boxShadow!.single.blurRadius, 8);
|
||||
});
|
||||
}
|
||||
@@ -99,9 +99,9 @@ void main() {
|
||||
);
|
||||
expect(forwarded, [DiscoverPageAction.home]);
|
||||
|
||||
logic.act(pageContext, DiscoverPageAction.my, embedded: false);
|
||||
logic.act(pageContext, DiscoverPageAction.mine, embedded: false);
|
||||
await tester.pumpAndSettle();
|
||||
await expectLater(popped.future, completion(DiscoverPageAction.my));
|
||||
await expectLater(popped.future, completion(DiscoverPageAction.mine));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ Future<void> _pumpFilterPage(WidgetTester tester) async {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-theater')));
|
||||
await tester.tap(find.byKey(const Key('home-search-entry')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('theater-toolbar-filter')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -79,7 +79,6 @@ void main() {
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: _GridCategoryRepository(),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('theater-category-7')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -187,38 +186,6 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_load_next_theater_page_near_scroll_bottom', (
|
||||
tester,
|
||||
) async {
|
||||
final catalog = _PagedTheaterCatalog([
|
||||
FeedPage(dramas: _pageDramas(0, 20), nextCursor: '20', hasMore: true),
|
||||
FeedPage(dramas: _pageDramas(20, 20)),
|
||||
]);
|
||||
await _pumpTheater(
|
||||
tester,
|
||||
catalogRepository: catalog,
|
||||
categoryRepository: _GridCategoryRepository(),
|
||||
);
|
||||
final position = tester
|
||||
.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('discover-refresh')),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.position;
|
||||
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(catalog.calls, 2);
|
||||
expect(catalog.requestedCursors, [null, '20']);
|
||||
expect(find.byKey(const Key('home-drama-card-page-39')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_refresh_feed_when_pulled_down', (tester) async {
|
||||
final counter = _CountingCategoryRepository(
|
||||
_GridCategoryRepository(discoverFeed: _tallDiscoverFeed),
|
||||
@@ -455,7 +422,9 @@ Future<void> _pumpTheater(
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-theater')));
|
||||
await tester.tap(find.byKey(const Key('home-search-entry')));
|
||||
await tester.pumpAndSettle();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
@@ -654,34 +623,6 @@ class _CountingTheaterCatalog extends _TheaterOnlyCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
class _PagedTheaterCatalog extends _TheaterOnlyCatalog {
|
||||
_PagedTheaterCatalog(this.pages);
|
||||
|
||||
final List<FeedPage> pages;
|
||||
final List<String?> requestedCursors = [];
|
||||
|
||||
int get calls => requestedCursors.length;
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? categoryId,
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async {
|
||||
if (size != 20) {
|
||||
return super.fetchFeed(
|
||||
types: types,
|
||||
categoryId: categoryId,
|
||||
cursor: cursor,
|
||||
size: size,
|
||||
);
|
||||
}
|
||||
requestedCursors.add(cursor);
|
||||
return pages.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingTheaterCatalog extends _TheaterOnlyCatalog {
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
@@ -701,14 +642,3 @@ class _FailingTheaterCatalog extends _TheaterOnlyCatalog {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Drama> _pageDramas(int start, int count) => [
|
||||
for (var index = start; index < start + count; index++)
|
||||
Drama(
|
||||
id: 'page-$index',
|
||||
title: 'Page $index',
|
||||
synopsis: 'Page $index synopsis',
|
||||
posterUrl: '',
|
||||
genre: 'Drama',
|
||||
),
|
||||
];
|
||||
@@ -303,7 +303,7 @@ Future<void> _pumpTheater(
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('tab-theater')));
|
||||
await tester.tap(find.byKey(const Key('home-search-entry')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user