diff --git a/.gitignore b/.gitignore index 5450a36..034bed8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,13 @@ app.*.map.json /android/app/release # Local demo media and internal planning are absent from the public repo. -/assets/ +# Launcher/splash source icons are required at build time, so keep them tracked. +/assets/* +!/assets/icon/ +!/assets/icon/*.png +!/assets/fonts/ +!/assets/fonts/*.ttf +!/assets/fonts/OFL-*.txt /server/ /docs/ /AGENTS.md diff --git a/README.md b/README.md index c2bc86e..24e676e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,155 @@ -一个使用 Flutter 开发的短剧播放 App 原型,面向海外用户,重点探索沉浸式刷剧、找剧和连续观看的移动端体验 +# FruitShorts(ReelShort)客户端 -## 主要功能 +FruitShorts 是面向印尼市场的短剧 Flutter 客户端。当前版本已连接 +`FruitServer` 的 `/app/**` 接口,支持沉浸播放、找剧、账号、观看记录、收藏、 +下载授权、评论互动、站内通知和中英印尼三语界面。 -- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。 -- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。 -- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。 -- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。 -- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。 +## 技术栈 -## 技术实现 +- Flutter 3.47.7 / Dart 3.13.5 +- Provider 状态管理与依赖注入 +- dio 网络请求 +- video_player 播放 +- shared_preferences 与 `SharedPreferencesAsync` 本地配置 +- flutter_localizations,支持 `en`、`zh`、`id` +- Android 系统画中画 -项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。 +## 目录结构 -## 当前说明 +```text +lib/ + core/ + config/ 地址、语言和运行时配置 + data/ models、remote API、repository、controller + formatting/ 文案与日期格式化 + platform/ 触觉等平台能力 + theme/ 颜色与主题 + widgets/ 跨页面复用组件 + l10n/ 三语 ARB 与生成代码 + pages/ 业务页面 +test/ 单元、组件、页面和联调测试 +integration_test/ 设备侧集成测试 +android/ Android 工程 +ios/ iOS 工程与 SPM 配置 +assets/ 图标、图片和本地媒体文件 +``` -现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。 +## 已接接口 + +以下接口均在 `lib/core/data/remote/` 中有实际调用,共 26 个不同路径、 +31 个 HTTP 方法级调用。 + +| 分组 | 契约 | 接口 | +| --- | --- | --- | +| 账号 | §1.1 | `POST /app/auth/register`、`/login`、`/refresh`、`/logout` | +| 内容 | §1.3、§1.4 | `GET /app/home/feed`、`/app/discover/feed`、`/app/categories`、`/app/dramas/{id}`、`/app/episodes/{id}/play` | +| 观看记录 | §1.5 | `POST/GET /app/watch/progress`、`GET /app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress` | +| 收藏 | §1.5 | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` | +| 下载授权 | §1.6 | `GET /app/downloads/policy`、`PUT /app/downloads/settings`、`POST/GET /app/downloads`、`GET /app/downloads/{episodeId}/url`、`POST /app/downloads/{episodeId}/progress`、`DELETE /app/downloads/{episodeId}`、`DELETE /app/downloads` | +| 互动 | §1.7 | `GET/POST /app/comments`、`DELETE /app/comments/{id}`、`POST /app/likes/toggle`、`POST /app/reports` | +| 公告 | §1.9.2 | `GET /app/notices` | + +客户端没有调用 `/app/config` 或创作者入驻接口。 + +## 运行 + +### 安装依赖 + +```bash +flutter pub get +``` + +### Android USB + +手机开启 USB 调试并连接电脑后: + +```bash +adb devices +adb reverse tcp:8081 tcp:8081 +flutter run -d \ + --dart-define=API_BASE_URL=http://127.0.0.1:8081 +``` + +`adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要 +重新执行。 + +Android 模拟器访问宿主机时使用: + +```bash +flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8081 +``` + +### iOS + +手机与 Mac 需在同一网络,首次启动允许“本地网络”权限: + +```bash +flutter run -d \ + --dart-define=API_BASE_URL=http://:8081 +``` + +### Debug 运行时地址 + +debug 版本可在「我的 → 设置 → 服务器地址」修改后端地址,保存后重启 App 生效。 +地址优先级: + +```text +本地运行时设置 > --dart-define(API_BASE_URL) > 平台默认地址 +``` + +因此不带 `--dart-define` 构建的 debug APK 也可在安装后改成当前电脑 IP。 +release 版本不显示入口,也不会读取本地覆盖。 + +## 环境注意 + +### iOS Swift 版本 + +`pubspec.yaml` 将 `shared_preferences_foundation` 固定为 `2.5.6`。该版本兼容 +Xcode 16.2 / Swift 6.0.3;`2.6.0` 的生成代码使用 Swift 6.1 才支持的 trailing +comma。不要手工升级该依赖,`flutter pub get` 后应显示 +`shared_preferences_foundation 2.5.6 (overridden)`。 + +### iOS SPM 包缺失 + +如果 Xcode 报 `Missing package product FlutterGeneratedPluginSwiftPackage`: + +```bash +rm -rf ~/Library/Developer/Xcode/DerivedData/Runner-* +flutter clean +flutter pub get +xcodebuild -resolvePackageDependencies \ + -project ios/Runner.xcodeproj \ + -scheme Runner +``` + +随后重新打开工程构建。`Runner.xcscheme` 的 PreAction 会先执行 +`flutter pub get`,再执行 Flutter prepare。 + +### Android libdartjni + +若启动时报 `libdartjni.so` 加载失败,通常是旧安装中的原生库残留: + +```bash +adb uninstall com.stormg.lumadrama +flutter run -d \ + --dart-define=API_BASE_URL=http://127.0.0.1:8081 +``` + +## 门禁 + +```bash +dart analyze lib test +flutter test +flutter build apk --debug +``` + +测试文件按模块放在 `test/` 下,用例名使用 +`should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。 + +## 当前边界 + +- 下载仅管理服务端授权,不落盘、不续传、不支持离线播放。 +- 没有独立搜索接口,搜索能力尚未形成完整闭环。 +- 站内通知没有已读/未读和红点状态。 +- 支付、金币和会员权益尚未形成客户端闭环。 +- Android 支持系统画中画;iOS 画中画尚未接入。 diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 96da49f..28a2697 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -6,4 +6,6 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bb5b1f8..2571b16 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,12 @@ + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png index 49cf10d..9794446 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/android12splash.png and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index 7d8eb37..83b9072 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash_icon.png b/android/app/src/main/res/drawable-xhdpi/splash_icon.png index af935a6..2828004 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splash_icon.png and b/android/app/src/main/res/drawable-xhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png index 09b431e..c3418c0 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/android12splash.png and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index c1c47be..871b143 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash_icon.png b/android/app/src/main/res/drawable-xxhdpi/splash_icon.png index 78ec1ce..eb60578 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash_icon.png and b/android/app/src/main/res/drawable-xxhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png index 391fa1a..ac5df74 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 4583ed4..98f3b9b 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png b/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png index 8fde7d4..ab8ca49 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png and b/android/app/src/main/res/drawable-xxxhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index c74b6f9..c79c58a 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,4 @@ - - diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 4216099..0d3f7bc 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 1807efe..ed7e106 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 7b131e3..d751115 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 4835758..bf85ac7 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 24c7b41..7dd208f 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 02d990f..d360f53 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -15,6 +15,6 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 6a8e712..3462ee2 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -2,6 +2,6 @@ - #FF000000 - #D74A7D + #FF100C0A + #C4322A \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 3052bbb..5d7602e 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -15,6 +15,6 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 96da49f..28a2697 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -6,4 +6,6 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/assets/fonts/OFL-Outfit.txt b/assets/fonts/OFL-Outfit.txt new file mode 100644 index 0000000..723cd44 --- /dev/null +++ b/assets/fonts/OFL-Outfit.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-PlusJakartaSans.txt b/assets/fonts/OFL-PlusJakartaSans.txt new file mode 100644 index 0000000..ea69b0c --- /dev/null +++ b/assets/fonts/OFL-PlusJakartaSans.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Outfit-Bold.ttf b/assets/fonts/Outfit-Bold.ttf new file mode 100644 index 0000000..0389ff2 Binary files /dev/null and b/assets/fonts/Outfit-Bold.ttf differ diff --git a/assets/fonts/Outfit-Regular.ttf b/assets/fonts/Outfit-Regular.ttf new file mode 100644 index 0000000..0b0a0d7 Binary files /dev/null and b/assets/fonts/Outfit-Regular.ttf differ diff --git a/assets/fonts/Outfit-SemiBold.ttf b/assets/fonts/Outfit-SemiBold.ttf new file mode 100644 index 0000000..52e25bc Binary files /dev/null and b/assets/fonts/Outfit-SemiBold.ttf differ diff --git a/assets/fonts/PlusJakartaSans-Bold.ttf b/assets/fonts/PlusJakartaSans-Bold.ttf new file mode 100644 index 0000000..91ab258 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-Bold.ttf differ diff --git a/assets/fonts/PlusJakartaSans-Medium.ttf b/assets/fonts/PlusJakartaSans-Medium.ttf new file mode 100644 index 0000000..6e7af77 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-Medium.ttf differ diff --git a/assets/fonts/PlusJakartaSans-Regular.ttf b/assets/fonts/PlusJakartaSans-Regular.ttf new file mode 100644 index 0000000..b655eb4 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-Regular.ttf differ diff --git a/assets/fonts/PlusJakartaSans-SemiBold.ttf b/assets/fonts/PlusJakartaSans-SemiBold.ttf new file mode 100644 index 0000000..609def9 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-SemiBold.ttf differ diff --git a/assets/icon/fruitshorts.png b/assets/icon/fruitshorts.png new file mode 100644 index 0000000..bed17d0 Binary files /dev/null and b/assets/icon/fruitshorts.png differ diff --git a/assets/icon/splash_mark.png b/assets/icon/splash_mark.png new file mode 100644 index 0000000..d6f5dc1 Binary files /dev/null and b/assets/icon/splash_mark.png differ diff --git a/docs/FILES.md b/docs/FILES.md new file mode 100644 index 0000000..cf68bc5 --- /dev/null +++ b/docs/FILES.md @@ -0,0 +1,99 @@ +# 客户端文件说明 + +## 目录树 + +```text +. +├── android/ Android 工程与 Gradle 配置 +├── assets/ +│ ├── icon/ 启动图标 +│ ├── images/ 本地图片 +│ └── media/ 本地视频文件(当前未在 pubspec 声明) +├── integration_test/ 设备侧集成测试 +├── ios/ +│ ├── Flutter/ Flutter 生成配置与 SPM 临时包 +│ ├── Runner/ iOS Runner +│ ├── Runner.xcodeproj/ Xcode 工程与共享 Scheme +│ └── RunnerTests/ iOS 原生测试 +├── lib/ +│ ├── core/ +│ │ ├── config/ 地址、语言、运行时服务器设置 +│ │ ├── data/ models、remote、repositories、services +│ │ ├── formatting/ 多语言文案、日期和错误文本 +│ │ ├── platform/ 平台能力封装 +│ │ ├── theme/ 颜色与 ThemeData +│ │ └── widgets/ 通用组件 +│ ├── l10n/ ARB 与本地化生成代码 +│ └── pages/ 业务页面 +├── test/ 单元和 Widget 测试 +├── docs/ 客户端任务与文件说明 +├── pubspec.yaml 依赖、资源和 Flutter 配置 +└── README.md 运行、接口与环境说明 +``` + +## 关键文件 + +| 路径 | 职责 | +| --- | --- | +| `lib/main.dart` | 初始化 Flutter、屏幕方向、debug 服务器覆盖并启动 App | +| `lib/app.dart` | Provider、repository、controller、ApiClient 和主题装配 | +| `lib/core/config/app_config.dart` | 地址优先级、超时、媒体 URL 和平台默认地址 | +| `lib/core/config/server_endpoint.dart` | debug 运行时地址规范化、读取、保存和清除 | +| `lib/core/config/locale_controller.dart` | 语言选择与系统语言回退 | +| `lib/core/data/models/` | 账号、剧目、评论、观看、下载、公告等数据结构 | +| `lib/core/data/remote/` | `/app/**` 接口调用;`api_client.dart` 统一鉴权和错误映射 | +| `lib/core/data/repositories/` | HTTP 与内存实现的 repository 边界 | +| `lib/core/data/services/` | 页面 controller、播放会话、分享、画中画等业务服务 | +| `lib/core/formatting/` | 本地化内容、日期与错误文本 | +| `lib/core/platform/haptics.dart` | 可注入的触觉反馈封装 | +| `lib/core/theme/app_colors.dart` | 克莱因蓝和界面颜色 token | +| `lib/core/theme/app_theme.dart` | light/dark ColorScheme 与组件主题 | +| `lib/core/widgets/empty_state.dart` | 统一空列表占位 | +| `lib/core/widgets/remote_image.dart` | 后端相对图片地址与失败占位 | +| `lib/core/widgets/auth_gate.dart` | 未登录操作拦截 | +| `lib/core/widgets/download_action.dart` | 下载授权操作 | +| `lib/pages/home/` | 根页面、四个底部 tab 和抽屉 | +| `lib/pages/player/` | 竖屏播放、选集、进度和播放器交互 | +| `lib/pages/discover/` | 首页/发现信息流、轮播、分类与三列剧目网格 | +| `lib/pages/drama_detail/` | 剧目详情与剧集入口 | +| `lib/pages/auth/` | 登录/注册页面与表单逻辑 | +| `lib/pages/mine/` | 我的页、片单、设置、语言与 debug 地址入口 | +| `lib/pages/favorites/` | 收藏列表 | +| `lib/pages/watch_records/` | 独立观看记录页 | +| `lib/pages/downloads/` | 下载授权与策略页面 | +| `lib/pages/notices/` | 站内通知分页和详情 | +| `lib/pages/interaction/` | 举报弹窗 | +| `lib/l10n/*.arb` | en/zh/id 源文案,修改后执行 `flutter gen-l10n` | +| `ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme` | iOS SPM 预构建脚本 | + +## 测试结构 + +| 目录 | 覆盖范围 | +| --- | --- | +| `test/auth/` | 登录 controller、表单、视觉与交互 | +| `test/catalog/`、`test/category/` | 首页数据、分类和模型解析 | +| `test/comment/`、`test/interaction/` | 评论、点赞、举报和弹窗 | +| `test/config/` | 三语、系统语言和运行时服务器地址 | +| `test/core/` | ApiClient、ID 解析、触觉、分享和画中画 | +| `test/discover/`、`test/home/` | 首页、轮播、网格和导航 | +| `test/download/`、`test/library/`、`test/watch/` | 下载、收藏、观看记录 | +| `test/drama_detail/`、`test/player/`、`test/playback/` | 详情、播放器与播放队列 | +| `test/notice/` | 通知分页、空态、详情与入口 | +| `test/empty_state/`、`test/theme/` | 空态组件与主题 token | +| `test/integration/` | 依赖本机后端的联调测试,服务未启动时跳过 | +| `integration_test/app_backend_test.dart` | 设备侧后端连通性测试 | +| `test/support/` | fake repository、HTTP adapter、播放器和测试数据 | + +## 生成物 + +以下内容由工具生成,不要手工修改: + +- `lib/l10n/app_localizations*.dart` +- `*.g.dart` +- `.dart_tool/` +- `build/` +- `ios/Flutter/ephemeral/` +- `ios/Runner/GeneratedPluginRegistrant.h` +- `ios/Runner/GeneratedPluginRegistrant.m` + +`pubspec.lock` 由 Dart 包管理工具维护,应提交但不手工编辑。 diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000..e1932eb --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,38 @@ +# 客户端任务清单 + +状态按当前 `develop` 中已合入的功能整理。契约章节以 +`FruitServer/docs/api-phase1.md` 为准;任务过程记录见 +`FruitServer/docs/plans/PROGRESS.md`。 + +## K1-K13 + +| 批次 | 范围 | 状态 | 关键提交或契约章节 | 备注 | +| --- | --- | --- | --- | --- | +| K1 | 详情、播放票据、观看进度、续看、收藏 | 已完成并合入 | §1.3-§1.5 | 已有三态和基础页面 | +| K2 | 分类、观看记录、收藏列表、下载授权管理 | 已完成并合入 | §1.2、§1.5、§1.6 | 下载只管理授权,不落盘 | +| K3 | 评论、回复、点赞、举报、分享 | 已完成并合入 | §1.7 | 分享使用系统分享面板 | +| K4 | 账号剩余能力 | 未开始 | §1.1 | Google 登录所需凭据和契约范围需核对 | +| K5 | 克莱因蓝主题、印尼语适配 | 已完成并合入 | 无接口变更 | 三语为 en/zh/id | +| K6 | 登录页、底部导航、触觉反馈 | 已完成并合入 | 无接口变更 | 触觉通过 `Haptics` 封装 | +| K7 | 首页轮播、站内通知 | 已完成并合入 | §1.3、§1.9.2 | 通知无已读态 | +| K8 | 登录页视觉重设计 | 已完成并合入 | 无接口变更 | 保留原有登录逻辑和校验 | +| K9 | 首页全部剧目双列网格 | 已完成并合入 | 无接口变更 | 数据源为 feed 分类结果 | +| K10 | 统一空列表占位 | 已完成并合入 | 无接口变更 | 评论弹窗使用 compact 变体 | +| K11 | 首页三列、分类首位“全部” | 已完成并合入 | 无接口变更 | 网格比例保持 2:3 | +| K12 | 首页去标题、空态视觉重做 | 已完成并合入 | 无接口变更 | 双层同心圆空态 | +| K13 | debug 运行时服务器地址 | 已完成并合入 | 无接口变更 | 保存后重启生效,release 不开放 | + +## 待办 + +| 事项 | 状态 | 依赖或说明 | +| --- | --- | --- | +| K4 账号剩余能力 | 待办 | Google 登录需要客户端凭据、平台配置和后端契约,范围需核对 | +| B1 支付与金币 | 待办 | 客户端尚未接入 §1.8 的支付/权益闭环,商品、渠道和验证方式需核对 | +| 搜索 | 待办 | 当前没有独立搜索接口,接口契约和数据范围需核对 | +| 下载落盘 | 待办 | 需要新增文件下载、进度持久化、续传、离线播放和本地清理设计 | + +## 维护规则 + +- 新批次完成后更新本表,不写未经确认的提交号。 +- 功能状态以当前 `develop` 代码和自动化测试为准。 +- “需核对”表示产品范围、接口或外部凭据尚未确认,不能按已完成处理。 diff --git a/integration_test/app_backend_test.dart b/integration_test/app_backend_test.dart new file mode 100644 index 0000000..a2fa765 --- /dev/null +++ b/integration_test/app_backend_test.dart @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/config/app_config.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/catalog_api.dart'; +import 'package:integration_test/integration_test.dart'; + +/// 设备侧集成测试:**在手机进程里**真实请求开发机上的 app-api。 +/// +/// 跑法(红线第 10 条要求的"设备侧取证"): +/// ``` +/// IP=$(ipconfig getifaddr en0) +/// flutter test integration_test -d --dart-define=API_BASE_URL=http://$IP:8081 +/// ``` +/// 断言失败时会把实际使用的地址一起打出来,便于区分"地址不对"还是"设备连不上"。 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final baseUrl = AppConfig.apiBaseUrl; + + testWidgets('should_fetch_home_feed_and_drama_detail_when_device_reaches_backend', ( + tester, + ) async { + debugPrint('[device] API_BASE_URL=$baseUrl'); + final client = ApiClient(baseUrl: baseUrl); + addTearDown(client.close); + final api = CatalogApi(client); + + final feed = await api.homeFeed(); + expect( + feed.dramas, + isNotEmpty, + reason: '设备侧拿不到首页列表(API_BASE_URL=$baseUrl)', + ); + debugPrint('[device] 首页分区=${feed.sections.length} 剧目=${feed.dramas.length}'); + + final card = feed.dramas.first; + expect(card.id, isNotEmpty, reason: '设备侧首页卡片 id 为空'); + + final detail = await api.dramaDetail(card.id); + expect(detail.id, card.id, reason: '详情返回的 id 与卡片不一致'); + expect( + detail.episodes, + isNotEmpty, + reason: '设备侧拿到的详情没有剧集(dramaId=${card.id})', + ); + debugPrint('[device] 详情 id=${detail.id} 剧集=${detail.episodes.length}'); + }); + + testWidgets('should_render_home_drama_list_when_app_boots_on_device', ( + tester, + ) async { + // 用真 App(真网络、真目录控制器):首页底部标签栏出现即代表"剧目列表加载成功", + // 也就是用户在真机上遇到的那个「剧目列表加载失败」已经消失。 + await tester.pumpWidget(const LumaDramaApp()); + + var loaded = false; + for (var attempt = 0; attempt < 60 && !loaded; attempt++) { + await tester.pump(const Duration(milliseconds: 500)); + loaded = find.byKey(const Key('home-tabs-bar')).evaluate().isNotEmpty; + } + + expect( + loaded, + isTrue, + reason: '首页 30 秒内没渲染出剧目列表(API_BASE_URL=$baseUrl)', + ); + debugPrint('[device] 首页剧目列表已渲染:API_BASE_URL=$baseUrl'); + }); +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6833b0c..b8a8db0 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -360,7 +360,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -487,7 +487,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -538,7 +538,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c3fedb2..89b781b 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -6,11 +6,27 @@ parallelizeBuildables = "YES" buildImplicitDependencies = "YES"> + + + + + + + + + scriptText = "#!/bin/sh set -e FLUTTER="" if [ -n "${FLUTTER_ROOT:-}" ] && [ -x "$FLUTTER_ROOT/bin/flutter" ]; then FLUTTER="$FLUTTER_ROOT/bin/flutter" elif FLUTTER="$(command -v flutter)"; then : elif [ -x "$HOME/flutter/bin/flutter" ]; then FLUTTER="$HOME/flutter/bin/flutter" elif [ -x "/opt/homebrew/bin/flutter" ]; then FLUTTER="/opt/homebrew/bin/flutter" fi if [ -z "$FLUTTER" ]; then echo "error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH." >&2 exit 1 fi FLUTTER_ROOT="$(cd "$(dirname "$FLUTTER")/.." && pwd -P)" if [ -z "${SRCROOT:-}" ]; then echo "error: SRCROOT is not set; cannot locate the Flutter project." >&2 exit 1 fi cd "$SRCROOT" "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare "> - + - - - - + + @@ -30,6 +28,6 @@ - + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 724c986..bbee514 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Nontonki + FruitShorts CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Nontonki + FruitShorts CFBundlePackageType APPL CFBundleShortVersionString @@ -26,6 +26,13 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + 开发阶段需要访问同一局域网内的短剧内容服务。 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/lib/app.dart b/lib/app.dart index 672cc11..ba3c8c7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,38 +1,215 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'core/config/locale_controller.dart'; +import 'core/data/remote/account_api.dart'; +import 'core/data/remote/api_client.dart'; +import 'core/data/remote/auth_api.dart'; +import 'core/data/remote/catalog_api.dart'; +import 'core/data/remote/category_api.dart'; +import 'core/data/remote/comment_api.dart'; +import 'core/data/remote/download_api.dart'; +import 'core/data/remote/interaction_api.dart'; +import 'core/data/remote/library_api.dart'; +import 'core/data/remote/notice_api.dart'; +import 'core/data/remote/playback_api.dart'; +import 'core/data/remote/watch_api.dart'; import 'core/data/repositories/catalog_repository.dart'; -import 'core/data/repositories/engagement_repository.dart'; +import 'core/data/repositories/account_repository.dart'; +import 'core/data/repositories/category_repository.dart'; +import 'core/data/repositories/comment_repository.dart'; +import 'core/data/repositories/download_repository.dart'; +import 'core/data/repositories/favorite_repository.dart'; +import 'core/data/repositories/interaction_repository.dart'; +import 'core/data/repositories/notice_repository.dart'; +import 'core/data/repositories/playback_repository.dart'; +import 'core/data/repositories/watch_history_repository.dart'; +import 'core/data/services/auth_controller.dart'; +import 'core/data/services/auth_session_store.dart'; import 'core/data/services/catalog_controller.dart'; -import 'core/data/services/engagement_controller.dart'; +import 'core/data/services/category_controller.dart'; +import 'core/data/services/download_controller.dart'; +import 'core/data/services/favorite_controller.dart'; +import 'core/data/services/interaction_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; +import 'core/platform/haptics.dart'; +import 'core/theme/app_theme.dart'; +import 'core/theme/app_colors.dart'; import 'l10n/app_localizations.dart'; import 'pages/home/home_view.dart'; -class LumaDramaApp extends StatelessWidget { - const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new}); +class LumaDramaApp extends StatefulWidget { + const LumaDramaApp({ + super.key, + this.playerFactory = VideoPlayerPort.new, + this.catalogRepository, + this.categoryRepository, + this.downloadRepository, + this.watchHistoryRepository, + this.favoriteRepository, + this.playbackRepository, + this.commentRepository, + this.interactionRepository, + this.noticeRepository, + this.accountRepository, + this.haptics = const FlutterHaptics(), + }); final PlayerFactory playerFactory; + /// 测试注入用;生产走 HTTP 仓库。 + final CatalogRepository? catalogRepository; + final CategoryRepository? categoryRepository; + final DownloadRepository? downloadRepository; + final WatchHistoryRepository? watchHistoryRepository; + final FavoriteRepository? favoriteRepository; + final PlaybackRepository? playbackRepository; + final CommentRepository? commentRepository; + final InteractionRepository? interactionRepository; + final NoticeRepository? noticeRepository; + final AccountRepository? accountRepository; + final Haptics haptics; + + @override + State createState() => _LumaDramaAppState(); +} + +class _LumaDramaAppState extends State { + late final AuthSessionStore _sessionStore = AuthSessionStore(); + + /// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求 + /// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。 + late final ApiClient _apiClient = ApiClient( + accessTokenReader: () => _auth.accessToken, + tokenRefresher: () => _auth.refresh(), + onUnauthorized: () => _auth.clear(), + deviceIdReader: _sessionStore.deviceId, + ); + + late final AuthController _auth = AuthController( + AuthApi(_apiClient), + _sessionStore, + ); + + late final CatalogRepository _catalogRepository = + widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)); + + late final CategoryRepository _categoryRepository = + widget.categoryRepository ?? + HttpCategoryRepository(CategoryApi(_apiClient), _catalogRepository); + + late final WatchHistoryRepository _watchHistory = + widget.watchHistoryRepository ?? + HttpWatchHistoryRepository( + WatchApi(_apiClient), + isSignedIn: () => _auth.isSignedIn, + ); + + late final FavoriteRepository _favorites = + widget.favoriteRepository ?? + HttpFavoriteRepository( + LibraryApi(_apiClient), + isSignedIn: () => _auth.isSignedIn, + ); + + late final PlaybackRepository _playback = + widget.playbackRepository ?? + HttpPlaybackRepository(PlaybackApi(_apiClient)); + + late final FavoriteController _favoriteController = FavoriteController( + _favorites, + ); + + late final DownloadRepository _downloadRepository = + widget.downloadRepository ?? + HttpDownloadRepository(DownloadApi(_apiClient)); + + late final DownloadController _downloadController = DownloadController( + _downloadRepository, + ); + + late final CommentRepository _commentRepository = + widget.commentRepository ?? HttpCommentRepository(CommentApi(_apiClient)); + + late final InteractionRepository _interactionRepository = + widget.interactionRepository ?? + HttpInteractionRepository(InteractionApi(_apiClient)); + + late final NoticeRepository _noticeRepository = + widget.noticeRepository ?? HttpNoticeRepository(NoticeApi(_apiClient)); + + late final AccountRepository _accountRepository = + widget.accountRepository ?? HttpAccountRepository(AccountApi(_apiClient)); + + late final InteractionController _interactionController = + InteractionController(_interactionRepository); + + @override + void initState() { + super.initState(); + // 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏 + _auth.load(); + // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 + _auth.addListener(_onAuthChanged); + if (_auth.isSignedIn) unawaited(_favoriteController.load()); + } + + bool _wasSignedIn = false; + + void _onAuthChanged() { + if (_auth.isSignedIn == _wasSignedIn) return; + _wasSignedIn = _auth.isSignedIn; + _interactionController.clear(); + if (_auth.isSignedIn) { + unawaited(_favoriteController.load()); + } else { + _favoriteController.clear(); + _downloadController.reset(); + } + } + + @override + void dispose() { + _auth.removeListener(_onAuthChanged); + _favoriteController.dispose(); + _downloadController.dispose(); + _interactionController.dispose(); + _apiClient.close(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MultiProvider( providers: [ Provider(create: (_) => PlaybackCoordinator()), - Provider(create: (_) => playerFactory), + Provider(create: (_) => widget.playerFactory), + Provider.value(value: _catalogRepository), + Provider.value(value: _playback), + Provider.value(value: _watchHistory), + Provider.value(value: _favorites), + Provider.value(value: _commentRepository), + Provider.value(value: _interactionRepository), + Provider.value(value: _noticeRepository), + Provider.value(value: _accountRepository), + Provider.value(value: widget.haptics), ChangeNotifierProvider( - create: (_) => CatalogController(DemoCatalogRepository())..load(), + create: (_) => CatalogController(_catalogRepository)..load(), + ), + ChangeNotifierProvider( + create: (_) => CategoryController(_categoryRepository)..load(), ), ChangeNotifierProvider(create: (_) => LocaleController()..load()), - ChangeNotifierProvider( - create: (_) => - EngagementController(SharedPreferencesEngagementRepository()) - ..load(), - ), + ChangeNotifierProvider.value(value: _favoriteController), + ChangeNotifierProvider.value(value: _downloadController), + ChangeNotifierProvider.value(value: _interactionController), + ChangeNotifierProvider.value(value: _auth), ], child: Consumer( builder: (context, locale, _) => MaterialApp( @@ -40,30 +217,19 @@ class LumaDramaApp extends StatelessWidget { locale: locale.locale, supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, - localeResolutionCallback: (deviceLocale, supported) { - final resolved = locale.locale ?? deviceLocale; - if (resolved == null) return supported.first; - for (final item in supported) { - if (item.languageCode == resolved.languageCode) return item; - } - return supported.first; - }, + localeResolutionCallback: (deviceLocale, _) => + resolveAppLocale(locale.locale, deviceLocale), debugShowCheckedModeBanner: false, - themeMode: ThemeMode.system, - theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF6D4AFF), + themeMode: ThemeMode.dark, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + builder: (context, child) => AnnotatedRegion( + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.transparent, + systemNavigationBarColor: AppColors.bar, + systemNavigationBarIconBrightness: Brightness.light, ), - scaffoldBackgroundColor: const Color(0xFFF8F7FC), - ), - darkTheme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF9B86FF), - brightness: Brightness.dark, - ), - scaffoldBackgroundColor: const Color(0xFF101019), + child: child ?? const SizedBox.shrink(), ), home: const HomePage(), ), diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart new file mode 100644 index 0000000..de06a15 --- /dev/null +++ b/lib/core/config/app_config.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +/// 客户端与后端约定的运行时配置。 +/// +/// 接口地址优先级:运行时设置 > `--dart-define=API_BASE_URL=...` > 平台默认值。 +/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。 +class AppConfig { + const AppConfig._(); + + /// 真机默认使用的服务地址。 + /// + /// 当前指向公网服务;换地址时改这一行。 + static const String devHostBaseUrl = 'http://39.162.50.99:8081'; + + /// 双网络探测的首选地址,当前与默认地址一致(公网服务)。 + static const String lanBaseUrl = 'http://39.162.50.99:8081'; + + static const String _loopbackBaseUrl = 'http://127.0.0.1:8081'; + + static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL'); + + static String _runtimeBaseUrl = ''; + + /// Build-time server URL supplied through `--dart-define=API_BASE_URL`. + static String get compiledBaseUrl => _overrideBaseUrl; + + /// Existing platform-aware fallback used when no runtime or build override exists. + static String get defaultBaseUrl => _defaultBaseUrl; + + /// Effective server URL, including the debug-only runtime override. + static String get apiBaseUrl => _runtimeBaseUrl.isNotEmpty + ? _runtimeBaseUrl + : (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl); + + static final String _defaultBaseUrl = _runsOnDevMachine + ? _loopbackBaseUrl + : devHostBaseUrl; + + /// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。 + /// Android 没有等价的探测方式,统一按真机处理(模拟器用 + /// `--dart-define=API_BASE_URL=http://10.0.2.2:8081` 覆盖)。 + static final bool _runsOnDevMachine = + kIsWeb || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux || + (defaultTargetPlatform == TargetPlatform.iOS && + Platform.environment.containsKey('SIMULATOR_DEVICE_NAME')); + + /// Applies an already normalized runtime URL. Passing null clears it. + static void applyRuntimeBaseUrl(String? value) { + _runtimeBaseUrl = value ?? ''; + } + + /// Clears the in-memory runtime override, mainly for tests and restarts. + static void clearRuntimeBaseUrl() { + _runtimeBaseUrl = ''; + } + + static const Duration requestTimeout = Duration(seconds: 8); + + /// 探测内网地址是否可达,可达则切换为内网直连,否则回落到公网地址。 + /// + /// Wi-Fi 下内网直连更快;移动数据下内网不可达,自动回落到公网端口映射。 + static Future probeAndApplyBaseUrl() async { + try { + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 2); + final request = await client.getUrl( + Uri.parse('$lanBaseUrl/app/categories'), + ); + final response = await request.close().timeout(const Duration(seconds: 2)); + await response.drain(); + client.close(force: true); + if (response.statusCode == 200) { + applyRuntimeBaseUrl(lanBaseUrl); + return; + } + } catch (_) { + // 内网不可达,回落到公网。 + } + applyRuntimeBaseUrl( + compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl, + ); + } + + /// 后端返回的是相对路径(/files/...),这里补上服务地址。 + static String mediaUrl(String path) { + if (path.isEmpty) return ''; + if (path.startsWith('http://') || path.startsWith('https://')) return path; + return '$apiBaseUrl$path'; + } +} diff --git a/lib/core/config/locale_controller.dart b/lib/core/config/locale_controller.dart index c9b3b67..9404b4c 100644 --- a/lib/core/config/locale_controller.dart +++ b/lib/core/config/locale_controller.dart @@ -3,6 +3,19 @@ import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// Resolves the selected or system language to one of the supported app locales. +/// +/// Indonesian is the launch-market fallback when a device uses an unsupported +/// language. The legacy `in` code is treated as Indonesian as well. +Locale resolveAppLocale(Locale? selected, Locale? deviceLocale) { + final code = (selected ?? deviceLocale)?.languageCode; + return switch (code) { + 'zh' => const Locale('zh'), + 'en' => const Locale('en'), + _ => const Locale('id'), + }; +} + class LocaleController extends ChangeNotifier { LocaleController({SharedPreferencesAsync? preferences}) : _preferences = preferences ?? SharedPreferencesAsync(); @@ -17,6 +30,7 @@ class LocaleController extends ChangeNotifier { _locale = switch (code) { 'en' => const Locale('en'), 'zh' => const Locale('zh'), + 'id' => const Locale('id'), _ => null, }; notifyListeners(); @@ -29,6 +43,7 @@ class LocaleController extends ChangeNotifier { _locale = switch (code) { 'en' => const Locale('en'), 'zh' => const Locale('zh'), + 'id' => const Locale('id'), _ => null, }; notifyListeners(); diff --git a/lib/core/config/server_endpoint.dart b/lib/core/config/server_endpoint.dart new file mode 100644 index 0000000..332eb6b --- /dev/null +++ b/lib/core/config/server_endpoint.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'app_config.dart'; + +/// Debug-only runtime server endpoint persisted outside build-time defines. +abstract final class ServerEndpoint { + /// Preference key used for the runtime server address. + static const String preferenceKey = 'dev.api_base_url'; + + /// Returns a normalized http(s) URL, or null for invalid input. + static String? normalize(String? raw) { + final value = raw?.trim() ?? ''; + final uri = Uri.tryParse(value); + if (uri == null || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.host.isEmpty) { + return null; + } + return value.replaceFirst(RegExp(r'/+$'), ''); + } + + /// Resolves runtime > dart-define > default using normalized valid values. + static String resolve({ + required String? stored, + required String compiled, + required String fallback, + }) { + return normalize(stored) ?? + normalize(compiled) ?? + normalize(fallback) ?? + fallback; + } + + /// Loads the persisted override before the app creates its ApiClient. + /// + /// In release builds this returns immediately without reading preferences. + static Future load({ + bool debug = kDebugMode, + SharedPreferencesAsync? preferences, + }) async { + if (!debug) return; + try { + final prefs = preferences ?? SharedPreferencesAsync(); + final stored = await prefs.getString(preferenceKey); + AppConfig.applyRuntimeBaseUrl( + resolve( + stored: stored, + compiled: AppConfig.compiledBaseUrl, + fallback: AppConfig.defaultBaseUrl, + ), + ); + } catch (_) { + AppConfig.clearRuntimeBaseUrl(); + } + } + + /// Persists a normalized runtime endpoint. + static Future save( + String raw, { + SharedPreferencesAsync? preferences, + }) async { + final normalized = normalize(raw); + if (normalized == null) return false; + try { + await (preferences ?? SharedPreferencesAsync()).setString( + preferenceKey, + normalized, + ); + return true; + } catch (_) { + return false; + } + } + + /// Removes the persisted runtime endpoint. + static Future clear({SharedPreferencesAsync? preferences}) async { + try { + await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey); + } catch (_) { + // Clearing an optional developer override is best effort. + } + } +} diff --git a/lib/core/data/models/account_device.dart b/lib/core/data/models/account_device.dart new file mode 100644 index 0000000..35c64d5 --- /dev/null +++ b/lib/core/data/models/account_device.dart @@ -0,0 +1,40 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 登录设备列表项;只保留页面展示需要的字段。 +class AccountDevice { + const AccountDevice({ + required this.id, + required this.deviceId, + required this.platform, + required this.deviceModel, + required this.status, + this.firstSeenAt, + this.lastSeenAt, + }); + + final String id; + final String deviceId; + final String platform; + final String deviceModel; + final String status; + final DateTime? firstSeenAt; + final DateTime? lastSeenAt; + + /// 兼容 Long 字符串与数字;userId/deleted 不解析、不展示。 + factory AccountDevice.fromJson(Map json) { + return AccountDevice( + id: jsonToId(json['id']), + deviceId: json['deviceId']?.toString() ?? '', + platform: json['platform']?.toString() ?? '', + deviceModel: json['deviceModel']?.toString() ?? '', + status: json['status']?.toString() ?? '', + firstSeenAt: _date(json['firstSeenAt']), + lastSeenAt: _date(json['lastSeenAt']), + ); + } + + static DateTime? _date(dynamic value) => + DateTime.tryParse(value?.toString() ?? ''); +} diff --git a/lib/core/data/models/account_export.dart b/lib/core/data/models/account_export.dart new file mode 100644 index 0000000..8bf0298 --- /dev/null +++ b/lib/core/data/models/account_export.dart @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'account_device.dart'; +import 'account_profile.dart'; + +/// 导出中的登录凭据摘要。 +class AccountCredential { + const AccountCredential({ + required this.type, + required this.masked, + required this.verified, + }); + + final String type; + final String masked; + final bool verified; + + factory AccountCredential.fromJson(Map json) { + return AccountCredential( + type: json['type']?.toString() ?? '', + masked: json['masked']?.toString() ?? '', + verified: json['verified'] == true, + ); + } +} + +/// 账号导出数据;`rawJson` 用于复制后端下发的完整内容。 +class AccountExport { + const AccountExport({ + required this.profile, + required this.credentials, + required this.consentCount, + required this.devices, + required this.raw, + }); + + final AccountProfile profile; + final List credentials; + final int consentCount; + final List devices; + final Map raw; + + int get deviceCount => devices.length; + String get rawJson => const JsonEncoder.withIndent(' ').convert(raw); + + factory AccountExport.fromJson(Map json) { + final profile = _map(json['profile']); + final credentials = _list(json['credentials']); + final consents = _list(json['consents']); + final devices = _list(json['devices']); + return AccountExport( + profile: AccountProfile.fromJson(profile), + credentials: [ + for (final item in credentials) AccountCredential.fromJson(item), + ], + consentCount: consents.length, + devices: [for (final item in devices) AccountDevice.fromJson(item)], + raw: json, + ); + } + + static Map _map(dynamic value) => + value is Map ? value.cast() : const {}; + + static List> _list(dynamic value) { + if (value is! List) return const []; + return [ + for (final item in value) + if (item is Map) item.cast(), + ]; + } +} diff --git a/lib/core/data/models/account_profile.dart b/lib/core/data/models/account_profile.dart new file mode 100644 index 0000000..f874723 --- /dev/null +++ b/lib/core/data/models/account_profile.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import 'auth_session.dart'; +import 'json_numbers.dart'; + +/// 当前账号资料;字段与 `GET/PUT /app/me` 的实际响应一致。 +class AccountProfile { + const AccountProfile({ + required this.uid, + required this.nickname, + required this.avatarUrl, + required this.gender, + required this.countryCode, + required this.regionCode, + required this.language, + required this.timezone, + required this.currency, + required this.status, + required this.maskedTarget, + required this.vipLevel, + this.createdAt, + this.lastLoginAt, + this.vipExpireAt, + }); + + final String uid; + final String nickname; + final String avatarUrl; + final String gender; + final String countryCode; + final String regionCode; + final String language; + final String timezone; + final String currency; + final String status; + final String maskedTarget; + final int vipLevel; + final DateTime? createdAt; + final DateTime? lastLoginAt; + final DateTime? vipExpireAt; + + /// 解析资料响应;缺失字段按空值处理。 + factory AccountProfile.fromJson(Map json) { + return AccountProfile( + uid: jsonToId(json['uid']), + nickname: json['nickname']?.toString() ?? '', + avatarUrl: json['avatarUrl']?.toString() ?? '', + gender: json['gender']?.toString() ?? '', + countryCode: json['countryCode']?.toString() ?? '', + regionCode: json['regionCode']?.toString() ?? '', + language: json['language']?.toString() ?? '', + timezone: json['timezone']?.toString() ?? '', + currency: json['currency']?.toString() ?? '', + status: json['status']?.toString() ?? '', + maskedTarget: json['maskedTarget']?.toString() ?? '', + vipLevel: jsonToInt(json['vipLevel']), + createdAt: _date(json['createdAt']), + lastLoginAt: _date(json['lastLoginAt']), + vipExpireAt: _date(json['vipExpireAt']), + ); + } + + static DateTime? _date(dynamic value) => + DateTime.tryParse(value?.toString() ?? ''); + + /// 转为本地登录态摘要;只同步登录态实际使用的三项。 + AuthUser toAuthUser() => + AuthUser(uid: uid, nickname: nickname, avatarUrl: avatarUrl); +} + +/// 资料更新请求;null 字段不会进入请求体。 +class AccountProfileUpdate { + const AccountProfileUpdate({ + this.nickname, + this.avatarUrl, + this.gender, + this.birthday, + this.language, + this.timezone, + this.regionCode, + }); + + final String? nickname; + final String? avatarUrl; + final String? gender; + final String? birthday; + final String? language; + final String? timezone; + final String? regionCode; + + /// 只发送本次有值的字段。 + Map toJson() => { + if (nickname != null) 'nickname': nickname, + if (avatarUrl != null) 'avatarUrl': avatarUrl, + if (gender != null) 'gender': gender, + if (birthday != null) 'birthday': birthday, + if (language != null) 'language': language, + if (timezone != null) 'timezone': timezone, + if (regionCode != null) 'regionCode': regionCode, + }; +} diff --git a/lib/core/data/models/auth_session.dart b/lib/core/data/models/auth_session.dart new file mode 100644 index 0000000..998fa49 --- /dev/null +++ b/lib/core/data/models/auth_session.dart @@ -0,0 +1,54 @@ +import 'json_numbers.dart'; + +/// 登录态:服务端下发的令牌与用户摘要。 +/// +/// 只保留客户端真正要用的字段(uid / 昵称 / 头像),不做整份 AppUserRespVO 的映射 —— +/// 首页卡片、个人页需要的都在这里,避免为用不到的字段维护一份平行模型。 +class AuthUser { + const AuthUser({required this.uid, required this.nickname, this.avatarUrl = ''}); + + final String uid; + final String nickname; + final String avatarUrl; + + factory AuthUser.fromJson(Map json) => AuthUser( + uid: json['uid'] as String? ?? '', + nickname: json['nickname'] as String? ?? '', + avatarUrl: json['avatarUrl'] as String? ?? '', + ); + + Map toJson() => { + 'uid': uid, + 'nickname': nickname, + 'avatarUrl': avatarUrl, + }; +} + +class AuthSession { + const AuthSession({ + required this.accessToken, + required this.refreshToken, + this.expiresIn = 0, + required this.user, + }); + + final String accessToken; + final String refreshToken; + final int expiresIn; + final AuthUser user; + + factory AuthSession.fromJson(Map json) => AuthSession( + accessToken: json['accessToken'] as String? ?? '', + refreshToken: json['refreshToken'] as String? ?? '', + // expiresIn 后端是 long,按字符串下发(见 json_numbers.dart) + expiresIn: jsonToInt(json['expiresIn']), + user: AuthUser.fromJson((json['user'] as Map?)?.cast() ?? const {}), + ); + + Map toJson() => { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'expiresIn': expiresIn, + 'user': user.toJson(), + }; +} diff --git a/lib/core/data/models/comment.dart b/lib/core/data/models/comment.dart new file mode 100644 index 0000000..1e2a01b --- /dev/null +++ b/lib/core/data/models/comment.dart @@ -0,0 +1,132 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 评论游标分页大小,契约 §1.7 使用游标而非页码。 +const int commentPageSize = 20; + +/// 评论列表页。 +class CommentPage { + const CommentPage({ + this.comments = const [], + this.nextCursor, + this.hasMore = false, + }); + + final List comments; + final String? nextCursor; + final bool hasMore; + + /// 解析后端 `CursorResult{list,nextCursor,hasMore}`。 + factory CommentPage.fromJson(Map json) { + final rawList = json['list']; + return CommentPage( + comments: [ + if (rawList is List) + for (final item in rawList) + if (item is Map) DramaComment.fromJson(item), + ], + nextCursor: json['nextCursor'] == null + ? null + : jsonToId(json['nextCursor']), + hasMore: json['hasMore'] == true, + ); + } +} + +/// 服务端评论视图,字段对应 `CommentRespVO`。 +class DramaComment { + const DramaComment({ + required this.id, + required this.dramaId, + required this.episodeId, + required this.parentId, + required this.rootId, + required this.userId, + required this.uid, + required this.nickname, + required this.avatarUrl, + required this.content, + required this.status, + required this.auditReason, + required this.likeCount, + required this.replyCount, + required this.reportCount, + required this.createdAt, + required this.mine, + required this.liked, + }); + + final String id; + final String dramaId; + final String episodeId; + final String parentId; + final String rootId; + final String userId; + final String uid; + final String nickname; + final String avatarUrl; + final String content; + final String status; + final String auditReason; + final int likeCount; + final int replyCount; + final int reportCount; + final DateTime? createdAt; + final bool mine; + final bool liked; + + /// 待人工审核的评论仅本人可见。 + bool get isPending => status == 'pending'; + + /// 顶层评论没有父评论。 + bool get isTopLevel => parentId.isEmpty || parentId == '0'; + + /// 解析 `CommentRespVO`,所有 ID 字段只走字符串解析。 + factory DramaComment.fromJson(Map json) => DramaComment( + id: jsonToId(json['id']), + dramaId: jsonToId(json['dramaId']), + episodeId: jsonToId(json['episodeId'], fallback: '0'), + parentId: jsonToId(json['parentId'], fallback: '0'), + rootId: jsonToId(json['rootId'], fallback: '0'), + userId: jsonToId(json['userId']), + uid: json['uid']?.toString() ?? '', + nickname: json['nickname']?.toString() ?? '', + avatarUrl: json['avatarUrl']?.toString() ?? '', + content: json['content']?.toString() ?? '', + status: json['status']?.toString() ?? '', + auditReason: json['auditReason']?.toString() ?? '', + likeCount: jsonToInt(json['likeCount']), + replyCount: jsonToInt(json['replyCount']), + reportCount: jsonToInt(json['reportCount']), + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''), + mine: json['mine'] == true, + liked: json['liked'] == true, + ); + + /// 局部更新点赞或回复数,列表中的其他字段保持不变。 + DramaComment copyWith({ + int? likeCount, + int? replyCount, + bool? liked, + }) => DramaComment( + id: id, + dramaId: dramaId, + episodeId: episodeId, + parentId: parentId, + rootId: rootId, + userId: userId, + uid: uid, + nickname: nickname, + avatarUrl: avatarUrl, + content: content, + status: status, + auditReason: auditReason, + likeCount: likeCount ?? this.likeCount, + replyCount: replyCount ?? this.replyCount, + reportCount: reportCount, + createdAt: createdAt, + mine: mine, + liked: liked ?? this.liked, + ); +} diff --git a/lib/core/data/models/content_category.dart b/lib/core/data/models/content_category.dart new file mode 100644 index 0000000..b93a308 --- /dev/null +++ b/lib/core/data/models/content_category.dart @@ -0,0 +1,17 @@ +// Author: fruit.com.cn + +/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。 +class ContentCategory { + const ContentCategory({required this.id, required this.name}); + + final String id; + final String name; + + /// 解析 `GET /app/categories` 的单项。 + factory ContentCategory.fromJson(Map json) { + return ContentCategory( + id: (json['id'] ?? '').toString(), + name: json['name'] as String? ?? '', + ); + } +} diff --git a/lib/core/data/models/download_license.dart b/lib/core/data/models/download_license.dart new file mode 100644 index 0000000..213da62 --- /dev/null +++ b/lib/core/data/models/download_license.dart @@ -0,0 +1,67 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 下载授权与任务进度合并视图,对应 `GET /app/downloads` 的列表项。 +class DownloadLicense { + const DownloadLicense({ + required this.licenseId, + required this.dramaId, + required this.dramaTitle, + required this.episodeId, + required this.episodeNo, + required this.deviceId, + required this.quality, + required this.playUrl, + required this.licenseKey, + required this.status, + required this.grantedAt, + required this.expireAt, + required this.taskStatus, + required this.progressPercent, + required this.downloadedBytes, + required this.totalBytes, + required this.errorMsg, + }); + + final String licenseId; + final String dramaId; + final String dramaTitle; + final String episodeId; + final int episodeNo; + final String deviceId; + final String quality; + final String playUrl; + final String licenseKey; + final String status; + final DateTime? grantedAt; + final DateTime? expireAt; + final String taskStatus; + final int progressPercent; + final int downloadedBytes; + final int totalBytes; + final String errorMsg; + + /// 解析授权响应;所有 ID 与 Long 字段兼容字符串下发。 + factory DownloadLicense.fromJson(Map json) { + return DownloadLicense( + licenseId: (json['licenseId'] ?? '').toString(), + dramaId: (json['dramaId'] ?? '').toString(), + dramaTitle: json['dramaTitle'] as String? ?? '', + episodeId: (json['episodeId'] ?? '').toString(), + episodeNo: jsonToInt(json['episodeNo']), + deviceId: json['deviceId'] as String? ?? '', + quality: json['quality'] as String? ?? '', + playUrl: json['playUrl'] as String? ?? '', + licenseKey: json['licenseKey'] as String? ?? '', + status: json['status'] as String? ?? '', + grantedAt: DateTime.tryParse(json['grantedAt'] as String? ?? ''), + expireAt: DateTime.tryParse(json['expireAt'] as String? ?? ''), + taskStatus: json['taskStatus'] as String? ?? '', + progressPercent: jsonToInt(json['progressPercent']), + downloadedBytes: jsonToInt(json['downloadedBytes']), + totalBytes: jsonToInt(json['totalBytes']), + errorMsg: json['errorMsg'] as String? ?? '', + ); + } +} diff --git a/lib/core/data/models/download_policy.dart b/lib/core/data/models/download_policy.dart new file mode 100644 index 0000000..6c8e46d --- /dev/null +++ b/lib/core/data/models/download_policy.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 下载策略与当前设备配额,对应 `GET /app/downloads/policy`。 +class DownloadPolicy { + const DownloadPolicy({ + required this.maxPerDevice, + required this.licenseDays, + required this.wifiOnly, + required this.maxQuality, + required this.maxStorageBytes, + required this.used, + required this.remaining, + required this.expirePolicyText, + }); + + final int maxPerDevice; + final int licenseDays; + final bool wifiOnly; + final String maxQuality; + final int maxStorageBytes; + final int used; + final int remaining; + final String expirePolicyText; + + /// 设置更新期间用于本地乐观展示。 + DownloadPolicy copyWith({bool? wifiOnly, String? maxQuality}) { + return DownloadPolicy( + maxPerDevice: maxPerDevice, + licenseDays: licenseDays, + wifiOnly: wifiOnly ?? this.wifiOnly, + maxQuality: maxQuality ?? this.maxQuality, + maxStorageBytes: maxStorageBytes, + used: used, + remaining: remaining, + expirePolicyText: expirePolicyText, + ); + } + + /// 解析策略响应;Long 字段兼容字符串。 + factory DownloadPolicy.fromJson(Map json) { + return DownloadPolicy( + maxPerDevice: jsonToInt(json['maxPerDevice']), + licenseDays: jsonToInt(json['licenseDays']), + wifiOnly: json['wifiOnly'] == true, + maxQuality: json['maxQuality'] as String? ?? '', + maxStorageBytes: jsonToInt(json['maxStorageBytes']), + used: jsonToInt(json['used']), + remaining: jsonToInt(json['remaining']), + expirePolicyText: json['expirePolicyText'] as String? ?? '', + ); + } +} diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 1b7d351..fed6cfa 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -1,21 +1,55 @@ // Author: fruit.com.cn -enum DramaFormat { animated, liveAction } +import 'json_numbers.dart'; +/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。 +/// +/// 封面与播放地址都是后端返回的相对路径(`/files/...`), +/// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。 class Episode { const Episode({ required this.id, required this.number, required this.title, - required this.assetPath, required this.isFree, + this.videoUrl = '', + this.durationSec = 0, + this.locked = false, }); final String id; final int number; final String title; - final String assetPath; final bool isFree; + + /// 播放地址:详情接口不下发,由 `/app/episodes/{id}/play` 返回后写入。 + final String videoUrl; + final int durationSec; + + /// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。 + final bool locked; + + Episode withVideoUrl(String url) => Episode( + id: id, + number: number, + title: title, + isFree: isFree, + videoUrl: url, + durationSec: durationSec, + locked: locked, + ); + + factory Episode.fromJson(Map json) { + return Episode( + id: (json['id'] ?? '').toString(), + number: (json['number'] as num?)?.toInt() ?? 0, + title: json['title'] as String? ?? '', + isFree: json['isFree'] == true, + durationSec: (json['durationSec'] as num?)?.toInt() ?? 0, + locked: json['locked'] == true, + videoUrl: json['playUrl'] as String? ?? '', + ); + } } class Drama { @@ -23,15 +57,74 @@ class Drama { required this.id, required this.title, required this.synopsis, - required this.posterAsset, - required this.episodes, - this.format = DramaFormat.liveAction, + required this.posterUrl, + this.episodes = const [], + this.category = '', + this.genre = '', + this.totalEpisodes = 0, + this.freeEpisodes = 0, + this.isHot = false, + this.viewCount = 0, + this.detailLoaded = false, }); final String id; final String title; final String synopsis; - final String posterAsset; + final String posterUrl; final List episodes; - final DramaFormat format; + final String category; + final String genre; + final int totalEpisodes; + final int freeEpisodes; + final bool isHot; + final int viewCount; + + /// 详情接口是否已经取过完整剧集列表。 + /// 列表卡片只带一集可播预览,需要完整剧集时再进详情。 + final bool detailLoaded; + + /// 卡片只带一集预览,集数以后端给的总集数为准。 + int get episodeCount => totalEpisodes > 0 ? totalEpisodes : episodes.length; + + /// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。 + factory Drama.fromCardJson(Map json) { + final preview = json['preview']; + return Drama( + id: (json['id'] ?? '').toString(), + title: json['title'] as String? ?? '', + synopsis: json['synopsis'] as String? ?? '', + posterUrl: json['cover'] as String? ?? '', + episodes: preview is Map + ? [Episode.fromJson(preview)] + : const [], + category: json['category'] as String? ?? '', + genre: json['genre'] as String? ?? '', + totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0, + isHot: json['isHot'] == true, + // viewCount 后端是 Long,按字符串下发(见 json_numbers.dart) + viewCount: jsonToInt(json['viewCount']), + ); + } + + /// 详情:带剧集列表。 + factory Drama.fromDetailJson(Map json) { + return Drama( + id: (json['id'] ?? '').toString(), + title: json['title'] as String? ?? '', + synopsis: json['synopsis'] as String? ?? '', + posterUrl: json['cover'] as String? ?? '', + category: json['category'] as String? ?? '', + genre: json['genre'] as String? ?? '', + totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0, + freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, + isHot: json['isHot'] == true, + viewCount: jsonToInt(json['viewCount']), + detailLoaded: true, + episodes: (json['episodes'] as List? ?? const []) + .whereType>() + .map(Episode.fromJson) + .toList(), + ); + } } diff --git a/lib/core/data/models/engagement_snapshot.dart b/lib/core/data/models/engagement_snapshot.dart deleted file mode 100644 index 9fe6ff2..0000000 --- a/lib/core/data/models/engagement_snapshot.dart +++ /dev/null @@ -1,13 +0,0 @@ -// Author: fruit.com.cn - -class EngagementSnapshot { - const EngagementSnapshot({ - this.savedDramaIds = const {}, - this.likedDramaIds = const {}, - this.commentsByDrama = const {}, - }); - - final Set savedDramaIds; - final Set likedDramaIds; - final Map> commentsByDrama; -} diff --git a/lib/core/data/models/home_feed.dart b/lib/core/data/models/home_feed.dart new file mode 100644 index 0000000..f16f468 --- /dev/null +++ b/lib/core/data/models/home_feed.dart @@ -0,0 +1,86 @@ +import 'drama.dart'; + +/// 首页/发现页聚合数据,一次请求拿到轮播与分区。 +class HomeFeed { + const HomeFeed({this.banners = const [], this.sections = const []}); + + final List banners; + final List sections; + + bool get isEmpty => sections.every((section) => section.dramas.isEmpty); + + /// 分区打平后的短剧列表,供现有 UI 的横滑/竖滑复用;按 id 去重。 + List get dramas { + final seen = {}; + final result = []; + for (final section in sections) { + for (final drama in section.dramas) { + if (seen.add(drama.id)) result.add(drama); + } + } + return result; + } + + factory HomeFeed.fromJson(Map json) { + final banners = (json['banners'] as List? ?? const []) + .whereType>() + .map(FeedBanner.fromJson) + .toList(); + final sections = (json['sections'] as List? ?? const []) + .whereType>() + .map(FeedSection.fromJson) + .toList(); + return HomeFeed(banners: banners, sections: sections); + } +} + +class FeedSection { + const FeedSection({ + required this.code, + required this.title, + this.dramas = const [], + }); + + final String code; + final String title; + final List dramas; + + factory FeedSection.fromJson(Map json) { + return FeedSection( + code: json['code'] as String? ?? '', + title: json['title'] as String? ?? '', + dramas: (json['dramas'] as List? ?? const []) + .whereType>() + .map(Drama.fromCardJson) + .toList(), + ); + } +} + +class FeedBanner { + const FeedBanner({ + required this.id, + required this.imageUrl, + this.linkType = 'NONE', + this.linkValue = '', + }); + + final String id; + final String imageUrl; + final String linkType; + final String linkValue; + + /// 点击轮播要打开的短剧 id,非短剧跳转返回 null。 + String? get dramaId => linkType == 'DRAMA' && linkValue.isNotEmpty + ? linkValue + : null; + + factory FeedBanner.fromJson(Map json) { + return FeedBanner( + id: (json['id'] ?? '').toString(), + imageUrl: json['imageUrl'] as String? ?? '', + linkType: json['linkType'] as String? ?? 'NONE', + linkValue: json['linkValue'] as String? ?? '', + ); + } +} diff --git a/lib/core/data/models/interaction.dart b/lib/core/data/models/interaction.dart new file mode 100644 index 0000000..d1413e5 --- /dev/null +++ b/lib/core/data/models/interaction.dart @@ -0,0 +1,70 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 互动目标类型,契约只允许短剧与评论。 +enum InteractionTargetType { + drama('DRAMA'), + comment('COMMENT'); + + const InteractionTargetType(this.apiValue); + + final String apiValue; +} + +/// 客户端固定举报原因,后端当前未限制枚举值。 +enum ReportReason { + spam('SPAM'), + abuse('ABUSE'), + porn('PORN'), + ad('AD'), + other('OTHER'); + + const ReportReason(this.apiValue); + + final String apiValue; +} + +/// 一次性互动请求,供仓库和测试断言使用。 +class ReportRequest { + const ReportRequest({ + required this.targetType, + required this.targetId, + required this.reasonCode, + this.description = '', + }); + + final InteractionTargetType targetType; + final String targetId; + final String reasonCode; + final String description; +} + +/// 点赞切换后的服务端状态。 +class LikeToggleResult { + const LikeToggleResult({required this.liked, required this.count}); + + final bool liked; + final int count; + + /// 计数按 Long 字符串兼容解析。 + factory LikeToggleResult.fromJson(Map json) => + LikeToggleResult( + liked: json['liked'] == true, + count: jsonToInt(json['count']), + ); +} + +/// 控制器统一返回结果;失败消息可直接展示后端 `message`。 +class OperationResult { + const OperationResult._(this.data, this.errorMessage); + + const OperationResult.success([T? data]) : this._(data, null); + + const OperationResult.failure(this.errorMessage) : data = null; + + final T? data; + final String? errorMessage; + + bool get isSuccess => errorMessage == null; +} diff --git a/lib/core/data/models/json_numbers.dart b/lib/core/data/models/json_numbers.dart new file mode 100644 index 0000000..9aa91a9 --- /dev/null +++ b/lib/core/data/models/json_numbers.dart @@ -0,0 +1,20 @@ +// Author: fruit.com.cn + +/// 数值字段的兼容解析。 +/// +/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段**一律以字符串下发** +/// (雪花 id 与计数都算),`Integer`/`int` 仍是数字。所以这里既接受数字也接受数字字符串, +/// 否则一个 `"12345"` 就能让 `as num?` 直接抛异常、整页解析失败。 +int jsonToInt(dynamic value, {int fallback = 0}) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value.trim()) ?? fallback; + return fallback; +} + +/// ID 字段统一转字符串;后端按契约下发字符串,同时兼容测试或旧数据的数字形式。 +String jsonToId(dynamic value, {String fallback = ''}) { + if (value is String) return value; + if (value is num) return value.toString(); + return fallback; +} diff --git a/lib/core/data/models/notice.dart b/lib/core/data/models/notice.dart new file mode 100644 index 0000000..94a7b85 --- /dev/null +++ b/lib/core/data/models/notice.dart @@ -0,0 +1,53 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// Server notice list page. +class NoticePage { + const NoticePage({required this.total, this.notices = const []}); + + final int total; + final List notices; + + /// Parses the anonymous `/app/notices` page shell. + factory NoticePage.fromJson(Map json) { + final rawList = json['list']; + return NoticePage( + total: jsonToInt(json['total']), + notices: [ + if (rawList is List) + for (final item in rawList) + if (item is Map) AppNotice.fromJson(item), + ], + ); + } +} + +/// System notice returned by `/app/notices`. +class AppNotice { + const AppNotice({ + required this.id, + required this.title, + required this.content, + required this.type, + required this.publishAt, + this.expireAt, + }); + + final String id; + final String title; + final String content; + final String type; + final DateTime? publishAt; + final DateTime? expireAt; + + /// Parses string IDs without narrowing them through `int`. + factory AppNotice.fromJson(Map json) => AppNotice( + id: jsonToId(json['id']), + title: json['title']?.toString() ?? '', + content: json['content']?.toString() ?? '', + type: json['type']?.toString() ?? '', + publishAt: DateTime.tryParse(json['publishAt']?.toString() ?? ''), + expireAt: DateTime.tryParse(json['expireAt']?.toString() ?? ''), + ); +} diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart new file mode 100644 index 0000000..bb4a899 --- /dev/null +++ b/lib/core/data/models/play_ticket.dart @@ -0,0 +1,55 @@ +import 'json_numbers.dart'; + +/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。 +class PlayTicket { + const PlayTicket({ + required this.episodeId, + required this.accessible, + required this.accessType, + this.dramaId = '', + this.number = 0, + this.title = '', + this.playUrl = '', + this.durationSec = 0, + this.unlockCoin = 0, + this.previewSeconds = 0, + }); + + static const String free = 'FREE'; + static const String coinRequired = 'COIN_REQUIRED'; + static const String vipRequired = 'VIP_REQUIRED'; + static const String noMedia = 'NO_MEDIA'; + + final String episodeId; + final String dramaId; + final int number; + final String title; + final bool accessible; + final String accessType; + + /// 签名播放地址:**原样使用**(含 `?e=&s=`),客户端不拼接、不改写。 + final String playUrl; + final int durationSec; + final int unlockCoin; + + /// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。 + final int previewSeconds; + + bool get needsUnlock => + accessType == coinRequired || accessType == vipRequired; + + factory PlayTicket.fromJson(Map json) { + return PlayTicket( + episodeId: (json['episodeId'] ?? '').toString(), + dramaId: (json['dramaId'] ?? '').toString(), + number: jsonToInt(json['number']), + title: json['title'] as String? ?? '', + accessible: json['accessible'] == true, + accessType: json['accessType'] as String? ?? noMedia, + playUrl: json['playUrl'] as String? ?? '', + durationSec: jsonToInt(json['durationSec']), + unlockCoin: jsonToInt(json['unlockCoin']), + previewSeconds: jsonToInt(json['previewSeconds']), + ); + } +} diff --git a/lib/core/data/models/watch_progress.dart b/lib/core/data/models/watch_progress.dart new file mode 100644 index 0000000..3d655ef --- /dev/null +++ b/lib/core/data/models/watch_progress.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。 +/// +/// 服务端按 `(user, drama)` 只留一条,所以它是"这部剧看到哪一集的哪一秒", +/// 不是逐集进度。ID 与毫秒都是字符串(见 api-phase1.md §0),统一走 jsonToInt 解析。 +class WatchProgress { + const WatchProgress({ + required this.dramaId, + required this.episodeId, + required this.position, + required this.duration, + this.finished = false, + this.playCount = 0, + this.updatedAt, + }); + + final String dramaId; + final String episodeId; + final Duration position; + final Duration duration; + final bool finished; + final int playCount; + final DateTime? updatedAt; + + bool get hasPosition => position > Duration.zero; + + /// 进度比例 0~1,时长未知时为 0,避免 UI 出现 NaN。 + double get fraction { + if (duration <= Duration.zero) return 0; + return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0); + } + + factory WatchProgress.fromJson(Map json) { + return WatchProgress( + dramaId: (json['dramaId'] ?? '').toString(), + episodeId: (json['episodeId'] ?? '').toString(), + position: Duration(milliseconds: jsonToInt(json['positionMs'])), + duration: Duration(milliseconds: jsonToInt(json['durationMs'])), + finished: json['finished'] == true, + playCount: jsonToInt(json['playCount']), + updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''), + ); + } + + Map toJson() => { + 'dramaId': dramaId, + 'episodeId': episodeId, + 'positionMs': position.inMilliseconds, + 'durationMs': duration.inMilliseconds, + 'finished': finished, + 'playCount': playCount, + if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(), + }; +} + +/// 续看点:从哪一集的哪个位置继续看。 +class ResumePoint { + const ResumePoint({required this.episodeId, required this.position}); + + final String episodeId; + final Duration position; +} diff --git a/lib/core/data/models/watch_record.dart b/lib/core/data/models/watch_record.dart new file mode 100644 index 0000000..954b478 --- /dev/null +++ b/lib/core/data/models/watch_record.dart @@ -0,0 +1,44 @@ +// Author: fruit.com.cn + +import 'drama.dart'; +import 'watch_progress.dart'; + +/// 观看记录页的一条数据:进度记录与按 dramaId 补取的详情卡片。 +class WatchRecord { + const WatchRecord({ + required this.progress, + this.drama, + this.detailLoading = true, + this.detailFailed = false, + }); + + final WatchProgress progress; + final Drama? drama; + final bool detailLoading; + final bool detailFailed; + + String get dramaId => progress.dramaId; + + /// 详情中按 episodeId 定位集号;缺失时返回 0。 + int get episodeNumber { + final episodes = drama?.episodes ?? const []; + for (var index = 0; index < episodes.length; index++) { + if (episodes[index].id == progress.episodeId) return index + 1; + } + return 0; + } + + /// 生成卡片加载中、成功或失败后的新状态。 + WatchRecord copyWith({ + Drama? drama, + bool? detailLoading, + bool? detailFailed, + }) { + return WatchRecord( + progress: progress, + drama: drama ?? this.drama, + detailLoading: detailLoading ?? this.detailLoading, + detailFailed: detailFailed ?? this.detailFailed, + ); + } +} diff --git a/lib/core/data/remote/account_api.dart b/lib/core/data/remote/account_api.dart new file mode 100644 index 0000000..44c78bf --- /dev/null +++ b/lib/core/data/remote/account_api.dart @@ -0,0 +1,52 @@ +// Author: fruit.com.cn + +import '../models/account_device.dart'; +import '../models/account_export.dart'; +import '../models/account_profile.dart'; +import 'api_client.dart'; + +/// 个人中心接口:`/app/me*`。 +class AccountApi { + const AccountApi(this._client); + + final ApiClient _client; + + Future profile() async { + final data = await _client.get('/app/me'); + return AccountProfile.fromJson(_map(data)); + } + + Future updateProfile(AccountProfileUpdate update) async { + final data = await _client.put('/app/me', body: update.toJson()); + return AccountProfile.fromJson(_map(data)); + } + + Future> devices() async { + final data = await _client.get('/app/me/devices'); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) AccountDevice.fromJson(item.cast()), + ]; + } + + Future export() async { + final data = await _client.get('/app/me/export'); + return AccountExport.fromJson(_map(data)); + } + + Future deactivate({String reason = ''}) async { + final data = await _client.post( + '/app/me/deactivate', + body: {'reason': reason.trim()}, + ); + return DateTime.tryParse(data?.toString() ?? ''); + } + + Future cancelDeactivation() async { + await _client.post('/app/me/deactivate/cancel'); + } + + Map _map(Object? data) => + data is Map ? data.cast() : const {}; +} diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart new file mode 100644 index 0000000..906a66d --- /dev/null +++ b/lib/core/data/remote/api_client.dart @@ -0,0 +1,154 @@ +import 'package:dio/dio.dart'; + +import '../../config/app_config.dart'; +import 'api_exception.dart'; +import 'api_interceptors.dart'; + +/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。 +export 'api_exception.dart'; + +/// 后端统一响应壳 `{code, message, data}` 的解析入口。 +/// +/// 职责: +/// - 用 dio 发请求(`AuthHeaderInterceptor` 注入令牌与设备号,`EnvelopeInterceptor` 解包与映射错误码); +/// - 编排「401 → 刷新令牌 → 重发一次」——**刻意不放在拦截器里重发**,因为 dio 的排队拦截器 +/// 在回调里再发起请求会把自己排到队尾,造成自锁。 +class ApiClient { + ApiClient({ + String? baseUrl, + HttpClientAdapter? adapter, + this.accessTokenReader, + this.tokenRefresher, + this.onUnauthorized, + this.deviceIdReader, + }) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl { + _dio = Dio( + BaseOptions( + baseUrl: this.baseUrl, + connectTimeout: AppConfig.requestTimeout, + receiveTimeout: AppConfig.requestTimeout, + // 拿原始字节自己解码:后端消息是 UTF-8,且"不是 JSON 的响应体"要能被识别出来 + responseType: ResponseType.bytes, + // 非 2xx 不抛异常:业务结果由响应体里的 code 表达(与旧实现一致) + validateStatus: (_) => true, + ), + ); + _dio.interceptors.addAll([ + AuthHeaderInterceptor( + accessTokenReader: accessTokenReader, + deviceIdReader: deviceIdReader, + ), + const EnvelopeInterceptor(), + ]); + if (adapter != null) { + _dio.httpClientAdapter = adapter; + } + } + + final String baseUrl; + late final Dio _dio; + + /// 取当前 access token(未登录返回 null) + final String? Function()? accessTokenReader; + + /// 刷新令牌:返回 true 表示刷新成功,可以重试原请求 + final Future Function()? tokenRefresher; + + /// 刷新也失败时回调(清本地登录态、回收 UI) + final void Function()? onUnauthorized; + + /// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用 + final Future Function()? deviceIdReader; + + Future get(String path, {Map? query}) { + return _request('GET', path, query: query); + } + + Future post( + String path, { + Object? body, + Map? query, + bool skipAuthRetry = false, + }) { + return _request( + 'POST', + path, + body: body, + query: query, + skipAuthRetry: skipAuthRetry, + ); + } + + Future put( + String path, { + Object? body, + Map? query, + bool skipAuthRetry = false, + }) { + return _request( + 'PUT', + path, + body: body, + query: query, + skipAuthRetry: skipAuthRetry, + ); + } + + Future delete( + String path, { + Object? body, + Map? query, + }) { + return _request('DELETE', path, body: body, query: query); + } + + Future _request( + String method, + String path, { + Object? body, + Map? query, + bool retried = false, + bool skipAuthRetry = false, + }) async { + final disableAuthRetry = skipAuthRetry || tokenRefresher == null; + try { + final response = await _dio.request( + path, + data: body, + queryParameters: (query?.isEmpty ?? true) ? null : query, + options: Options( + method: method, + contentType: body == null ? null : Headers.jsonContentType, + extra: { + ApiRequestFlags.disableAuthRetry: disableAuthRetry, + ApiRequestFlags.retried: retried, + }, + ), + ); + return response.data; + } on DioException catch (error) { + final cause = error.error; + if (cause is TokenExpiredException) { + final refreshed = await tokenRefresher?.call() ?? false; + if (refreshed) { + return _request( + method, + path, + body: body, + query: query, + retried: true, + skipAuthRetry: skipAuthRetry, + ); + } + onUnauthorized?.call(); + throw ApiException(401, cause.message ?? '登录已过期'); + } + if (cause is ApiException) { + throw cause; + } + rethrow; + } + } + + void close() => _dio.close(force: true); +} diff --git a/lib/core/data/remote/api_exception.dart b/lib/core/data/remote/api_exception.dart new file mode 100644 index 0000000..abd2226 --- /dev/null +++ b/lib/core/data/remote/api_exception.dart @@ -0,0 +1,15 @@ +// Author: fruit.com.cn + +/// 接口异常:code 为后端业务码,message 可直接展示。 +/// +/// 单独成文件是为了让 [ApiClient] 与拦截器都能引用它而不互相 import +/// (`api_client.dart` 会 `export` 本文件,历史调用方继续 `import 'api_client.dart'` 即可拿到它)。 +class ApiException implements Exception { + const ApiException(this.code, this.message); + + final int code; + final String message; + + @override + String toString() => 'ApiException($code, $message)'; +} diff --git a/lib/core/data/remote/api_interceptors.dart b/lib/core/data/remote/api_interceptors.dart new file mode 100644 index 0000000..a48087d --- /dev/null +++ b/lib/core/data/remote/api_interceptors.dart @@ -0,0 +1,133 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import 'api_exception.dart'; + +/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。 +class ApiRequestFlags { + const ApiRequestFlags._(); + + /// 本次请求不参与「401 → 刷新 → 重试」:刷新令牌自身、登出,以及没有刷新回调时。 + static const String disableAuthRetry = 'app.auth.disableRetry'; + + /// 本次请求已经是刷新后的重试,见到它就不再刷新,避免无限循环。 + static const String retried = 'app.auth.retried'; +} + +/// 内部信号:响应体里 `code == 401`,应当由 `ApiClient` 刷新令牌后重发一次。 +/// +/// 只在本层流转,不会漏给调用方 —— 调用方拿到的始终是 `ApiException`。 +class TokenExpiredException implements Exception { + const TokenExpiredException(this.message); + + /// 后端返回的提示语(可能为空),用于刷新失败时拼 `ApiException` 的消息。 + final String? message; +} + +/// 注入鉴权与设备标识请求头。 +/// +/// 每个请求都重新取一次令牌,所以刷新成功后重发的那次会自动带上新令牌。 +class AuthHeaderInterceptor extends Interceptor { + AuthHeaderInterceptor({this.accessTokenReader, this.deviceIdReader}); + + /// 当前 access token;未登录返回 null 或空串。 + final String? Function()? accessTokenReader; + + /// 设备号(`X-Device-Id`):注册风控、下载绑定都用它。 + final Future Function()? deviceIdReader; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + options.headers['Accept'] = 'application/json'; + final token = accessTokenReader?.call(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + final deviceId = await deviceIdReader?.call(); + if (deviceId != null && deviceId.isNotEmpty) { + options.headers['X-Device-Id'] = deviceId; + } + handler.next(options); + } +} + +/// 解包后端统一响应壳 `{code,message,data}`,并把业务码映射成异常。 +/// +/// 判定口径与旧的 `http` 实现逐条对齐: +/// - body 不是合法 JSON → 视为 `code = -1`,抛 `ApiException(状态码, 'HTTP <状态码>')` +/// - `code != 0` → 抛 `ApiException(code, message ?? '请求失败')` +/// - `code == 401` 且允许刷新 → 抛 [TokenExpiredException] 作为信号(由 `ApiClient` 刷新后重发) +class EnvelopeInterceptor extends Interceptor { + const EnvelopeInterceptor(); + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + final options = response.requestOptions; + final shell = _decodeShell(response.data); + final code = shell['code'] as int? ?? -1; + final message = shell['message'] as String?; + final retried = options.extra[ApiRequestFlags.retried] == true; + final disableRetry = + options.extra[ApiRequestFlags.disableAuthRetry] == true; + + if (code == 401 && !retried && !disableRetry) { + return handler.reject( + DioException( + requestOptions: options, + response: response, + type: DioExceptionType.badResponse, + error: TokenExpiredException(message), + ), + true, + ); + } + if (code == -1) { + final status = response.statusCode ?? -1; + return handler.reject( + DioException( + requestOptions: options, + response: response, + type: DioExceptionType.badResponse, + error: ApiException(status, 'HTTP $status'), + ), + true, + ); + } + if (code != 0) { + return handler.reject( + DioException( + requestOptions: options, + response: response, + type: DioExceptionType.badResponse, + error: ApiException(code, message ?? '请求失败'), + ), + true, + ); + } + response.data = shell['data']; + handler.next(response); + } + + /// 统一按 UTF-8 解码:后端的中文提示语不能因为缺 charset 而乱码。 + Map _decodeShell(Object? body) { + final text = switch (body) { + List bytes => utf8.decode(bytes, allowMalformed: true), + String text => text, + _ => '', + }; + try { + return (jsonDecode(text) as Map).cast(); + } catch (_) { + return const {}; + } + } +} diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart new file mode 100644 index 0000000..175f753 --- /dev/null +++ b/lib/core/data/remote/auth_api.dart @@ -0,0 +1,73 @@ +import '../models/auth_session.dart'; +import 'api_client.dart'; + +/// 账号相关接口(/app/auth/**)。 +/// +/// 注册/登录都用「邮箱 + 密码」;邮箱注册不需要验证码(后端按 targetType 放开), +/// 手机号注册仍要验证码 —— 客户端这一批只开放邮箱入口。 +class AuthApi { + const AuthApi(this._client); + + final ApiClient _client; + + Future register({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + final data = await _client.post( + '/app/auth/register', + body: { + 'targetType': 'EMAIL', + 'target': email, + 'password': password, + 'countryCode': 'ID', + 'deviceId': deviceId, + 'platform': platform, + 'appVersion': appVersion, + }, + ); + return AuthSession.fromJson((data as Map).cast()); + } + + Future login({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + final data = await _client.post( + '/app/auth/login', + body: { + 'targetType': 'EMAIL', + 'target': email, + 'password': password, + 'deviceId': deviceId, + 'platform': platform, + 'appVersion': appVersion, + }, + ); + return AuthSession.fromJson((data as Map).cast()); + } + + Future refresh(String refreshToken) async { + final data = await _client.post( + '/app/auth/refresh', + body: {'refreshToken': refreshToken}, + // 刷新请求本身不带旧令牌重试,失败就是失败 + skipAuthRetry: true, + ); + return AuthSession.fromJson((data as Map).cast()); + } + + Future logout(String refreshToken) async { + await _client.post( + '/app/auth/logout', + body: {'refreshToken': refreshToken}, + skipAuthRetry: true, + ); + } +} diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart new file mode 100644 index 0000000..4346460 --- /dev/null +++ b/lib/core/data/remote/catalog_api.dart @@ -0,0 +1,29 @@ +import '../models/drama.dart'; +import '../models/home_feed.dart'; +import 'api_client.dart'; + +/// 客户端目录接口,对应后端 `/app/**`。 +class CatalogApi { + const CatalogApi(this._client); + + final ApiClient _client; + + Future homeFeed() async { + return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed'))); + } + + Future discoverFeed() async { + return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); + } + + Future dramaDetail(String dramaId) async { + return Drama.fromDetailJson( + _asMap(await _client.get('/app/dramas/$dramaId')), + ); + } + + Map _asMap(Object? data) { + if (data is Map) return data; + throw const ApiException(-1, '返回数据格式异常'); + } +} diff --git a/lib/core/data/remote/category_api.dart b/lib/core/data/remote/category_api.dart new file mode 100644 index 0000000..7c2d324 --- /dev/null +++ b/lib/core/data/remote/category_api.dart @@ -0,0 +1,21 @@ +// Author: fruit.com.cn + +import '../models/content_category.dart'; +import 'api_client.dart'; + +/// 内容分类接口,对应契约 `GET /app/categories`。 +class CategoryApi { + const CategoryApi(this._client); + + final ApiClient _client; + + /// 拉取已启用分类,id 全程按字符串解析。 + Future> list() async { + final data = await _client.get('/app/categories'); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) ContentCategory.fromJson(item), + ]; + } +} diff --git a/lib/core/data/remote/comment_api.dart b/lib/core/data/remote/comment_api.dart new file mode 100644 index 0000000..f99438e --- /dev/null +++ b/lib/core/data/remote/comment_api.dart @@ -0,0 +1,59 @@ +// Author: fruit.com.cn + +import '../models/comment.dart'; +import 'api_client.dart'; + +/// 评论接口,对应契约 §1.7 的 `/app/comments*`。 +class CommentApi { + const CommentApi(this._client); + + final ApiClient _client; + + /// 拉取游标分页;`rootId` 用于加载某一楼层的回复。 + Future page({ + required String dramaId, + String? rootId, + String? cursor, + int size = commentPageSize, + }) async { + final data = await _client.get( + '/app/comments', + query: { + 'dramaId': dramaId, + 'rootId': ?rootId, + 'cursor': ?cursor, + 'size': '$size', + }, + ); + if (data is! Map) return const CommentPage(); + return CommentPage.fromJson(data); + } + + /// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。 + Future publish({ + required String dramaId, + String? episodeId, + required String content, + String? parentId, + }) async { + final data = await _client.post( + '/app/comments', + body: { + 'dramaId': dramaId, + if (episodeId != null && episodeId.isNotEmpty) + 'episodeId': episodeId, + 'content': content, + if (parentId != null && parentId.isNotEmpty) 'parentId': parentId, + }, + ); + if (data is! Map) { + throw const ApiException(500, '评论响应格式错误'); + } + return DramaComment.fromJson(data); + } + + /// 删除自己的评论。 + Future delete(String commentId) async { + await _client.delete('/app/comments/$commentId'); + } +} diff --git a/lib/core/data/remote/download_api.dart b/lib/core/data/remote/download_api.dart new file mode 100644 index 0000000..ae8e744 --- /dev/null +++ b/lib/core/data/remote/download_api.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import '../models/download_license.dart'; +import '../models/download_policy.dart'; +import 'api_client.dart'; + +/// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。 +/// +/// 设备号由 [ApiClient] 统一通过 `X-Device-Id` 发送,本类不另造设备参数。 +class DownloadApi { + const DownloadApi(this._client); + + final ApiClient _client; + + /// 拉取配额和偏好策略。 + Future policy() async { + return DownloadPolicy.fromJson( + _asMap(await _client.get('/app/downloads/policy')), + ); + } + + /// 更新仅 Wi-Fi 与最高清晰度偏好。 + Future updateSettings({bool? wifiOnly, String? maxQuality}) async { + await _client.put( + '/app/downloads/settings', + body: {'wifiOnly': ?wifiOnly, 'maxQuality': ?maxQuality}, + ); + } + + /// 申请下载授权;同集重复申请由服务端保证幂等。 + Future grant(String episodeId, {String? quality}) async { + return DownloadLicense.fromJson( + _asMap( + await _client.post( + '/app/downloads', + body: {'episodeId': episodeId, 'quality': ?quality}, + ), + ), + ); + } + + /// 拉取当前设备的授权列表。 + Future> list() async { + final data = await _client.get('/app/downloads'); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) DownloadLicense.fromJson(item), + ]; + } + + /// 续传前刷新短时效下载地址。 + Future refreshUrl(String episodeId) async { + return DownloadLicense.fromJson( + _asMap(await _client.get('/app/downloads/$episodeId/url')), + ); + } + + /// 上报下载进度;本批只提供方法,不启动文件落盘。 + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async { + return DownloadLicense.fromJson( + _asMap( + await _client.post( + '/app/downloads/$episodeId/progress', + body: { + 'deviceId': ?deviceId, + 'status': ?status, + 'progressPercent': ?progressPercent, + 'downloadedBytes': ?downloadedBytes, + 'totalBytes': ?totalBytes, + 'errorMsg': ?errorMsg, + }, + ), + ), + ); + } + + /// 吊销单集授权。 + Future remove(String episodeId) async { + await _client.delete('/app/downloads/$episodeId'); + } + + /// 清空当前设备授权并返回清除条数。 + Future clear() async { + final data = await _client.delete('/app/downloads'); + return data is int ? data : 0; + } + + Map _asMap(Object? data) { + if (data is Map) return data; + throw const ApiException(-1, ''); + } +} diff --git a/lib/core/data/remote/interaction_api.dart b/lib/core/data/remote/interaction_api.dart new file mode 100644 index 0000000..6fd1a45 --- /dev/null +++ b/lib/core/data/remote/interaction_api.dart @@ -0,0 +1,44 @@ +// Author: fruit.com.cn + +import '../models/interaction.dart'; +import 'api_client.dart'; + +/// 点赞与举报接口,对应契约 §1.7。 +class InteractionApi { + const InteractionApi(this._client); + + final ApiClient _client; + + /// 切换点赞,服务端返回的状态与计数为最终真相。 + Future toggleLike({ + required InteractionTargetType targetType, + required String targetId, + }) async { + final data = await _client.post( + '/app/likes/toggle', + body: { + 'targetType': targetType.apiValue, + 'targetId': targetId, + }, + ); + if (data is! Map) { + throw const ApiException(500, '点赞响应格式错误'); + } + return LikeToggleResult.fromJson(data); + } + + /// 提交举报,返回工单 ID。 + Future report(ReportRequest request) async { + final data = await _client.post( + '/app/reports', + body: { + 'targetType': request.targetType.apiValue, + 'targetId': request.targetId, + 'reasonCode': request.reasonCode, + if (request.description.isNotEmpty) + 'description': request.description, + }, + ); + return data?.toString() ?? ''; + } +} diff --git a/lib/core/data/remote/library_api.dart b/lib/core/data/remote/library_api.dart new file mode 100644 index 0000000..14c09b7 --- /dev/null +++ b/lib/core/data/remote/library_api.dart @@ -0,0 +1,35 @@ +// Author: fruit.com.cn + +import '../models/drama.dart'; +import 'api_client.dart'; + +/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。 +/// +/// 列表返回的是与首页一致的卡片结构,所以直接用 `Drama.fromCardJson` 解析。 +class LibraryApi { + const LibraryApi(this._client); + + final ApiClient _client; + + /// 切换收藏,返回切换后的状态(true = 已收藏),客户端以它为唯一真相。 + Future toggleFavorite(String dramaId) async { + final data = await _client.post( + '/app/favorites/toggle', + body: {'dramaId': dramaId}, + ); + return data == true; + } + + Future> favorites() async { + final data = await _client.get('/app/favorites'); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) Drama.fromCardJson(item), + ]; + } + + Future removeFavorite(String dramaId) async { + await _client.delete('/app/favorites/$dramaId'); + } +} diff --git a/lib/core/data/remote/notice_api.dart b/lib/core/data/remote/notice_api.dart new file mode 100644 index 0000000..36111f6 --- /dev/null +++ b/lib/core/data/remote/notice_api.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import '../models/notice.dart'; +import 'api_client.dart'; + +/// Anonymous system notice API. +class NoticeApi { + const NoticeApi(this._client); + + final ApiClient _client; + + /// Loads one page from `GET /app/notices`. + Future page({required int pageIndex, int pageSize = 20}) async { + final data = await _client.get( + '/app/notices', + query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, + ); + if (data is! Map) { + return const NoticePage(total: 0); + } + return NoticePage.fromJson(data); + } +} diff --git a/lib/core/data/remote/playback_api.dart b/lib/core/data/remote/playback_api.dart new file mode 100644 index 0000000..0122d95 --- /dev/null +++ b/lib/core/data/remote/playback_api.dart @@ -0,0 +1,20 @@ +// Author: fruit.com.cn + +import '../models/play_ticket.dart'; +import 'api_client.dart'; + +/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。 +/// +/// **签名地址原样使用**:后端下发的 `playUrl` 带 `?e=过期时间&s=签名`, +/// 客户端只做「相对路径补 host」,不拼接、不改写、不丢 query —— 否则签名校验直接失败。 +class PlaybackApi { + const PlaybackApi(this._client); + + final ApiClient _client; + + Future ticket(String episodeId) async { + final data = await _client.get('/app/episodes/$episodeId/play'); + if (data is Map) return PlayTicket.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } +} diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart new file mode 100644 index 0000000..f57d7fb --- /dev/null +++ b/lib/core/data/remote/watch_api.dart @@ -0,0 +1,64 @@ +// Author: fruit.com.cn + +import '../models/watch_progress.dart'; +import 'api_client.dart'; + +/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。 +class WatchApi { + const WatchApi(this._client); + + final ApiClient _client; + + /// 上报进度。服务端从 `episodeId` 推出所属剧集,并保证「同集只前进不后退」。 + Future saveProgress({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + String source = 'app', + }) async { + final data = await _client.post( + '/app/watch/progress', + body: { + 'episodeId': episodeId, + 'positionMs': position.inMilliseconds, + if (duration != null) 'durationMs': duration.inMilliseconds, + 'finished': finished, + 'source': source, + }, + ); + return data is Map ? WatchProgress.fromJson(data) : null; + } + + /// 某部剧的续看位置;没有记录时后端返回空 data。 + Future progressOfDrama(String dramaId) async { + final data = await _client.get( + '/app/watch/progress', + query: {'dramaId': dramaId}, + ); + return data is Map ? WatchProgress.fromJson(data) : null; + } + + /// 最近观看(服务端按下标返回,已按更新时间倒序)。 + Future> recent({int limit = 20}) async { + final data = await _client.get( + '/app/watch/recent', + query: {'limit': '$limit'}, + ); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) WatchProgress.fromJson(item), + ]; + } + + /// 删除某部剧的观看记录(记录页清单条用)。 + Future removeProgress(String dramaId) async { + await _client.delete('/app/watch/progress/$dramaId'); + } + + /// 清空当前账号的全部观看记录。 + Future clearProgress() async { + await _client.delete('/app/watch/progress'); + } +} diff --git a/lib/core/data/repositories/account_repository.dart b/lib/core/data/repositories/account_repository.dart new file mode 100644 index 0000000..c7d029c --- /dev/null +++ b/lib/core/data/repositories/account_repository.dart @@ -0,0 +1,48 @@ +// Author: fruit.com.cn + +import '../models/account_device.dart'; +import '../models/account_export.dart'; +import '../models/account_profile.dart'; +import '../remote/account_api.dart'; + +/// 账号中心的数据来源。 +abstract interface class AccountRepository { + Future profile(); + + Future updateProfile(AccountProfileUpdate update); + + Future> devices(); + + Future export(); + + Future deactivate(String reason); + + Future cancelDeactivation(); +} + +/// HTTP 实现。 +class HttpAccountRepository implements AccountRepository { + const HttpAccountRepository(this._api); + + final AccountApi _api; + + @override + Future profile() => _api.profile(); + + @override + Future updateProfile(AccountProfileUpdate update) => + _api.updateProfile(update); + + @override + Future> devices() => _api.devices(); + + @override + Future export() => _api.export(); + + @override + Future deactivate(String reason) => + _api.deactivate(reason: reason); + + @override + Future cancelDeactivation() => _api.cancelDeactivation(); +} diff --git a/lib/core/data/repositories/catalog_repository.dart b/lib/core/data/repositories/catalog_repository.dart index 9948260..a36472a 100644 --- a/lib/core/data/repositories/catalog_repository.dart +++ b/lib/core/data/repositories/catalog_repository.dart @@ -1,106 +1,33 @@ // Author: fruit.com.cn import '../models/drama.dart'; +import '../models/home_feed.dart'; +import '../remote/catalog_api.dart'; +/// 目录数据来源:首页/发现页聚合、详情。 +/// +/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。 +/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。 abstract interface class CatalogRepository { - Future> fetchDramas(); + Future fetchHomeFeed(); + + Future fetchDiscoverFeed(); + + Future fetchDrama(String dramaId); } -class DemoCatalogRepository implements CatalogRepository { +class HttpCatalogRepository implements CatalogRepository { + HttpCatalogRepository(this._api); + + final CatalogApi _api; + @override - Future> fetchDramas() async => const [ - Drama( - id: 'the-signal-demo', - title: 'The Signal', - synopsis: - 'A mock series for testing vertical episode playback with one reused local clip.', - posterAsset: 'assets/images/stitch_signal_poster_clean.png', - format: DramaFormat.animated, - episodes: [ - Episode( - id: 'signal-01', - number: 1, - title: 'Episode 1', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - Episode( - id: 'signal-02', - number: 2, - title: 'Episode 2', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - Episode( - id: 'signal-03', - number: 3, - title: 'Episode 3', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - ], - ), - Drama( - id: 'city-lights-demo', - title: 'City Lights & Velvet Nights', - synopsis: - 'A mock romance series using the same local video for navigation testing.', - posterAsset: 'assets/images/stitch_city_poster_clean.png', - format: DramaFormat.liveAction, - episodes: [ - Episode( - id: 'city-01', - number: 1, - title: 'Episode 1', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - Episode( - id: 'city-02', - number: 2, - title: 'Episode 2', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - Episode( - id: 'city-03', - number: 3, - title: 'Episode 3', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - ], - ), - Drama( - id: 'vengeance-demo', - title: 'Vengeance Rising', - synopsis: - 'A mock action series using the same local video for navigation testing.', - posterAsset: 'assets/images/stitch_vengeance_poster_clean.png', - format: DramaFormat.liveAction, - episodes: [ - Episode( - id: 'vengeance-01', - number: 1, - title: 'Episode 1', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - Episode( - id: 'vengeance-02', - number: 2, - title: 'Episode 2', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - Episode( - id: 'vengeance-03', - number: 3, - title: 'Episode 3', - assetPath: 'assets/media/demo_clip.mp4', - isFree: true, - ), - ], - ), - ]; + Future fetchHomeFeed() => _api.homeFeed(); + + @override + Future fetchDiscoverFeed() => _api.discoverFeed(); + + @override + Future fetchDrama(String dramaId) => _api.dramaDetail(dramaId); + } diff --git a/lib/core/data/repositories/category_repository.dart b/lib/core/data/repositories/category_repository.dart new file mode 100644 index 0000000..39b6a11 --- /dev/null +++ b/lib/core/data/repositories/category_repository.dart @@ -0,0 +1,30 @@ +// Author: fruit.com.cn + +import '../models/content_category.dart'; +import '../models/home_feed.dart'; +import '../remote/category_api.dart'; +import 'catalog_repository.dart'; + +/// 发现页分类数据来源:分类清单与发现 feed。 +abstract interface class CategoryRepository { + /// 拉取服务端分类。 + Future> fetchCategories(); + + /// 拉取发现页分区;分类内容只从这里读取。 + Future fetchDiscoverFeed(); +} + +/// HTTP 实现。发现 feed 复用既有目录仓库,不新增接口。 +class HttpCategoryRepository implements CategoryRepository { + const HttpCategoryRepository(this._categoryApi, this._catalogRepository); + + final CategoryApi _categoryApi; + final CatalogRepository _catalogRepository; + + @override + Future> fetchCategories() => _categoryApi.list(); + + @override + Future fetchDiscoverFeed() => + _catalogRepository.fetchDiscoverFeed(); +} diff --git a/lib/core/data/repositories/comment_repository.dart b/lib/core/data/repositories/comment_repository.dart new file mode 100644 index 0000000..92d93e2 --- /dev/null +++ b/lib/core/data/repositories/comment_repository.dart @@ -0,0 +1,62 @@ +// Author: fruit.com.cn + +import '../models/comment.dart'; +import '../remote/comment_api.dart'; + +/// 评论数据来源抽象,页面与控制器不直接依赖 HTTP。 +abstract interface class CommentRepository { + /// 拉取评论或回复的分页。 + Future page({ + required String dramaId, + String? rootId, + String? cursor, + int size = commentPageSize, + }); + + /// 发表评论或回复。 + Future publish({ + required String dramaId, + String? episodeId, + required String content, + String? parentId, + }); + + /// 删除指定评论。 + Future delete(String commentId); +} + +/// HTTP 评论仓库。 +class HttpCommentRepository implements CommentRepository { + const HttpCommentRepository(this._api); + + final CommentApi _api; + + @override + Future page({ + required String dramaId, + String? rootId, + String? cursor, + int size = commentPageSize, + }) => _api.page( + dramaId: dramaId, + rootId: rootId, + cursor: cursor, + size: size, + ); + + @override + Future publish({ + required String dramaId, + String? episodeId, + required String content, + String? parentId, + }) => _api.publish( + dramaId: dramaId, + episodeId: episodeId, + content: content, + parentId: parentId, + ); + + @override + Future delete(String commentId) => _api.delete(commentId); +} diff --git a/lib/core/data/repositories/download_repository.dart b/lib/core/data/repositories/download_repository.dart new file mode 100644 index 0000000..21263fc --- /dev/null +++ b/lib/core/data/repositories/download_repository.dart @@ -0,0 +1,82 @@ +// Author: fruit.com.cn + +import '../models/download_license.dart'; +import '../models/download_policy.dart'; +import '../remote/download_api.dart'; + +/// 下载授权数据来源,页面只依赖该抽象。 +abstract interface class DownloadRepository { + Future fetchPolicy(); + + Future> list(); + + Future grant(String episodeId, {String? quality}); + + Future updateSettings({bool? wifiOnly, String? maxQuality}); + + Future refreshUrl(String episodeId); + + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }); + + Future remove(String episodeId); + + Future clear(); +} + +/// HTTP 下载授权仓库。 +class HttpDownloadRepository implements DownloadRepository { + const HttpDownloadRepository(this._api); + + final DownloadApi _api; + + @override + Future fetchPolicy() => _api.policy(); + + @override + Future> list() => _api.list(); + + @override + Future grant(String episodeId, {String? quality}) => + _api.grant(episodeId, quality: quality); + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) => + _api.updateSettings(wifiOnly: wifiOnly, maxQuality: maxQuality); + + @override + Future refreshUrl(String episodeId) => + _api.refreshUrl(episodeId); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) => _api.reportProgress( + episodeId, + deviceId: deviceId, + status: status, + progressPercent: progressPercent, + downloadedBytes: downloadedBytes, + totalBytes: totalBytes, + errorMsg: errorMsg, + ); + + @override + Future remove(String episodeId) => _api.remove(episodeId); + + @override + Future clear() => _api.clear(); +} diff --git a/lib/core/data/repositories/engagement_repository.dart b/lib/core/data/repositories/engagement_repository.dart deleted file mode 100644 index c7067bf..0000000 --- a/lib/core/data/repositories/engagement_repository.dart +++ /dev/null @@ -1,70 +0,0 @@ -// Author: fruit.com.cn - -import 'dart:convert'; - -import 'package:shared_preferences/shared_preferences.dart'; - -import '../models/engagement_snapshot.dart'; - -abstract interface class EngagementRepository { - Future load(); - Future save(EngagementSnapshot snapshot); -} - -class SharedPreferencesEngagementRepository implements EngagementRepository { - SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences}) - : _preferences = preferences ?? SharedPreferencesAsync(); - - static const _key = 'engagement.v1.demo-local-v1'; - final SharedPreferencesAsync _preferences; - - @override - Future load() async { - final raw = await _preferences.getString(_key); - if (raw == null) return const EngagementSnapshot(); - try { - final json = jsonDecode(raw); - if (json is! Map) return const EngagementSnapshot(); - Set ids(Object? value) => - value is List ? value.whereType().toSet() : {}; - final comments = >{}; - final rawComments = json['comments']; - if (rawComments is Map) { - for (final entry in rawComments.entries) { - if (entry.value is List) { - comments[entry.key] = (entry.value as List) - .whereType() - .toList(); - } - } - } - return EngagementSnapshot( - savedDramaIds: ids(json['saved']), - likedDramaIds: ids(json['liked']), - commentsByDrama: comments, - ); - } catch (_) { - return const EngagementSnapshot(); - } - } - - @override - Future save(EngagementSnapshot snapshot) => _preferences.setString( - _key, - jsonEncode({ - 'saved': snapshot.savedDramaIds.toList(), - 'liked': snapshot.likedDramaIds.toList(), - 'comments': snapshot.commentsByDrama, - }), - ); -} - -class InMemoryEngagementRepository implements EngagementRepository { - EngagementSnapshot snapshot = const EngagementSnapshot(); - - @override - Future load() async => snapshot; - - @override - Future save(EngagementSnapshot value) async => snapshot = value; -} diff --git a/lib/core/data/repositories/favorite_repository.dart b/lib/core/data/repositories/favorite_repository.dart new file mode 100644 index 0000000..1329adf --- /dev/null +++ b/lib/core/data/repositories/favorite_repository.dart @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import '../models/drama.dart'; +import '../remote/library_api.dart'; + +/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。 +abstract interface class FavoriteRepository { + /// 切换收藏,返回切换后的状态(true = 已收藏)。 + Future toggle(String dramaId); + + /// 收藏列表(卡片结构与首页一致)。 + Future> list(); + + Future remove(String dramaId); +} + +class HttpFavoriteRepository implements FavoriteRepository { + const HttpFavoriteRepository(this._api, {this.isSignedIn}); + + final LibraryApi _api; + + /// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。 + final bool Function()? isSignedIn; + + bool get _signedIn => isSignedIn?.call() ?? true; + + @override + Future toggle(String dramaId) => _api.toggleFavorite(dramaId); + + @override + Future> list() async { + if (!_signedIn) return const []; + return _api.favorites(); + } + + @override + Future remove(String dramaId) => _api.removeFavorite(dramaId); +} + +/// 内存实现:单测与 widget 测试注入用。 +class InMemoryFavoriteRepository implements FavoriteRepository { + InMemoryFavoriteRepository({Set? saved, List? cards}) + : _saved = {...?saved}, + _cards = [...?cards]; + + final Set _saved; + final List _cards; + bool failToggle = false; + bool failList = false; + bool failRemove = false; + int toggleCalls = 0; + int listCalls = 0; + + @override + Future toggle(String dramaId) async { + toggleCalls += 1; + if (failToggle) throw StateError('favorite unavailable'); + if (!_saved.add(dramaId)) _saved.remove(dramaId); + return _saved.contains(dramaId); + } + + @override + Future> list() async { + listCalls++; + if (failList) throw StateError('favorite unavailable'); + return _cards.where((card) => _saved.contains(card.id)).toList(); + } + + @override + Future remove(String dramaId) async { + if (failRemove) throw StateError('favorite unavailable'); + _saved.remove(dramaId); + } +} diff --git a/lib/core/data/repositories/interaction_repository.dart b/lib/core/data/repositories/interaction_repository.dart new file mode 100644 index 0000000..d7a8b09 --- /dev/null +++ b/lib/core/data/repositories/interaction_repository.dart @@ -0,0 +1,32 @@ +// Author: fruit.com.cn + +import '../models/interaction.dart'; +import '../remote/interaction_api.dart'; + +/// 点赞与举报数据来源抽象。 +abstract interface class InteractionRepository { + /// 切换目标点赞状态。 + Future toggleLike({ + required InteractionTargetType targetType, + required String targetId, + }); + + /// 提交举报并返回工单 ID。 + Future report(ReportRequest request); +} + +/// HTTP 互动仓库。 +class HttpInteractionRepository implements InteractionRepository { + const HttpInteractionRepository(this._api); + + final InteractionApi _api; + + @override + Future toggleLike({ + required InteractionTargetType targetType, + required String targetId, + }) => _api.toggleLike(targetType: targetType, targetId: targetId); + + @override + Future report(ReportRequest request) => _api.report(request); +} diff --git a/lib/core/data/repositories/notice_repository.dart b/lib/core/data/repositories/notice_repository.dart new file mode 100644 index 0000000..d7d79ba --- /dev/null +++ b/lib/core/data/repositories/notice_repository.dart @@ -0,0 +1,21 @@ +// Author: fruit.com.cn + +import '../models/notice.dart'; +import '../remote/notice_api.dart'; + +/// Notice data source used by the page and controller. +abstract interface class NoticeRepository { + /// Loads one notice page. + Future page({required int pageIndex, int pageSize = 20}); +} + +/// HTTP-backed notice repository. +class HttpNoticeRepository implements NoticeRepository { + const HttpNoticeRepository(this._api); + + final NoticeApi _api; + + @override + Future page({required int pageIndex, int pageSize = 20}) => + _api.page(pageIndex: pageIndex, pageSize: pageSize); +} diff --git a/lib/core/data/repositories/playback_repository.dart b/lib/core/data/repositories/playback_repository.dart new file mode 100644 index 0000000..24bc443 --- /dev/null +++ b/lib/core/data/repositories/playback_repository.dart @@ -0,0 +1,96 @@ +// Author: fruit.com.cn + +import '../models/play_ticket.dart'; +import '../remote/playback_api.dart'; + +/// 播放票据来源。 +/// +/// 票据里的签名地址有有效期(契约 §1.4:签名默认 15 分钟),所以**不能一直缓存**: +/// [ticketFor] 命中未过期的缓存就用缓存,过期或 [forceRefresh] 时重新向后端要一张。 +abstract interface class PlaybackRepository { + Future ticketFor(String episodeId, {bool forceRefresh = false}); + + /// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。 + bool isFresh(String episodeId); +} + +class HttpPlaybackRepository implements PlaybackRepository { + HttpPlaybackRepository( + this._api, { + this.ttl = const Duration(minutes: 10), + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final PlaybackApi _api; + + /// 缓存时长:比后端 15 分钟签名短 5 分钟,避免"地址刚好在播放中途失效"。 + final Duration ttl; + final DateTime Function() _clock; + final Map _cache = {}; + + @override + Future ticketFor( + String episodeId, { + bool forceRefresh = false, + }) async { + final cached = _cache[episodeId]; + if (!forceRefresh && cached != null && !cached.isExpired(_clock(), ttl)) { + return cached.ticket; + } + final ticket = await _api.ticket(episodeId); + _cache[episodeId] = _CachedTicket(ticket, _clock()); + return ticket; + } + + @override + bool isFresh(String episodeId) { + final cached = _cache[episodeId]; + if (cached == null) return false; + return !cached.isExpired(_clock(), ttl); + } + + /// 供测试与"退出播放页"清理用。 + void clear() => _cache.clear(); +} + +class _CachedTicket { + const _CachedTicket(this.ticket, this.fetchedAt); + + final PlayTicket ticket; + final DateTime fetchedAt; + + bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl; +} + +/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。 +class FakePlaybackRepository implements PlaybackRepository { + FakePlaybackRepository({this.ticket, this.error, this.fresh = true}); + + PlayTicket? ticket; + Object? error; + final List requests = []; + final List forced = []; + + /// 模拟"票据是否还有效":置 false 可验证回前台会重新取。 + bool fresh; + + @override + bool isFresh(String episodeId) => fresh; + + @override + Future ticketFor( + String episodeId, { + bool forceRefresh = false, + }) async { + requests.add(episodeId); + forced.add(forceRefresh); + if (error != null) throw error!; + return ticket ?? + PlayTicket( + episodeId: episodeId, + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc', + ); + } +} diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart index a2fe4dd..fb4a9dd 100644 --- a/lib/core/data/repositories/watch_history_repository.dart +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -1,67 +1,156 @@ // Author: fruit.com.cn -import 'package:shared_preferences/shared_preferences.dart'; - -const demoProfileId = 'demo-local-v1'; +import '../models/watch_progress.dart'; +import '../remote/watch_api.dart'; +/// 观看进度来源。 +/// +/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是 +/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。 +/// 本地实现已删除:进度以服务端为准,换设备也能续看。 abstract interface class WatchHistoryRepository { - Future positionFor( - String profileId, - String dramaId, - String episodeId, - ); - Future lastEpisodeId(String profileId, String dramaId); - Future save( - String profileId, - String dramaId, - String episodeId, - Duration position, - ); + /// 某部剧的续看记录;没有记录返回 null(不是异常)。 + Future progressFor(String dramaId); + + /// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。 + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }); + + /// 最近观看(默认 20 条)。 + Future> recent({int limit = 20}); + + /// 清理某部剧的记录(K2 的记录页会用,这里先备好)。 + Future remove(String dramaId); + + /// 清空当前账号的全部观看记录。 + Future clear(); } -class SharedPreferencesWatchHistoryRepository - implements WatchHistoryRepository { - SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences}) - : _preferences = preferences ?? SharedPreferencesAsync(); +class HttpWatchHistoryRepository implements WatchHistoryRepository { + const HttpWatchHistoryRepository(this._api, {this.isSignedIn}); - final SharedPreferencesAsync _preferences; + final WatchApi _api; - String _positionKey(String profile, String drama, String episode) => - 'watch.v1.$profile.$drama.$episode.positionMs'; - String _lastKey(String profile, String drama) => - 'watch.v1.$profile.$drama.lastEpisode'; + /// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。 + final bool Function()? isSignedIn; + + bool get _signedIn => isSignedIn?.call() ?? true; @override - Future positionFor( - String profileId, - String dramaId, - String episodeId, - ) async { - final milliseconds = await _preferences.getInt( - _positionKey(profileId, dramaId, episodeId), - ); - return milliseconds == null ? null : Duration(milliseconds: milliseconds); + Future progressFor(String dramaId) async { + if (!_signedIn) return null; + return _api.progressOfDrama(dramaId); } @override - Future lastEpisodeId(String profileId, String dramaId) => - _preferences.getString(_lastKey(profileId, dramaId)); + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async { + if (!_signedIn) return null; + return _api.saveProgress( + episodeId: episodeId, + position: position, + duration: duration, + finished: finished, + ); + } @override - Future save( - String profileId, - String dramaId, - String episodeId, - Duration position, - ) async { - await _preferences.setInt( - _positionKey(profileId, dramaId, episodeId), - position.inMilliseconds, - ); - await _preferences.setString(_lastKey(profileId, dramaId), episodeId); + Future> recent({int limit = 20}) async { + if (!_signedIn) return const []; + return _api.recent(limit: limit); + } + + @override + Future remove(String dramaId) async { + if (!_signedIn) return; + await _api.removeProgress(dramaId); + } + + @override + Future clear() async { + if (!_signedIn) return; + await _api.clearProgress(); } } +/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。 +class InMemoryWatchHistoryRepository implements WatchHistoryRepository { + InMemoryWatchHistoryRepository({List? seed}) + : _records = { + for (final item in seed ?? const []) item.dramaId: item, + }; + + final Map _records; + int failReads = 0; + bool failWrites = false; + final List writes = []; + + @override + Future progressFor(String dramaId) async { + if (failReads > 0) { + failReads -= 1; + throw StateError('progress unavailable'); + } + return _records[dramaId]; + } + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async { + if (failWrites) throw StateError('progress unavailable'); + writes.add('$episodeId|${position.inMilliseconds}|$finished'); + final existing = _records[episodeId]; + final record = WatchProgress( + dramaId: existing?.dramaId ?? '', + episodeId: episodeId, + position: position, + duration: duration ?? Duration.zero, + finished: finished, + ); + return record; + } + + @override + Future> recent({int limit = 20}) async { + if (failReads > 0) { + failReads -= 1; + throw StateError('progress unavailable'); + } + return _records.values.take(limit).toList(); + } + + @override + Future remove(String dramaId) async => _records.remove(dramaId); + + @override + Future clear() async => _records.clear(); +} + +/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。 +extension WatchProgressResume on WatchHistoryRepository { + Future resumePointFor(String dramaId) async { + final progress = await progressFor(dramaId); + if (progress == null || progress.episodeId.isEmpty) return null; + return ResumePoint( + episodeId: progress.episodeId, + position: progress.position, + ); + } +} + +/// 续看位置钳位:片尾附近与异常值一律从 0 开始,避免"一进来就播完"。 Duration clampResumePosition(Duration saved, Duration duration) { if (duration <= const Duration(milliseconds: 500)) return Duration.zero; if (saved <= Duration.zero) return Duration.zero; diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart new file mode 100644 index 0000000..32713ba --- /dev/null +++ b/lib/core/data/services/auth_controller.dart @@ -0,0 +1,120 @@ +import 'package:flutter/foundation.dart'; + +import '../models/auth_session.dart'; +import '../remote/api_client.dart'; +import '../remote/auth_api.dart'; +import 'auth_session_store.dart'; + +/// 登录态的唯一来源。 +/// +/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新; +/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 —— +/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。 +class AuthController extends ChangeNotifier { + AuthController(this._api, this._store); + + final AuthApi _api; + final AuthSessionStore _store; + + AuthSession? _session; + String _deviceId = ''; + bool _loaded = false; + + bool get isLoaded => _loaded; + bool get isSignedIn => _session != null; + AuthUser? get user => _session?.user; + String? get accessToken => _session?.accessToken; + String get deviceId => _deviceId; + + /// App 启动时调用:恢复本地令牌与设备号。 + Future load() async { + _deviceId = await _store.deviceId(); + _session = await _store.read(); + _loaded = true; + notifyListeners(); + } + + Future register({ + required String email, + required String password, + }) async { + final session = await _api.register( + email: email, + password: password, + deviceId: await _store.deviceId(), + platform: _platform, + ); + await _apply(session); + } + + Future signIn({required String email, required String password}) async { + final session = await _api.login( + email: email, + password: password, + deviceId: await _store.deviceId(), + platform: _platform, + ); + await _apply(session); + } + + Future signOut() async { + final refreshToken = _session?.refreshToken ?? ''; + _session = null; + await _store.clear(); + notifyListeners(); + if (refreshToken.isNotEmpty) { + // 服务端吊销失败不影响本地已登出,异常直接吞掉 + try { + await _api.logout(refreshToken); + } catch (_) {} + } + } + + /// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。 + Future refresh() async { + final refreshToken = _session?.refreshToken ?? ''; + if (refreshToken.isEmpty) { + await clear(); + return false; + } + try { + await _apply(await _api.refresh(refreshToken)); + return true; + } catch (_) { + await clear(); + return false; + } + } + + Future clear() async { + if (_session == null) return; + _session = null; + await _store.clear(); + notifyListeners(); + } + + /// 资料保存成功后同步登录态摘要,保留当前令牌。 + Future applyProfile(AuthUser user) async { + final current = _session; + if (current == null) return; + await _apply( + AuthSession( + accessToken: current.accessToken, + refreshToken: current.refreshToken, + expiresIn: current.expiresIn, + user: user, + ), + ); + } + + Future _apply(AuthSession session) async { + _session = session; + await _store.save(session); + notifyListeners(); + } + + String get _platform { + if (kIsWeb) return 'WEB'; + return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID'; + } +} diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart new file mode 100644 index 0000000..1586759 --- /dev/null +++ b/lib/core/data/services/auth_session_store.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/auth_session.dart'; + +/// 令牌与设备号的本地持久化。 +/// +/// 用 `SharedPreferencesAsync`(与项目里 engagement 仓库一致):旧的 +/// `SharedPreferences.getInstance()` 在新版本里已标注弃用,两套混用还会让 +/// widget 测试只 mock 到其中一套。 +/// +/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**; +/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。 +class AuthSessionStore { + static const _kAccessToken = 'auth.accessToken'; + static const _kRefreshToken = 'auth.refreshToken'; + static const _kUser = 'auth.user'; + static const _kDeviceId = 'device.id'; + + String? _cachedDeviceId; + + Future deviceId() async { + if (_cachedDeviceId != null) return _cachedDeviceId!; + final prefs = SharedPreferencesAsync(); + var value = await prefs.getString(_kDeviceId) ?? ''; + if (value.isEmpty) { + final random = Random(); + value = + 'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-' + '${random.nextInt(1 << 32).toRadixString(36)}'; + await prefs.setString(_kDeviceId, value); + } + _cachedDeviceId = value; + return value; + } + + Future read() async { + final prefs = SharedPreferencesAsync(); + final accessToken = await prefs.getString(_kAccessToken) ?? ''; + final refreshToken = await prefs.getString(_kRefreshToken) ?? ''; + if (accessToken.isEmpty || refreshToken.isEmpty) return null; + final rawUser = await prefs.getString(_kUser); + Map user = const {}; + if (rawUser != null && rawUser.isNotEmpty) { + try { + user = (jsonDecode(rawUser) as Map).cast(); + } catch (_) { + user = const {}; + } + } + return AuthSession( + accessToken: accessToken, + refreshToken: refreshToken, + user: AuthUser.fromJson(user), + ); + } + + Future save(AuthSession session) async { + final prefs = SharedPreferencesAsync(); + await prefs.setString(_kAccessToken, session.accessToken); + await prefs.setString(_kRefreshToken, session.refreshToken); + await prefs.setString(_kUser, jsonEncode(session.user.toJson())); + } + + Future clear() async { + final prefs = SharedPreferencesAsync(); + await prefs.remove(_kAccessToken); + await prefs.remove(_kRefreshToken); + await prefs.remove(_kUser); + // 设备号不清:它代表"这台设备",与登录态无关 + } +} diff --git a/lib/core/data/services/catalog_controller.dart b/lib/core/data/services/catalog_controller.dart index 546dd70..34cccdb 100644 --- a/lib/core/data/services/catalog_controller.dart +++ b/lib/core/data/services/catalog_controller.dart @@ -1,28 +1,71 @@ // Author: fruit.com.cn import 'package:flutter/foundation.dart'; -import '../models/drama.dart'; -import '../repositories/catalog_repository.dart'; +import '../models/drama.dart'; +import '../models/home_feed.dart'; +import '../repositories/catalog_repository.dart'; +import '../remote/api_client.dart'; + +/// 目录状态:首页/发现页聚合数据 + 详情按需加载。 +/// +/// 列表卡片不含剧集,只有进详情或播放时才取一次详情, +/// 这样首页接口能保持很小的响应体。 class CatalogController extends ChangeNotifier { CatalogController(this.repository); + final CatalogRepository repository; - List dramas = const []; + HomeFeed feed = const HomeFeed(); bool loading = false; + + /// 第一次首页请求结束后为 true。之后的重试不再回到开屏。 + bool settled = false; String? error; + List get dramas => feed.dramas; + List get banners => feed.banners; + List get sections => feed.sections; + Future load() async { loading = true; error = null; notifyListeners(); try { - dramas = await repository.fetchDramas(); + feed = await repository.fetchHomeFeed(); + } on ApiException catch (exception) { + error = exception.message; } catch (_) { - error = 'The catalog could not be loaded.'; + error = 'catalogError'; } finally { loading = false; + settled = true; notifyListeners(); } } + + /// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。 + Future requireDetail(Drama drama) async { + if (drama.detailLoaded) return drama; + final detail = await repository.fetchDrama(drama.id); + _replace(detail); + return detail; + } + + void _replace(Drama detail) { + final sections = feed.sections + .map( + (section) => FeedSection( + code: section.code, + title: section.title, + dramas: [ + for (final drama in section.dramas) + if (drama.id == detail.id) detail else drama, + ], + ), + ) + .toList(); + feed = HomeFeed(banners: feed.banners, sections: sections); + notifyListeners(); + } } diff --git a/lib/core/data/services/category_controller.dart b/lib/core/data/services/category_controller.dart new file mode 100644 index 0000000..efeada2 --- /dev/null +++ b/lib/core/data/services/category_controller.dart @@ -0,0 +1,82 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/content_category.dart'; +import '../models/drama.dart'; +import '../models/home_feed.dart'; +import '../repositories/category_repository.dart'; + +/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。 +class CategoryController extends ChangeNotifier { + CategoryController(this._repository); + + static const String allCode = 'all'; + static const String hotCode = 'hot'; + static const String newCode = 'new'; + + final CategoryRepository _repository; + + List categories = const []; + HomeFeed feed = const HomeFeed(); + String selectedCode = allCode; + bool loading = false; + String? error; + bool _disposed = false; + + /// 当前筛选对应的分区 code;全部分类为空字符串语义。 + bool get selectedSectionMissing => + selectedCode != allCode && _selectedSection == null; + + /// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。 + List get visibleDramas { + if (selectedCode == allCode) return feed.dramas; + return _selectedSection?.dramas ?? const []; + } + + FeedSection? get _selectedSection { + for (final section in feed.sections) { + if (section.code == selectedCode) return section; + } + return null; + } + + /// 加载分类与发现 feed;失败保留可重试错误态。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + final results = await Future.wait([ + _repository.fetchCategories(), + _repository.fetchDiscoverFeed(), + ]); + categories = results[0] as List; + feed = results[1] as HomeFeed; + } catch (_) { + error = 'categoriesError'; + } finally { + loading = false; + _notify(); + } + } + + /// 选择服务端分类,内容只匹配 `category-{id}`。 + void selectCategory(String id) => selectCode('category-$id'); + + /// 选择内置或分区 code。 + void selectCode(String code) { + selectedCode = code; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/data/services/comment_controller.dart b/lib/core/data/services/comment_controller.dart new file mode 100644 index 0000000..55f6785 --- /dev/null +++ b/lib/core/data/services/comment_controller.dart @@ -0,0 +1,378 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/comment.dart'; +import '../models/interaction.dart'; +import '../remote/api_client.dart'; +import '../repositories/comment_repository.dart'; +import '../repositories/interaction_repository.dart'; + +/// 评论列表、发布、回复、删除与评论点赞的状态控制器。 +class CommentController extends ChangeNotifier { + CommentController({ + required CommentRepository comments, + required InteractionRepository interactions, + required this.dramaId, + this.episodeId, + }) : _commentRepository = comments, + _interactionRepository = interactions; + + final CommentRepository _commentRepository; + final InteractionRepository _interactionRepository; + final String dramaId; + final String? episodeId; + final List _comments = []; + final Map> _replies = {}; + final Map _replyCursors = {}; + final Map _replyHasMore = {}; + final Set _expandedRoots = {}; + final Set _loadingReplies = {}; + final Set _loadingMoreReplies = {}; + final Set _likingCommentIds = {}; + final Set _deletingCommentIds = {}; + bool _disposed = false; + bool loading = false; + bool loadingMore = false; + bool submitting = false; + bool hasMore = false; + String? nextCursor; + String? error; + + /// 当前已加载的顶层评论。 + List get comments => List.unmodifiable(_comments); + + /// 某一楼层已加载的回复。 + List repliesFor(String rootId) => + List.unmodifiable(_replies[rootId] ?? const []); + + /// 楼层回复是否已展开。 + bool repliesExpanded(String rootId) => _expandedRoots.contains(rootId); + + /// 楼层回复是否正在首屏加载。 + bool repliesLoading(String rootId) => _loadingReplies.contains(rootId); + + /// 楼层回复是否还有下一页。 + bool repliesHasMore(String rootId) => _replyHasMore[rootId] ?? false; + + /// 评论是否正在提交点赞,用于防连点。 + bool likingComment(String id) => _likingCommentIds.contains(id); + + void _notify() { + if (!_disposed) notifyListeners(); + } + + /// 加载首屏评论,成功后替换当前顶层列表。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + final page = await _commentRepository.page( + dramaId: dramaId, + size: commentPageSize, + ); + _comments + ..clear() + ..addAll(page.comments); + nextCursor = page.nextCursor; + hasMore = page.hasMore; + } catch (failure) { + error = _errorMessage(failure) ?? 'commentsLoadFailed'; + } finally { + loading = false; + _notify(); + } + } + + /// 使用 `nextCursor` 续拉顶层评论。 + Future loadMore() async { + final cursor = nextCursor; + if (loadingMore || !hasMore || cursor == null) return; + loadingMore = true; + error = null; + _notify(); + try { + final page = await _commentRepository.page( + dramaId: dramaId, + cursor: cursor, + size: commentPageSize, + ); + _comments.addAll(page.comments); + nextCursor = page.nextCursor; + hasMore = page.hasMore; + } catch (failure) { + error = _errorMessage(failure) ?? 'commentsLoadFailed'; + } finally { + loadingMore = false; + _notify(); + } + } + + /// 展开并用 `rootId` 加载一层楼层回复。 + Future loadReplies(String rootId) async { + _expandedRoots.add(rootId); + if (_replies.containsKey(rootId) || _loadingReplies.contains(rootId)) { + _notify(); + return; + } + _loadingReplies.add(rootId); + error = null; + _notify(); + try { + final page = await _commentRepository.page( + dramaId: dramaId, + rootId: rootId, + size: commentPageSize, + ); + _replies[rootId] = List.of(page.comments); + _replyCursors[rootId] = page.nextCursor; + _replyHasMore[rootId] = page.hasMore; + } catch (failure) { + error = _errorMessage(failure) ?? 'commentsLoadFailed'; + } finally { + _loadingReplies.remove(rootId); + _notify(); + } + } + + /// 收起楼层回复,不丢弃已加载数据。 + void hideReplies(String rootId) { + if (_expandedRoots.remove(rootId)) _notify(); + } + + /// 续拉楼层回复。 + Future loadMoreReplies(String rootId) async { + final cursor = _replyCursors[rootId]; + if (_loadingMoreReplies.contains(rootId) || cursor == null) return; + if (!(_replyHasMore[rootId] ?? false)) return; + _loadingMoreReplies.add(rootId); + _notify(); + try { + final page = await _commentRepository.page( + dramaId: dramaId, + rootId: rootId, + cursor: cursor, + size: commentPageSize, + ); + (_replies[rootId] ??= []).addAll(page.comments); + _replyCursors[rootId] = page.nextCursor; + _replyHasMore[rootId] = page.hasMore; + } catch (failure) { + error = _errorMessage(failure) ?? 'commentsLoadFailed'; + } finally { + _loadingMoreReplies.remove(rootId); + _notify(); + } + } + + /// 发布评论或回复,输入框是否清空由调用方根据成功结果决定。 + Future> publish( + String rawContent, { + DramaComment? replyTo, + }) async { + final content = rawContent.trim(); + if (content.isEmpty || submitting) { + return const OperationResult.failure(null); + } + submitting = true; + _notify(); + try { + final comment = await _commentRepository.publish( + dramaId: dramaId, + episodeId: episodeId, + content: content, + parentId: replyTo?.id, + ); + _insertPublished(comment, replyTo); + return OperationResult.success(comment); + } catch (failure) { + return OperationResult.failure(_errorMessage(failure)); + } finally { + submitting = false; + _notify(); + } + } + + /// 乐观删除自己的评论,失败时恢复到原位置。 + Future> delete(DramaComment comment) async { + if (!comment.mine || _deletingCommentIds.contains(comment.id)) { + return const OperationResult.failure(null); + } + _deletingCommentIds.add(comment.id); + final location = _removeComment(comment.id); + _notify(); + try { + await _commentRepository.delete(comment.id); + return const OperationResult.success(); + } catch (failure) { + _restoreComment(comment, location); + return OperationResult.failure(_errorMessage(failure)); + } finally { + _deletingCommentIds.remove(comment.id); + _notify(); + } + } + + /// 评论点赞先乐观更新,再用服务端 `{liked,count}` 校准。 + Future> toggleLike( + DramaComment comment, + ) async { + if (_likingCommentIds.contains(comment.id)) { + return const OperationResult.failure(null); + } + final current = _findComment(comment.id) ?? comment; + _likingCommentIds.add(comment.id); + _replaceComment( + current.copyWith( + liked: !current.liked, + likeCount: current.liked + ? (current.likeCount > 0 ? current.likeCount - 1 : 0) + : current.likeCount + 1, + ), + ); + _notify(); + try { + final result = await _interactionRepository.toggleLike( + targetType: InteractionTargetType.comment, + targetId: comment.id, + ); + _replaceComment( + current.copyWith(liked: result.liked, likeCount: result.count), + ); + return OperationResult.success(result); + } catch (failure) { + _replaceComment(current); + return OperationResult.failure(_errorMessage(failure)); + } finally { + _likingCommentIds.remove(comment.id); + _notify(); + } + } + + /// 举报评论,失败消息原样返回给界面展示。 + Future> report( + DramaComment comment, { + required ReportReason reason, + String description = '', + }) async { + try { + final reportId = await _interactionRepository.report( + ReportRequest( + targetType: InteractionTargetType.comment, + targetId: comment.id, + reasonCode: reason.apiValue, + description: description.trim(), + ), + ); + return OperationResult.success(reportId); + } catch (failure) { + return OperationResult.failure(_errorMessage(failure)); + } + } + + void _insertPublished(DramaComment comment, DramaComment? replyTo) { + final rootId = comment.rootId != '0' + ? comment.rootId + : replyTo?.isTopLevel == true + ? replyTo!.id + : replyTo?.rootId; + if (replyTo == null || rootId == null || rootId == '0') { + if (!_comments.any((item) => item.id == comment.id)) { + _comments.insert(0, comment); + } + return; + } + final replies = _replies.putIfAbsent(rootId, () => []); + if (!replies.any((item) => item.id == comment.id)) { + replies.insert(0, comment); + } + _expandedRoots.add(rootId); + final rootIndex = _comments.indexWhere((item) => item.id == rootId); + if (rootIndex >= 0) { + final root = _comments[rootIndex]; + _comments[rootIndex] = root.copyWith(replyCount: root.replyCount + 1); + } + } + + _CommentLocation? _removeComment(String id) { + final topIndex = _comments.indexWhere((item) => item.id == id); + if (topIndex >= 0) { + final removed = _comments.removeAt(topIndex); + return _CommentLocation(comment: removed, topIndex: topIndex); + } + for (final entry in _replies.entries) { + final index = entry.value.indexWhere((item) => item.id == id); + if (index < 0) continue; + final removed = entry.value.removeAt(index); + return _CommentLocation( + comment: removed, + rootId: entry.key, + replyIndex: index, + ); + } + return null; + } + + void _restoreComment(DramaComment comment, _CommentLocation? location) { + if (location == null) return; + if (location.topIndex != null) { + final index = location.topIndex!.clamp(0, _comments.length); + _comments.insert(index, comment); + return; + } + final replies = _replies.putIfAbsent(location.rootId!, () => []); + final index = location.replyIndex!.clamp(0, replies.length); + replies.insert(index, comment); + } + + DramaComment? _findComment(String id) { + for (final comment in _comments) { + if (comment.id == id) return comment; + } + for (final replies in _replies.values) { + for (final comment in replies) { + if (comment.id == id) return comment; + } + } + return null; + } + + void _replaceComment(DramaComment replacement) { + final topIndex = _comments.indexWhere((item) => item.id == replacement.id); + if (topIndex >= 0) { + _comments[topIndex] = replacement; + return; + } + for (final replies in _replies.values) { + final index = replies.indexWhere((item) => item.id == replacement.id); + if (index >= 0) { + replies[index] = replacement; + return; + } + } + } + + String? _errorMessage(Object failure) => + failure is ApiException ? failure.message : null; + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +class _CommentLocation { + const _CommentLocation({ + required this.comment, + this.topIndex, + this.rootId, + this.replyIndex, + }); + + final DramaComment comment; + final int? topIndex; + final String? rootId; + final int? replyIndex; +} diff --git a/lib/core/data/services/download_controller.dart b/lib/core/data/services/download_controller.dart new file mode 100644 index 0000000..1e38ef8 --- /dev/null +++ b/lib/core/data/services/download_controller.dart @@ -0,0 +1,168 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/download_license.dart'; +import '../models/download_policy.dart'; +import '../remote/api_exception.dart'; +import '../repositories/download_repository.dart'; + +/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。 +class DownloadActionResult { + const DownloadActionResult({required this.ok, this.license, this.message}); + + final bool ok; + final DownloadLicense? license; + final String? message; +} + +/// 下载授权与策略状态。 +class DownloadController extends ChangeNotifier { + DownloadController(this._repository); + + final DownloadRepository _repository; + + DownloadPolicy? policy; + List licenses = const []; + bool loading = false; + bool clearing = false; + String? error; + String? actionError; + bool _disposed = false; + + /// 同时拉取策略与当前设备授权列表。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + final results = await Future.wait([ + _repository.fetchPolicy(), + _repository.list(), + ]); + policy = results[0] as DownloadPolicy; + licenses = results[1] as List; + } catch (_) { + error = 'downloadsError'; + } finally { + loading = false; + _notify(); + } + } + + /// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。 + Future grant( + String episodeId, { + String? quality, + }) async { + try { + final license = await _repository.grant(episodeId, quality: quality); + licenses = [ + license, + for (final item in licenses) + if (item.episodeId != license.episodeId) item, + ]; + actionError = null; + _notify(); + return DownloadActionResult(ok: true, license: license); + } on ApiException catch (exception) { + actionError = exception.message; + _notify(); + return DownloadActionResult(ok: false, message: exception.message); + } catch (_) { + actionError = 'downloadGrantFailed'; + _notify(); + return const DownloadActionResult(ok: false); + } + } + + /// 更新设置,先本地展示,失败恢复服务端旧值。 + Future updateSettings({bool? wifiOnly, String? maxQuality}) async { + final previous = policy; + if (previous != null) { + policy = previous.copyWith(wifiOnly: wifiOnly, maxQuality: maxQuality); + _notify(); + } + try { + await _repository.updateSettings( + wifiOnly: wifiOnly, + maxQuality: maxQuality, + ); + actionError = null; + return true; + } catch (_) { + policy = previous; + actionError = 'downloadSettingsFailed'; + return false; + } finally { + _notify(); + } + } + + /// 删除单条授权,失败按原位置恢复。 + Future remove(String episodeId) async { + final index = licenses.indexWhere((item) => item.episodeId == episodeId); + if (index < 0) return true; + final removed = licenses[index]; + licenses = [...licenses]..removeAt(index); + actionError = null; + _notify(); + try { + await _repository.remove(episodeId); + return true; + } catch (_) { + final restored = [...licenses]; + restored.insert( + index > restored.length ? restored.length : index, + removed, + ); + licenses = restored; + actionError = 'downloadDeleteFailed'; + return false; + } finally { + _notify(); + } + } + + /// 清空授权,失败恢复原列表。 + Future clear() async { + if (licenses.isEmpty) return true; + final previous = licenses; + licenses = const []; + clearing = true; + actionError = null; + _notify(); + try { + await _repository.clear(); + return true; + } catch (_) { + licenses = previous; + actionError = 'downloadClearFailed'; + return false; + } finally { + clearing = false; + _notify(); + } + } + + /// 退出登录时清空账号相关状态,避免跨账号串联。 + void reset() { + policy = null; + licenses = const []; + loading = false; + clearing = false; + error = null; + actionError = null; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/data/services/engagement_controller.dart b/lib/core/data/services/engagement_controller.dart deleted file mode 100644 index 440fe8e..0000000 --- a/lib/core/data/services/engagement_controller.dart +++ /dev/null @@ -1,80 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/foundation.dart'; - -import '../models/engagement_snapshot.dart'; -import '../repositories/engagement_repository.dart'; - -/// Local demo interactions, indexed by drama ID. No public/social backend. -class EngagementController extends ChangeNotifier { - EngagementController(this._repository); - - final EngagementRepository _repository; - final Set _saved = {}; - final Set _liked = {}; - final Map> _comments = {}; - Future _loadFuture = Future.value(); - Future _pendingSave = Future.value(); - - bool isSaved(String dramaId) => _saved.contains(dramaId); - bool isLiked(String dramaId) => _liked.contains(dramaId); - List commentsFor(String dramaId) => - List.unmodifiable(_comments[dramaId] ?? const []); - - Future load() => _loadFuture = _loadFromRepository(); - - Future _loadFromRepository() async { - try { - final snapshot = await _repository.load(); - _saved.addAll(snapshot.savedDramaIds); - _liked.addAll(snapshot.likedDramaIds); - for (final entry in snapshot.commentsByDrama.entries) { - _comments[entry.key] = List.of(entry.value); - } - notifyListeners(); - } catch (_) { - // Local preferences are best effort for this demo. - } - } - - Future toggleSaved(String dramaId) async { - await _loadFuture; - if (!_saved.add(dramaId)) _saved.remove(dramaId); - notifyListeners(); - await _persist(); - } - - Future toggleLiked(String dramaId) async { - await _loadFuture; - if (!_liked.add(dramaId)) _liked.remove(dramaId); - notifyListeners(); - await _persist(); - } - - Future addComment(String dramaId, String text) async { - await _loadFuture; - final clean = text.trim(); - if (clean.isEmpty) return; - (_comments[dramaId] ??= []).add( - clean.length > 300 ? clean.substring(0, 300) : clean, - ); - notifyListeners(); - await _persist(); - } - - Future _persist() { - final snapshot = EngagementSnapshot( - savedDramaIds: Set.of(_saved), - likedDramaIds: Set.of(_liked), - commentsByDrama: { - for (final entry in _comments.entries) entry.key: List.of(entry.value), - }, - ); - _pendingSave = _pendingSave - .then((_) => _repository.save(snapshot)) - .catchError((Object _) { - // Keep the current in-memory state if local persistence fails. - }); - return _pendingSave; - } -} diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart new file mode 100644 index 0000000..4b9e4b1 --- /dev/null +++ b/lib/core/data/services/favorite_controller.dart @@ -0,0 +1,127 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/drama.dart'; +import '../repositories/favorite_repository.dart'; + +/// 收藏状态:**以服务端为准**。 +/// +/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于 +/// "点了没反应"或"看起来收藏了其实没有"。 +class FavoriteController extends ChangeNotifier { + FavoriteController(this._repository); + + final FavoriteRepository _repository; + final Set _saved = {}; + List cards = const []; + bool loading = false; + String? error; + bool _disposed = false; + + Set get savedIds => Set.unmodifiable(_saved); + + bool isSaved(String dramaId) => _saved.contains(dramaId); + + void notify() { + if (!_disposed) notifyListeners(); + } + + /// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。 + Future load() async { + loading = true; + error = null; + notify(); + try { + final list = await _repository.list(); + cards = list; + _saved + ..clear() + ..addAll(list.map((drama) => drama.id)); + } catch (_) { + error = 'favoritesError'; + } finally { + loading = false; + notify(); + } + } + + /// 未登录时不要调用(服务端要求登录态)。 + /// + /// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表, + /// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。 + Future toggle(String dramaId, {Drama? card}) async { + final wasSaved = _saved.contains(dramaId); + // 乐观更新 + if (wasSaved) { + _saved.remove(dramaId); + cards = cards.where((drama) => drama.id != dramaId).toList(); + } else { + _saved.add(dramaId); + } + notify(); + try { + final saved = await _repository.toggle(dramaId); + // 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作) + if (saved) { + _saved.add(dramaId); + if (card != null && !cards.any((drama) => drama.id == dramaId)) { + cards = [card, ...cards]; + } + } else { + _saved.remove(dramaId); + cards = cards.where((drama) => drama.id != dramaId).toList(); + } + notify(); + return true; + } catch (_) { + // 回滚 + if (wasSaved) { + _saved.add(dramaId); + } else { + _saved.remove(dramaId); + } + notify(); + return false; + } + } + + /// 取消收藏("我的"页列表里删除某条)。 + Future remove(String dramaId) async { + final index = cards.indexWhere((drama) => drama.id == dramaId); + if (index < 0) return true; + final removed = cards[index]; + _saved.remove(dramaId); + final next = [...cards]..removeAt(index); + cards = next; + notify(); + try { + await _repository.remove(dramaId); + return true; + } catch (_) { + final restored = [...cards]; + restored.insert( + index > restored.length ? restored.length : index, + removed, + ); + cards = restored; + _saved.add(dramaId); + notify(); + return false; + } + } + + /// 退出登录时清空:收藏状态属于账号,不能跨账号串联。 + void clear() { + _saved.clear(); + cards = const []; + error = null; + notify(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart new file mode 100644 index 0000000..9dd39c9 --- /dev/null +++ b/lib/core/data/services/interaction_controller.dart @@ -0,0 +1,107 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/interaction.dart'; +import '../remote/api_client.dart'; +import '../repositories/interaction_repository.dart'; + +/// 剧集点赞与举报的会话状态控制器。 +/// +/// 剧集契约暂未下发初始点赞状态与计数,本批只维护当前会话内的 liked 状态, +/// 不展示数字,也不伪造初始值。 +class InteractionController extends ChangeNotifier { + InteractionController(this._repository); + + final InteractionRepository _repository; + final Set _likedDramaIds = {}; + final Set _busyDramaIds = {}; + bool _disposed = false; + + /// 当前会话内该剧是否已点赞。 + bool isLiked(String dramaId) => _likedDramaIds.contains(dramaId); + + /// 点赞请求是否进行中,用于防连点。 + bool isBusy(String dramaId) => _busyDramaIds.contains(dramaId); + + /// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。 + Future> toggleDramaLike( + String dramaId, + ) async { + if (_busyDramaIds.contains(dramaId)) { + return const OperationResult.failure(null); + } + final wasLiked = _likedDramaIds.contains(dramaId); + _busyDramaIds.add(dramaId); + if (wasLiked) { + _likedDramaIds.remove(dramaId); + } else { + _likedDramaIds.add(dramaId); + } + _notify(); + try { + final result = await _repository.toggleLike( + targetType: InteractionTargetType.drama, + targetId: dramaId, + ); + if (result.liked) { + _likedDramaIds.add(dramaId); + } else { + _likedDramaIds.remove(dramaId); + } + return OperationResult.success(result); + } catch (failure) { + if (wasLiked) { + _likedDramaIds.add(dramaId); + } else { + _likedDramaIds.remove(dramaId); + } + return OperationResult.failure(_errorMessage(failure)); + } finally { + _busyDramaIds.remove(dramaId); + _notify(); + } + } + + /// 举报剧集,失败消息原样返回给界面。 + Future> reportDrama( + String dramaId, { + required ReportReason reason, + String description = '', + }) async { + try { + final reportId = await _repository.report( + ReportRequest( + targetType: InteractionTargetType.drama, + targetId: dramaId, + reasonCode: reason.apiValue, + description: description.trim(), + ), + ); + return OperationResult.success(reportId); + } catch (failure) { + return OperationResult.failure(_errorMessage(failure)); + } + } + + /// 登录账号变化时清理会话内状态,避免跨账号串联。 + void clear() { + if (_likedDramaIds.isEmpty && _busyDramaIds.isEmpty) return; + _likedDramaIds.clear(); + _busyDramaIds.clear(); + _notify(); + } + + String? _errorMessage(Object failure) => + failure is ApiException ? failure.message : null; + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/data/services/notices_controller.dart b/lib/core/data/services/notices_controller.dart new file mode 100644 index 0000000..6bbf71e --- /dev/null +++ b/lib/core/data/services/notices_controller.dart @@ -0,0 +1,118 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/notice.dart'; +import '../remote/api_client.dart'; +import '../repositories/notice_repository.dart'; + +/// Pagination and state controller for the anonymous notices page. +class NoticesController extends ChangeNotifier { + NoticesController(this._repository, {this.pageSize = 20}); + + final NoticeRepository _repository; + final int pageSize; + final List _notices = []; + int _pageIndex = 0; + int _total = 0; + bool _disposed = false; + bool loading = false; + bool loadingMore = false; + bool refreshing = false; + String? error; + String? loadMoreError; + + /// Loaded notices in server order. + List get notices => List.unmodifiable(_notices); + + /// Whether another page is expected from the latest total. + bool get hasMore => _notices.length < _total; + + /// Loads the first page. + Future load() async { + loading = true; + error = null; + loadMoreError = null; + _notify(); + await _replaceFirstPage(); + loading = false; + _notify(); + } + + /// Refreshes from page one while keeping the current page visible. + Future refresh() async { + refreshing = true; + error = null; + loadMoreError = null; + _notify(); + await _replaceFirstPage(); + refreshing = false; + _notify(); + } + + /// Appends the next page when loaded count is below total. + Future loadMore() async { + if (loading || loadingMore || refreshing || !hasMore) return; + loadingMore = true; + loadMoreError = null; + _notify(); + try { + final page = await _repository.page( + pageIndex: _pageIndex + 1, + pageSize: pageSize, + ); + _appendUnique(page.notices); + _pageIndex++; + _total = page.total; + } catch (failure, stackTrace) { + debugPrint('[NoticesController] load more failed: $failure'); + debugPrintStack( + stackTrace: stackTrace, + label: '[NoticesController] load more failed', + ); + loadMoreError = _message(failure) ?? 'noticesLoadFailed'; + } finally { + loadingMore = false; + _notify(); + } + } + + Future _replaceFirstPage() async { + try { + final page = await _repository.page(pageIndex: 1, pageSize: pageSize); + if (_disposed) return; + _notices + ..clear() + ..addAll(page.notices); + _pageIndex = 1; + _total = page.total; + } catch (failure, stackTrace) { + debugPrint('[NoticesController] load failed: $failure'); + debugPrintStack( + stackTrace: stackTrace, + label: '[NoticesController] load failed', + ); + if (!_disposed) error = _message(failure) ?? 'noticesLoadFailed'; + } + } + + void _appendUnique(List incoming) { + final ids = _notices.map((notice) => notice.id).toSet(); + for (final notice in incoming) { + if (ids.add(notice.id)) _notices.add(notice); + } + } + + String? _message(Object failure) => + failure is ApiException ? failure.message : null; + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 1ce9755..9bca68a 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -130,6 +130,9 @@ class PlaybackSession extends ChangeNotifier { if (!slot.retiring && !_closed) slot.prepared = true; } catch (error) { slot.failure = error; + if (kDebugMode) { + debugPrint('片源准备失败 ${episodes[index].videoUrl}: $error'); + } } }(); return slot; @@ -151,14 +154,23 @@ class PlaybackSession extends ChangeNotifier { /// use exactly the same mechanism without warming an unrelated episode. Future select(int index, {int? preloadIndex}) async { if (_closed || index < 0 || index >= episodes.length) return; + // 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。 + // 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。 + if (!_selected || index != _currentIndex) { + _userWantsPlay = true; + } final request = ++_generation; _selected = true; _currentIndex = index; _nextIndex = preloadIndex; _error = null; + if (!_visible) { + _loading = false; + _notify(); + return; + } _loading = preparedPlayerAt(index) == null && !isLocked; _notify(); - if (!_visible) return; final acquired = await _coordinator._acquire( this, () => _visible && request == _generation, diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index 5014941..a5f573d 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -2,6 +2,7 @@ import 'package:video_player/video_player.dart'; +import '../../config/app_config.dart'; import '../models/drama.dart'; import '../models/player_port.dart'; @@ -9,11 +10,25 @@ class VideoPlayerPort implements PlayerPort { VideoPlayerPort( Episode episode, { Future? closedCaptionFile, - }) : controller = VideoPlayerController.asset( - episode.assetPath, - closedCaptionFile: closedCaptionFile, - videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false), - ); + }) : controller = _open(episode, closedCaptionFile); + + /// 后端下发的是相对路径(`/files/...`),交给播放器前补上服务地址。 + /// 否则 Uri 没有 scheme/host,AVPlayer 和 ExoPlayer 都加载不了。 + static Uri resolveUri(Episode episode) => + Uri.parse(AppConfig.mediaUrl(episode.videoUrl)); + + static VideoPlayerController _open( + Episode episode, + Future? closedCaptionFile, + ) { + final options = VideoPlayerOptions(mixWithOthers: false); + // 片源统一由后端下发(详情里的可播放地址 / 播放票据) + return VideoPlayerController.networkUrl( + resolveUri(episode), + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: options, + ); + } final VideoPlayerController controller; Future? _initialization; diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart new file mode 100644 index 0000000..f3b819b --- /dev/null +++ b/lib/core/data/services/watch_records_controller.dart @@ -0,0 +1,224 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/drama.dart'; +import '../models/watch_progress.dart'; +import '../models/watch_record.dart'; +import '../remote/api_client.dart'; +import '../repositories/catalog_repository.dart'; +import '../repositories/watch_history_repository.dart'; + +/// 独立观看记录页状态。 +/// +/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。 +class WatchRecordsController extends ChangeNotifier { + factory WatchRecordsController({ + required WatchHistoryRepository history, + required CatalogRepository catalog, + int limit = defaultLimit, + }) => WatchRecordsController._(history, catalog, limit); + + WatchRecordsController._(this._history, this._catalog, this.limit); + + static const int defaultLimit = 20; + static const int detailConcurrency = 4; + + final WatchHistoryRepository _history; + final CatalogRepository _catalog; + final int limit; + final Map _detailCache = {}; + + List records = const []; + bool loading = false; + bool clearing = false; + String? error; + String? actionError; + bool _disposed = false; + + /// 加载最近记录并补齐详情;单条详情失败不拖垮整页。 + Future load() async { + loading = true; + error = null; + actionError = null; + _notify(); + try { + final recent = await _history.recent(limit: limit); + if (_disposed) return; + records = _uniqueRecords(recent); + _notify(); + await _hydrate(records.map((record) => record.dramaId).toList()); + } catch (failure, stackTrace) { + debugPrint( + '[WatchRecordsController] recent failed ' + '${failure.runtimeType}: $failure', + ); + debugPrintStack( + stackTrace: stackTrace, + label: '[WatchRecordsController] recent failed', + ); + if (!_disposed) { + error = failure is ApiException ? failure.message : 'progressError'; + } + } finally { + if (!_disposed) { + loading = false; + _notify(); + } + } + } + + /// 只重试一条详情失败记录。 + Future retryDetail(String dramaId) async { + final index = _recordIndex(dramaId); + if (index < 0) return; + _replaceAt( + index, + records[index].copyWith(detailLoading: true, detailFailed: false), + ); + _notify(); + await _loadDetail(dramaId); + _notify(); + } + + /// 删除单条记录,先更新 UI,失败按原位置回滚。 + Future remove(String dramaId) async { + final index = _recordIndex(dramaId); + if (index < 0) return true; + final removed = records[index]; + records = [...records]..removeAt(index); + actionError = null; + _notify(); + try { + await _history.remove(dramaId); + return true; + } catch (_) { + final next = [...records]; + next.insert(index > next.length ? next.length : index, removed); + records = next; + actionError = 'recordDeleteFailed'; + return false; + } finally { + _notify(); + } + } + + /// 清空全部记录,先更新 UI,失败恢复原列表。 + Future clear() async { + if (records.isEmpty) return true; + final previous = records; + records = const []; + clearing = true; + actionError = null; + _notify(); + try { + await _history.clear(); + return true; + } catch (_) { + records = previous; + actionError = 'recordClearFailed'; + return false; + } finally { + clearing = false; + _notify(); + } + } + + List _uniqueRecords(List recent) { + final seen = {}; + return [ + for (final progress in recent) + if (seen.add(progress.dramaId)) WatchRecord(progress: progress), + ]; + } + + Future _hydrate(List dramaIds) async { + final queue = [ + for (final id in dramaIds) + if (!_detailCache.containsKey(id)) id, + ]; + final workerCount = queue.length < detailConcurrency + ? queue.length + : detailConcurrency; + Future worker() async { + while (queue.isNotEmpty && !_disposed) { + await _loadDetail(queue.removeAt(0)); + } + } + + await Future.wait([ + for (var index = 0; index < workerCount; index++) worker(), + ]); + } + + Future _loadDetail(String dramaId) async { + final index = _recordIndex(dramaId); + if (index < 0) return; + final cached = _detailCache[dramaId]; + if (cached != null) { + _replaceAt( + index, + records[index].copyWith( + drama: cached, + detailLoading: false, + detailFailed: false, + ), + ); + return; + } + try { + final detail = await _catalog.fetchDrama(dramaId); + if (_disposed) return; + _detailCache[dramaId] = detail; + final current = _recordIndex(dramaId); + if (current < 0) return; + _replaceAt( + current, + records[current].copyWith( + drama: detail, + detailLoading: false, + detailFailed: false, + ), + ); + } catch (failure, stackTrace) { + debugPrint( + '[WatchRecordsController] detail failed dramaId=$dramaId ' + '${failure.runtimeType}: $failure', + ); + debugPrintStack( + stackTrace: stackTrace, + label: '[WatchRecordsController] detail failed dramaId=$dramaId', + ); + if (_disposed) return; + final current = _recordIndex(dramaId); + if (current < 0) return; + _replaceAt( + current, + records[current].copyWith(detailLoading: false, detailFailed: true), + ); + } + } + + int _recordIndex(String dramaId) { + for (var index = 0; index < records.length; index++) { + if (records[index].dramaId == dramaId) return index; + } + return -1; + } + + void _replaceAt(int index, WatchRecord record) { + final next = [...records]; + next[index] = record; + records = next; + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/formatting/error_text.dart b/lib/core/formatting/error_text.dart new file mode 100644 index 0000000..fb5fd77 --- /dev/null +++ b/lib/core/formatting/error_text.dart @@ -0,0 +1,18 @@ +// Author: fruit.com.cn + +import '../../l10n/app_localizations.dart'; + +/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与 +/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。 +String errorText(AppLocalizations l10n, String? code, String fallback) { + if (code == null || code.isEmpty) return fallback; + return switch (code) { + 'networkError' => l10n.networkError, + 'progressError' => l10n.progressError, + 'favoritesError' => l10n.favoritesError, + 'ticketError' => l10n.playbackError, + 'commentsLoadFailed' => l10n.commentsLoadFailed, + 'noticesLoadFailed' => l10n.noticesLoadFailed, + final value => value, + }; +} diff --git a/lib/core/formatting/localized_date_time.dart b/lib/core/formatting/localized_date_time.dart new file mode 100644 index 0000000..e5708ad --- /dev/null +++ b/lib/core/formatting/localized_date_time.dart @@ -0,0 +1,15 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +/// Formats a timestamp with the active Material locale and 12/24-hour setting. +String formatLocalizedDateTime(BuildContext context, DateTime value) { + final localizations = MaterialLocalizations.of(context); + final local = value.toLocal(); + final date = localizations.formatCompactDate(local); + final time = localizations.formatTimeOfDay( + TimeOfDay.fromDateTime(local), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + return '$date $time'; +} diff --git a/lib/core/platform/haptics.dart b/lib/core/platform/haptics.dart new file mode 100644 index 0000000..cbdbd17 --- /dev/null +++ b/lib/core/platform/haptics.dart @@ -0,0 +1,39 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; + +/// User-action haptic feedback boundary. +abstract interface class Haptics { + /// Feedback for a selection change. + Future selectionClick(); + + /// Feedback for a primary action. + Future lightImpact(); + + /// Feedback for a destructive action. + Future mediumImpact(); +} + +/// Default implementation backed by Flutter's platform haptics. +class FlutterHaptics implements Haptics { + const FlutterHaptics(); + + @override + Future selectionClick() => + _ignoreUnavailable(HapticFeedback.selectionClick); + + @override + Future lightImpact() => _ignoreUnavailable(HapticFeedback.lightImpact); + + @override + Future mediumImpact() => + _ignoreUnavailable(HapticFeedback.mediumImpact); + + Future _ignoreUnavailable(Future Function() feedback) async { + try { + await feedback(); + } catch (_) { + // Haptics are optional and may be unavailable on the target device. + } + } +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..da46392 --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,80 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +/// Obsidian Flare palette from `docs/design/README.md` and screens 01/02. +/// +/// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page +/// code does not need a mass rename; their values now follow the design tokens. +abstract final class AppColors { + /// Primary CTA, selected navigation, playback progress. + static const blue = Color(0xFFFF5B3D); + static const primary = blue; + + /// Pressed state for the primary CTA. + static const bluePressed = Color(0xFFFF4321); + + /// Secondary accent for tags, locks, and highlighted icons. + static const blueSoft = Color(0xFF9278EA); + static const secondary = blueSoft; + static const lavender = blueSoft; + + /// Coins and rewards. + static const gold = Color(0xFFFBBF24); + + /// Success and free-state accents. + static const success = Color(0xFF34D399); + + /// Liked hearts use the primary accent. + static const favorite = blue; + + /// Text placed on [gold]. + static const onGold = Color(0xFF2A1810); + + /// Text and icons placed directly on [blue]. + static const onPrimary = Color(0xFFFFFFFF); + + /// Page background. + static const ink = Color(0xFF0B0C10); + + /// Bottom navigation background (rendered at 95% with blur). + static const bar = Color(0xFF0D0D11); + + /// Cards lifted off [ink]. + static const card = Color(0xFF1B1C21); + + /// Alternate card surface from the mobile preview. + static const cardAlt = Color(0xFF1C1D21); + + /// Floating surfaces, inputs, and nested rows. + static const raised = Color(0xFF1C1D21); + static const quick = Color(0xFF202126); + static const input = Color(0xFF25252A); + static const goldCard = Color(0xFF201B1E); + + /// Body text on dark surfaces. + static const cream = Color(0xFFE6E1E3); + + /// Titles and high-emphasis text. + static const onSurface = Color(0xFFFFFFFF); + + /// Secondary text. + static const muted = Color(0xFFD2CDD0); + + /// Weak hints and timestamps. + static const subtle = Color(0xFF9A96A3); + + /// Soft orange used for continue labels and secondary times. + static const accent = Color(0xFFFFA490); + + /// 8% white border used by cards and inputs. + static const border = Color(0x14FFFFFF); + + /// 14% white border used by glass controls. + static const glassBorder = Color(0x24FFFFFF); + + static const outlineDark = Color(0xFF2A2C36); + static const outlineVariantDark = Color(0xFF242329); + static const outlineLight = Color(0xFF8E8E93); + static const outlineVariantLight = Color(0xFFE4E4E7); +} diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart new file mode 100644 index 0000000..9bc0cb6 --- /dev/null +++ b/lib/core/theme/app_dimens.dart @@ -0,0 +1,61 @@ +// Author: fruit.com.cn + +/// Cross-page spacing, type, radius, and control sizes. +abstract final class AppDimens { + static const double spaceXs = 4; + static const double spaceSm = 8; + static const double spaceMd = 12; + static const double spaceLg = 16; + static const double spaceXl = 24; + + static const double pageHorizontal = 16; + static const double cardRadius = 12; + static const double authCardRadius = 20; + static const double cardPadding = 16; + static const double innerRadius = 10; + static const double innerPadding = 12; + static const double pillRadius = 20; + static const double glassSmallRadius = 12; + static const double glassRadius = 14; + static const double glassCardRadius = 14; + static const double buttonRadius = 9; + static const double coverRadius = 10; + + static const double caption = 12; + static const double emptyStateSubtitle = 12.5; + static const double emptyStateAction = 13; + static const double body = 14; + static const double listTitle = 15; + static const double sectionTitle = 16; + static const double pageTitle = 20; + static const double detailHeadline = 24; + static const double detailHeadlineSmall = 18; + static const double previewCardTitle = 13; + static const double previewSectionTitle = 14; + static const double chipFont = 10; + static const double navLabel = 9; + + static const double buttonHeight = 48; + static const double primaryButtonHeight = 52; + static const double buttonCompact = 32; + static const double buttonSmall = 34; + static const double buttonMedium = 40; + static const double buttonLarge = 44; + static const double tapTarget = 44; + + static const double navHeight = 54; + static const double navIcon = 17; + static const double progressHeight = 3; + static const double progressRadius = 2; + static const double progressLineHeight = 3; + + static const double searchHeight = 44; + static const double navDot = 3; + static const double glassBlur = 24; + + static const double accountCardHeight = 64; + static const double accountIcon = 18; + static const double accountLabel = 11; + + static const double watchPlaceholderHeight = 114; +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..324e540 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,162 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'app_colors.dart'; +import 'app_dimens.dart'; + +/// Obsidian Flare themes. Dark is the product default; light mirrors the same +/// orange primary for system-light devices. +abstract final class AppTheme { + static const _lightBackground = Color(0xFFF7F7F8); + static const _darkBackground = AppColors.ink; + + /// Light theme for system light mode. + static ThemeData light() => _build( + brightness: Brightness.light, + surface: _lightBackground, + accent: AppColors.blue, + ); + + /// Dark theme for system dark mode. + static ThemeData dark() => _build( + brightness: Brightness.dark, + surface: _darkBackground, + accent: AppColors.blue, + ); + + static ThemeData _build({ + required Brightness brightness, + required Color surface, + required Color accent, + }) { + final dark = brightness == Brightness.dark; + final scheme = dark + ? const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: AppColors.onPrimary, + secondary: AppColors.secondary, + onSecondary: AppColors.onPrimary, + primaryContainer: AppColors.bluePressed, + onPrimaryContainer: AppColors.onPrimary, + secondaryContainer: AppColors.raised, + onSecondaryContainer: AppColors.onPrimary, + surface: _darkBackground, + onSurface: AppColors.onSurface, + outline: AppColors.outlineDark, + outlineVariant: AppColors.outlineVariantDark, + surfaceTint: AppColors.blue, + inversePrimary: AppColors.blueSoft, + ) + : ColorScheme.light( + primary: AppColors.blue, + onPrimary: AppColors.onPrimary, + secondary: AppColors.secondary, + onSecondary: AppColors.onPrimary, + primaryContainer: AppColors.blue.withValues(alpha: 0.14), + onPrimaryContainer: AppColors.ink, + secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14), + onSecondaryContainer: AppColors.ink, + surface: _lightBackground, + onSurface: AppColors.ink, + outline: AppColors.outlineLight, + outlineVariant: AppColors.outlineVariantLight, + surfaceTint: AppColors.blue, + inversePrimary: AppColors.blueSoft, + ); + final base = ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: scheme, + scaffoldBackgroundColor: surface, + fontFamily: 'PlusJakartaSans', + progressIndicatorTheme: ProgressIndicatorThemeData(color: accent), + sliderTheme: SliderThemeData( + activeTrackColor: accent, + thumbColor: AppColors.onPrimary, + ), + tabBarTheme: TabBarThemeData(labelColor: accent, indicatorColor: accent), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: accent), + ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith(_buttonColor), + foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary), + minimumSize: const WidgetStatePropertyAll( + Size(0, AppDimens.buttonHeight), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStatePropertyAll( + dark ? AppColors.blueSoft : AppColors.bluePressed, + ), + minimumSize: const WidgetStatePropertyAll( + Size(0, AppDimens.buttonHeight), + ), + side: WidgetStatePropertyAll( + BorderSide( + color: dark ? AppColors.outlineDark : AppColors.outlineLight, + ), + ), + ), + ), + appBarTheme: const AppBarTheme( + systemOverlayStyle: SystemUiOverlayStyle.light, + titleTextStyle: TextStyle( + color: AppColors.onSurface, + fontFamily: 'Outfit', + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w800, + ), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? AppColors.onPrimary + : null, + ), + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) ? accent : null, + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: AppColors.bluePressed, + contentTextStyle: const TextStyle(color: AppColors.onPrimary), + ), + ); + final textTheme = base.textTheme + .apply( + fontFamily: 'PlusJakartaSans', + bodyColor: dark ? AppColors.cream : AppColors.ink, + displayColor: dark ? AppColors.onSurface : AppColors.ink, + ) + .copyWith( + displayLarge: _display(base.textTheme.displayLarge, dark), + displayMedium: _display(base.textTheme.displayMedium, dark), + displaySmall: _display(base.textTheme.displaySmall, dark), + headlineLarge: _display(base.textTheme.headlineLarge, dark), + headlineMedium: _display(base.textTheme.headlineMedium, dark), + headlineSmall: _display(base.textTheme.headlineSmall, dark), + titleLarge: _display(base.textTheme.titleLarge, dark), + ); + return base.copyWith(textTheme: textTheme); + } + + static TextStyle? _display(TextStyle? style, bool dark) => style?.copyWith( + fontFamily: 'Outfit', + color: dark ? AppColors.onSurface : AppColors.ink, + ); + + static Color _buttonColor(Set states) { + if (states.contains(WidgetState.pressed) || + states.contains(WidgetState.hovered) || + states.contains(WidgetState.focused)) { + return AppColors.bluePressed; + } + return AppColors.blue; + } +} diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart new file mode 100644 index 0000000..898d032 --- /dev/null +++ b/lib/core/widgets/app_bottom_nav.dart @@ -0,0 +1,117 @@ +// Author: fruit.com.cn + +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +/// One destination rendered by [AppBottomNav]. +class AppBottomNavItem { + const AppBottomNavItem({ + required this.tabKey, + required this.icon, + required this.selectedIcon, + required this.label, + required this.onTap, + this.selected = false, + }); + + final Key tabKey; + final IconData icon; + final IconData selectedIcon; + final String label; + final VoidCallback onTap; + final bool selected; +} + +/// Shared 3-tab navigation used by all root tabs and the player fallback. +class AppBottomNav extends StatelessWidget { + const AppBottomNav({ + super.key, + required this.items, + this.backgroundColor = AppColors.bar, + this.barKey, + }); + + final List items; + final Color backgroundColor; + final Key? barKey; + + @override + Widget build(BuildContext context) => ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: SafeArea( + top: false, + child: Container( + key: barKey, + height: AppDimens.navHeight, + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.95), + border: const Border( + top: BorderSide(color: AppColors.outlineVariantDark), + ), + ), + child: Row( + children: [for (final item in items) _NavDestination(item: item)], + ), + ), + ), + ), + ); +} + +class _NavDestination extends StatelessWidget { + const _NavDestination({required this.item}); + + final AppBottomNavItem item; + + @override + Widget build(BuildContext context) => Expanded( + child: Semantics( + button: true, + selected: item.selected, + label: item.label, + child: InkWell( + key: item.tabKey, + onTap: item.onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedScale( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + scale: item.selected ? 1 : 0.96, + child: Icon( + item.selected ? item.selectedIcon : item.icon, + size: AppDimens.navIcon, + color: item.selected + ? AppColors.blue + : Colors.white.withValues(alpha: 0.45), + ), + ), + const SizedBox(height: 2), + 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 + : Colors.white.withValues(alpha: 0.45), + letterSpacing: 0, + ), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/core/widgets/auth_gate.dart b/lib/core/widgets/auth_gate.dart new file mode 100644 index 0000000..6d5e079 --- /dev/null +++ b/lib/core/widgets/auth_gate.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../pages/auth/auth_view.dart'; +import '../data/services/auth_controller.dart'; +import '../theme/app_colors.dart'; + +/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库) +Future openAuthPage(BuildContext context, {bool signUpMode = false}) { + return Navigator.of(context).push( + MaterialPageRoute(builder: (_) => AuthPage(signUpMode: signUpMode)), + ); +} + +/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。 +/// +/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。 +Future ensureSignedIn(BuildContext context) async { + final auth = context.read(); + if (auth.isSignedIn) return true; + + final l10n = AppLocalizations.of(context)!; + final go = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.authGateTitle, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.authGateMessage, + style: const TextStyle(color: AppColors.muted, fontSize: 13), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(sheetContext).pop(false), + child: Text(l10n.cancel), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + ), + onPressed: () => Navigator.of(sheetContext).pop(true), + child: Text(l10n.authGateAction), + ), + ), + ], + ), + ], + ), + ), + ), + ); + + if (go == true && context.mounted) { + await openAuthPage(context); + } + return false; +} + +/// 页面级登录引导,用于记录、收藏和下载等必须登录的独立页面。 +class AuthGatePanel extends StatelessWidget { + const AuthGatePanel({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Center( + key: const Key('auth-gate-panel'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.authGateTitle, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + l10n.authGateMessage, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 13), + ), + const SizedBox(height: 18), + FilledButton( + onPressed: () => openAuthPage(context), + child: Text(l10n.authGateAction), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/download_action.dart b/lib/core/widgets/download_action.dart new file mode 100644 index 0000000..6da727b --- /dev/null +++ b/lib/core/widgets/download_action.dart @@ -0,0 +1,33 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../data/services/download_controller.dart'; +import '../platform/haptics.dart'; +import 'auth_gate.dart'; + +/// 详情页与播放页共用的下载授权入口。 +/// +/// 未登录先走登录闸;业务拒绝时直接展示后端 message。 +Future requestEpisodeDownload( + BuildContext context, + String episodeId, +) async { + if (!await ensureSignedIn(context)) return; + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await context.read().grant(episodeId); + if (!context.mounted) return; + final l10n = AppLocalizations.of(context)!; + final hasBackendMessage = result.message?.trim().isNotEmpty ?? false; + final message = result.ok + ? l10n.downloadGranted + : hasBackendMessage + ? result.message! + : l10n.downloadGrantFailed; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart new file mode 100644 index 0000000..da31b46 --- /dev/null +++ b/lib/core/widgets/empty_state.dart @@ -0,0 +1,168 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +const double _regularIconSize = 48; +const double _compactIconSize = 32; +const double _maxContentWidth = 260; +const double _regularHorizontal = 24; +const double _regularVertical = 28; +const double _compactPad = 16; +const double _regularGap = 20; +const double _compactGap = 12; +const double _actionMinHeight = 44; + +/// 统一空列表占位:一枚图标、标题、可选说明与操作。 +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.actionLabel, + this.actionKey, + this.onAction, + this.compact = false, + }); + + final IconData icon; + final String title; + final String? subtitle; + final String? actionLabel; + final Key? actionKey; + final VoidCallback? onAction; + final bool compact; + + @override + Widget build(BuildContext context) { + final hasAction = actionLabel != null && onAction != null; + return Center( + child: ConstrainedBox( + key: const Key('empty-state-content'), + constraints: const BoxConstraints(maxWidth: _maxContentWidth), + child: Padding( + key: const Key('empty-state-padding'), + padding: EdgeInsets.symmetric( + horizontal: compact ? _compactPad : _regularHorizontal, + vertical: compact ? _compactPad : _regularVertical, + ), + child: Semantics( + container: true, + label: title, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: _EmptyMark(icon: icon, compact: compact), + ), + SizedBox(height: compact ? _compactGap : _regularGap), + _EmptyCopy( + title: title, + subtitle: subtitle, + actionLabel: hasAction ? actionLabel : null, + actionKey: actionKey, + onAction: hasAction ? onAction : null, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 空状态只留一枚图标,不加水印底和描边。 +class _EmptyMark extends StatelessWidget { + const _EmptyMark({required this.icon, required this.compact}); + + final IconData icon; + final bool compact; + + @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, + ); + } +} + +class _EmptyCopy extends StatelessWidget { + const _EmptyCopy({ + required this.title, + required this.subtitle, + required this.actionLabel, + required this.actionKey, + required this.onAction, + }); + + final String title; + final String? subtitle; + final String? actionLabel; + final Key? actionKey; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Text( + title, + key: const Key('empty-state-title'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: scheme.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 6), + ExcludeSemantics( + child: Text( + subtitle!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.emptyStateSubtitle, + height: 1.45, + ), + ), + ), + ], + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 8), + TextButton( + key: actionKey ?? const Key('empty-state-action'), + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: AppColors.blueSoft, + minimumSize: const Size(44, _actionMinHeight), + padding: const EdgeInsets.symmetric(horizontal: 14), + ), + child: Text( + actionLabel!, + style: const TextStyle(fontSize: AppDimens.emptyStateAction), + ), + ), + ], + ], + ); + } +} diff --git a/lib/core/widgets/glass_panel.dart b/lib/core/widgets/glass_panel.dart new file mode 100644 index 0000000..02d144c --- /dev/null +++ b/lib/core/widgets/glass_panel.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +/// Dark glass surface used by the home info card and floating controls. +class GlassPanel extends StatelessWidget { + const GlassPanel({ + super.key, + required this.child, + this.radius = AppDimens.glassRadius, + this.blur = 10, + this.padding, + this.borderColor = AppColors.glassBorder, + this.gradient, + }); + + final Widget child; + final double radius; + final double blur; + final EdgeInsetsGeometry? padding; + final Color borderColor; + final Gradient? gradient; + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(radius); + final surface = DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all(color: borderColor), + gradient: + gradient ?? + const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x24FFFFFF), Color(0x0DFFFFFF)], + ), + ), + child: Padding(padding: padding ?? EdgeInsets.zero, child: child), + ); + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: surface, + ), + ); + } +} diff --git a/lib/core/widgets/library_drama_tile.dart b/lib/core/widgets/library_drama_tile.dart new file mode 100644 index 0000000..2c715ff --- /dev/null +++ b/lib/core/widgets/library_drama_tile.dart @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../data/models/drama.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; +import 'remote_image.dart'; + +/// 收藏等账号列表复用的剧集卡片,视觉与"我的"页列表保持一致。 +class LibraryDramaTile extends StatelessWidget { + const LibraryDramaTile({ + super.key, + required this.drama, + required this.onTap, + this.trailing, + }); + + final Drama drama; + final VoidCallback? onTap; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppDimens.innerPadding), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + child: RemoteImage(drama.posterUrl, width: 74, height: 103), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 7), + Text( + drama.genre.isEmpty ? drama.category : drama.genre, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + ?trailing, + ], + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart new file mode 100644 index 0000000..455a342 --- /dev/null +++ b/lib/core/widgets/remote_image.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; + +/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。 +class RemoteImage extends StatelessWidget { + const RemoteImage( + this.path, { + super.key, + this.width, + this.height, + this.fit = BoxFit.cover, + this.showFallbackIcon = false, + this.fallbackIconKey, + }); + + final String path; + final double? width; + final double? height; + final BoxFit fit; + final bool showFallbackIcon; + final Key? fallbackIconKey; + + @override + Widget build(BuildContext context) { + final url = AppConfig.mediaUrl(path); + if (url.isEmpty) return _fallback(); + return Image.network( + url, + width: width, + height: height, + fit: fit, + errorBuilder: (_, _, _) => _fallback(), + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return _fallback(); + }, + ); + } + + Widget _fallback() { + return Container( + width: width, + height: height, + color: AppColors.bar, + alignment: Alignment.center, + child: showFallbackIcon + ? Icon( + Icons.image_not_supported_outlined, + key: fallbackIconKey, + color: AppColors.muted, + size: 32, + ) + : null, + ); + } +} diff --git a/lib/core/widgets/server_endpoint_dialog.dart b/lib/core/widgets/server_endpoint_dialog.dart new file mode 100644 index 0000000..66d6e9d --- /dev/null +++ b/lib/core/widgets/server_endpoint_dialog.dart @@ -0,0 +1,128 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../config/app_config.dart'; +import '../config/server_endpoint.dart'; +import '../theme/app_colors.dart'; + +/// Opens the debug-only runtime server address editor. +Future showServerEndpointDialog( + BuildContext context, + AppLocalizations l10n, +) async { + final changed = await showDialog( + context: context, + builder: (_) => _ServerEndpointDialog(l10n: l10n), + ); + if (changed == true && context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.restartToApply))); + return true; + } + return false; +} + +class _ServerEndpointDialog extends StatefulWidget { + const _ServerEndpointDialog({required this.l10n}); + + final AppLocalizations l10n; + + @override + State<_ServerEndpointDialog> createState() => _ServerEndpointDialogState(); +} + +class _ServerEndpointDialogState extends State<_ServerEndpointDialog> { + late final TextEditingController _controller = TextEditingController( + text: AppConfig.apiBaseUrl, + ); + String? _error; + bool _saving = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _save() async { + final normalized = ServerEndpoint.normalize(_controller.text); + if (normalized == null) { + setState(() => _error = widget.l10n.invalidServerAddress); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final saved = await ServerEndpoint.save(normalized); + if (!mounted) return; + if (!saved) { + setState(() { + _saving = false; + _error = widget.l10n.invalidServerAddress; + }); + return; + } + Navigator.pop(context, true); + } + + Future _restoreDefault() async { + setState(() => _saving = true); + await ServerEndpoint.clear(); + if (mounted) Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) { + final l10n = widget.l10n; + return AlertDialog( + title: Text(l10n.serverAddress), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: const Key('server-endpoint-input'), + controller: _controller, + enabled: !_saving, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + hintText: l10n.serverAddressHint, + errorText: _error, + errorMaxLines: 2, + ), + ), + const SizedBox(height: 10), + Text( + l10n.restartToApply, + key: const Key('server-endpoint-restart-hint'), + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + const SizedBox(height: 4), + TextButton( + key: const Key('server-endpoint-restore'), + onPressed: _saving ? null : _restoreDefault, + child: Text(l10n.restoreDefault), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + key: const Key('server-endpoint-save'), + onPressed: _saving ? null : _save, + child: Text(l10n.saveAddress), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4559e95..b710ee7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,6 +1,6 @@ { "@@locale": "en", - "appName": "Nontonki", + "appName": "FruitShorts", "demo": "DEMO", "homeHeadline": "Watch now", "homeSubtitle": "Free short drama preview.", @@ -15,6 +15,14 @@ "discoverTab": "Discover", "searchDiscover": "Search dramas, actors, genres...", "featured": "Featured", + "recommended": "Recommended", + "fruitShortsBrand": "FruitShorts", + "authLandingSubtitle": "Sign in to sync progress, favorites and coins", + "comingSoon": "Coming soon", + "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", + "authContinueGoogle": "Continue with Google", + "authPhoneSignIn": "Phone sign in", + "authEmailSignIn": "Email sign in", "suspense": "Suspense", "romance": "Romance", "actionGenre": "Action", @@ -23,21 +31,52 @@ "newReleases": "New Releases", "trendingMicroDramas": "Trending Micro-Dramas", "seeAll": "See All", + "trendingSubtitle": "Most binge-watched this week", + "verticalMiniSeries": "VERTICAL MINI-SERIES", + "followSeries": "Follow Series", + "seasonOneVerticalCinema": "Season 1 • Vertical Cinema", + "seasonOneOriginalRelease": "Season 1 • Original Release", + "startWatching": "Start Watching", + "showLess": "Less", + "views": "views", + "episodeOfTotal": "Episode {current} / {total}", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "clearScreen": "Clear screen", + "exitClearScreen": "Exit clear screen", + "playbackSpeed": "Playback speed", + "minutesAgo": "{count} min ago", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", "watchNow": "Watch free", "noSearchResults": "No matching dramas.", + "emptyHint": "New content will appear here.", "demoCatalogNotice": "Three demo dramas share the same local video.", "myTab": "My", "localViewer": "Local Viewer", "localDemoProfile": "Local demo profile", "continueWatching": "Continue Watching", "watchedPercent": "Watched {percent}%", - "@watchedPercent": {"placeholders": {"percent": {"type": "int"}}}, + "@watchedPercent": { + "placeholders": { + "percent": { + "type": "int" + } + } + }, "resume": "Resume", "history": "History", - "watchlist": "Watchlist", + "watchlist": "Favorites", "likedTab": "Liked", "all": "All", "inProgress": "In Progress", @@ -49,6 +88,7 @@ "animatedTab": "Animated", "recommendedTab": "Recommended", "noCategoryDramas": "No dramas in this category yet.", + "allDramas": "All Dramas", "trendingTab": "Trending", "liveActionTab": "Live Action", "search": "Search", @@ -59,29 +99,91 @@ "save": "Save", "comments": "Comments", "share": "Share", - "localOnlyComments": "Only visible on this device", "noComments": "No comments yet.", "writeComment": "Write a comment", "postComment": "Post", + "loadMore": "Load more", + "commentsLoadFailed": "Comments could not be loaded.", + "commentPostFailed": "Could not post your comment.", + "delete": "Delete", + "deleteComment": "Delete comment", + "deleteCommentTitle": "Delete this comment?", + "deleteCommentMessage": "This removes your comment from the conversation.", + "commentDeleteFailed": "Could not delete this comment.", + "underReview": "Under review", + "reply": "Reply", + "replyingTo": "Replying to {name}", + "@replyingTo": { + "placeholders": { + "name": {} + } + }, + "cancelReply": "Cancel reply", + "viewReplies": "View replies ({count})", + "@viewReplies": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "hideReplies": "Hide replies", + "anonymousUser": "Viewer", + "more": "More", + "likeFailed": "Could not update like. Please retry.", + "reportComment": "Report comment", + "reportDrama": "Report drama", + "reportReason": "Reason", + "reportReasonSpam": "Spam", + "reportReasonAbuse": "Harassment", + "reportReasonPorn": "Sexual content", + "reportReasonAd": "Advertising", + "reportReasonOther": "Other", + "reportDescriptionHint": "Describe the issue (optional)", + "reportSubmit": "Submit report", + "reportSubmitted": "Report submitted. Thank you.", + "reportFailed": "Could not submit report.", "shareFailed": "Could not open the share sheet.", "nowPlaying": "Playing now", - "watchFullscreen": "Watch in landscape", - "exitFullscreen": "Exit fullscreen", "pictureInPicture": "Picture in picture", "pipUnavailable": "Picture in picture is unavailable on this device", "originalTag": "Original", "dramaTag": "Short drama", "episodeLabel": "Episode {number}", - "@episodeLabel": {"placeholders": {"number": {"type": "int"}}}, + "@episodeLabel": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "exploreSeries": "Explore series", "episodeCount": "{count} EPISODES", - "@episodeCount": {"placeholders": {"count": {"type": "int"}}}, + "@episodeCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "technicalDemoCount": "FREE PREVIEW · {count} EPISODES", - "@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}}, + "@technicalDemoCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "episodes": "Episodes", "watchEpisodeOne": "Watch episode 1", "continueEpisode": "Continue episode {number}", - "@continueEpisode": {"placeholders": {"number": {"type": "int"}}}, + "@continueEpisode": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "freeTestMedia": "Same demo clip · free", "lockedStoreUnavailable": "Locked · store unavailable", "freeTestClip": "Free test clip", @@ -89,14 +191,49 @@ "freeSamplesNotice": "Two free samples · store setup pending", "demoTestMedia": "FREE · SAME DEMO CLIP", "episodeProgress": "EPISODE {number} / {total}", - "@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}}, + "@episodeProgress": { + "placeholders": { + "number": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "testVideoNotice": "Self-generated technical test video", "lockedTitle": "Episode {number} is locked", - "@lockedTitle": {"placeholders": {"number": {"type": "int"}}}, + "@lockedTitle": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.", "playbackError": "This clip could not be played.", "catalogError": "The catalog could not be loaded.", + "categoriesError": "Categories could not be loaded.", + "detailError": "The episode list could not be loaded.", + "noMediaNotice": "This episode has no playable media yet.", "noStories": "No stories available yet.", + "notices": "Notices", + "noNotices": "No notices yet.", + "noticesLoadFailed": "Notices could not be loaded.", + "noticeDetails": "Notice details", + "noticeAnnouncement": "Notice", + "close": "Close", + "carouselSemantics": "Banner {current} of {total}", + "@carouselSemantics": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "retry": "Retry", "backToSeries": "Back to series", "nextEpisode": "Next episode", @@ -112,6 +249,7 @@ "systemLanguage": "System", "english": "English", "simplifiedChinese": "简体中文", + "bahasaIndonesia": "Bahasa Indonesia", "episodeOneTitle": "First Light", "episodeTwoTitle": "A New Frequency", "episodeThreeTitle": "Crossing Over", @@ -129,6 +267,214 @@ "genreDramaMystery": "Drama · Mystery", "continueWatchingHeading": "CONTINUE WATCHING", "preferencesHeading": "PREFERENCES", - "brandMark": "NONTONKI · DEMO", - "shareFooter": "Nontonki demo" + "brandMark": "FRUITSHORTS", + "shareFooter": "FruitShorts", + "shortsTab": "Shorts", + "myListTab": "My List", + "watchFull": "Watch Full", + "swipeForNext": "Swipe up for next", + "logIn": "Log in", + "logInHint": "Keep your coins and list", + "rewards": "Rewards", + "forYou": "For You", + "hot": "Hot", + "newest": "New", + "favorite": "Favorite", + "myAccount": "My account", + "coins": "Coins", + "topUpWallet": "Top Up Wallet", + "vipMembership": "VIP Membership", + "notActive": "Not Active", + "joinVip": "Join VIP", + "earnRewards": "Earn Rewards", + "freeBonus": "Get Free Bonus", + "watchHistory": "Watch History", + "settings": "Settings", + "serverAddress": "Server address", + "serverSettings": "Server settings", + "serverAddressHint": "http(s)://host:port", + "saveAddress": "Save address", + "restartToApply": "Restart the app to apply.", + "restoreDefault": "Restore default", + "invalidServerAddress": "Enter a valid http(s) server address.", + "signOut": "Sign Out", + "mockUserName": "Vicky sheng", + "mockUserId": "ID: M1CZWVYE", + "mockNotice": "Sample data only.", + "topBadge": "TOP 1", + "signIn": "Sign In", + "signUp": "Sign Up", + "authSignInTitle": "Welcome back", + "authSignUpTitle": "Create your account", + "authSubtitle": "Sign in with your email and password.", + "authSignUpSubtitle": "Create an account to keep your list, history, and progress.", + "authBrandTagline": "Short dramas made for Indonesia", + "authEmailLabel": "Email", + "authPasswordLabel": "Password", + "authPasswordHint": "At least 8 characters, letters and numbers", + "authEmailRequired": "Enter your email", + "authEmailInvalid": "That email doesn't look right", + "authPasswordRequired": "Enter your password", + "authPasswordLength": "Password must be 8-64 characters", + "authPasswordWeak": "Password needs both letters and numbers", + "authNetworkError": "Network error, please try again", + "authSwitchToSignUp": "New here? Create an account", + "authSwitchToSignIn": "Already have an account? Sign in", + "authGateTitle": "Sign in to continue", + "authGateMessage": "Watch history, favorites, comments and downloads need an account.", + "authGateAction": "Sign In", + "showPassword": "Show password", + "hidePassword": "Hide password", + "cancel": "Not now", + "noEpisodesYet": "No playable episodes yet.", + "networkError": "Network is unreachable. Please check your connection and retry.", + "progressError": "Your watch position could not be loaded.", + "favoritesError": "Your favorites could not be loaded.", + "favoriteToggleFailed": "Could not update favorites. Please retry.", + "myFavorites": "My favorites", + "noFavorites": "No favorites yet.", + "downloads": "Downloads", + "downloadsError": "Download settings could not be loaded.", + "noDownloads": "No download authorizations yet.", + "downloadedItems": "Download authorizations", + "downloadQuota": "{remaining} of {max} authorizations available", + "@downloadQuota": { + "placeholders": { + "remaining": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "downloadLicenseDays": "Authorization valid for {days} days", + "@downloadLicenseDays": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "downloadWifiOnly": "Wi-Fi only", + "downloadQuality": "Maximum quality", + "downloadStatus": "Authorization: {status}", + "@downloadStatus": { + "placeholders": { + "status": {} + } + }, + "downloadTaskStatus": "Task: {status}", + "@downloadTaskStatus": { + "placeholders": { + "status": {} + } + }, + "downloadProgress": "{progress}%", + "@downloadProgress": { + "placeholders": { + "progress": { + "type": "int" + } + } + }, + "downloadExpiresAt": "Expires {time}", + "@downloadExpiresAt": { + "placeholders": { + "time": {} + } + }, + "clearDownloadsTitle": "Clear download authorizations?", + "clearDownloadsMessage": "This revokes every download authorization for this device.", + "downloadSettingsFailed": "Could not update download settings.", + "downloadDeleteFailed": "Could not remove this download authorization.", + "downloadClearFailed": "Could not clear download authorizations.", + "downloadGrantFailed": "Could not request this download.", + "downloadGranted": "Download authorization is ready.", + "noWatchRecords": "No watch history yet.", + "recordDeleteFailed": "Could not delete this record. Please retry.", + "recordClearFailed": "Could not clear watch history. Please retry.", + "clearAll": "Clear all", + "clearWatchHistoryTitle": "Clear watch history?", + "clearWatchHistoryMessage": "This removes the watch history from your account.", + "confirmClear": "Clear", + "detailLoadFailed": "Details failed to load", + "retryDetail": "Retry", + "episodeUnavailable": "Episode unavailable", + "updatedAtLabel": "Updated {time}", + "@updatedAtLabel": { + "placeholders": { + "time": {} + } + }, + "remove": "Remove", + "unlockComingSoon": "Unlocking with coins is coming soon.", + "resumeProgress": "Resume at {position} / {total}", + "@resumeProgress": { + "placeholders": { + "position": {}, + "total": {} + } + }, + "accountCenter": "Account", + "accountProfile": "Profile", + "accountDevices": "Login devices", + "accountDataExport": "Data export", + "accountDeactivate": "Delete account", + "accountProfileLoadFailed": "Could not load your profile.", + "accountProfileSaveFailed": "Could not save your profile.", + "accountNickname": "Nickname", + "accountNicknameInvalid": "Enter 1 to 30 characters.", + "accountGender": "Gender", + "accountMale": "Male", + "accountFemale": "Female", + "accountUnknownGender": "Prefer not to say", + "accountSelectBirthday": "Set birthday", + "accountBirthdayHint": "Birthday can be set but not cleared in this version.", + "accountSaved": "Profile saved.", + "accountReadOnlyInfo": "Account information", + "accountUid": "UID", + "accountMaskedAccount": "Account", + "accountCountry": "Country", + "accountCurrency": "Currency", + "accountStatus": "Status", + "accountLanguage": "Language", + "accountTimezone": "Time zone", + "accountRegion": "Region", + "accountVipLevel": "VIP level", + "accountCreatedAt": "Registered", + "accountLastLogin": "Last login", + "accountDevicesEmpty": "No login devices found.", + "accountDevicesLoadFailed": "Could not load devices.", + "accountPlatform": "Platform", + "accountDeviceId": "Device ID", + "accountFirstSeen": "First seen", + "accountLastActive": "Last active", + "accountDeviceActive": "Active", + "accountDeviceInactive": "Inactive", + "accountExportLoadFailed": "Could not export account data.", + "accountCredentials": "Sign-in credentials", + "accountConsentRecords": "Consent records", + "accountDeviceCount": "Devices", + "accountCopyJson": "Copy full JSON", + "accountCopied": "Account data copied.", + "accountVerified": "Verified", + "accountUnverified": "Unverified", + "accountDeactivateWarning": "Requests enter a 15-day cooling-off period. All devices are signed out immediately.", + "accountDeactivateReason": "Reason (optional)", + "accountDeactivateReasonHint": "Tell us why you are leaving", + "accountRequestDeactivate": "Request account deletion", + "accountConfirmDeactivateTitle": "Request account deletion?", + "accountConfirmDeactivateMessage": "All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.", + "accountConfirmDeactivate": "Confirm", + "accountScheduledFor": "Deletion scheduled for {time}", + "@accountScheduledFor": { + "placeholders": { + "time": {} + } + }, + "accountCancelDeactivation": "Cancel deletion request", + "accountCancelSucceeded": "Deletion request cancelled.", + "accountDeactivateFailed": "Could not request account deletion.", + "accountCancelFailed": "Could not cancel the deletion request." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb new file mode 100644 index 0000000..6071753 --- /dev/null +++ b/lib/l10n/app_id.arb @@ -0,0 +1,480 @@ +{ + "@@locale": "id", + "appName": "FruitShorts", + "demo": "Demo", + "homeHeadline": "Tonton sekarang", + "homeSubtitle": "Pratinjau drama pendek gratis.", + "mediaNotice": "Video demo lokal dari pengguna · Pratinjau gratis", + "featuredDemo": "UNGGULAN · DEMO TEKNIS", + "demoTitle": "Sinyal", + "demoSynopsis": "Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.", + "homeTab": "Beranda", + "openQuickMenu": "Buka menu cepat", + "quickAccess": "Akses cepat", + "currentSeries": "Serial saat ini", + "discoverTab": "Temukan", + "searchDiscover": "Cari drama, aktor, genre...", + "featured": "Unggulan", + "recommended": "Rekomendasi", + "fruitShortsBrand": "FruitShorts", + "authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin", + "comingSoon": "Segera hadir", + "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", + "authContinueGoogle": "Lanjutkan dengan Google", + "authPhoneSignIn": "Masuk dengan ponsel", + "authEmailSignIn": "Masuk dengan email", + "suspense": "Thriller", + "romance": "Romantis", + "actionGenre": "Aksi", + "filter": "Filter", + "topCharts": "Peringkat Teratas", + "newReleases": "Rilisan Baru", + "trendingMicroDramas": "Drama Mini Populer", + "seeAll": "Lihat Semua", + "trendingSubtitle": "Paling banyak ditonton minggu ini", + "verticalMiniSeries": "SERIAL MINI VERTIKAL", + "followSeries": "Ikuti Series", + "seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal", + "seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal", + "startWatching": "Mulai Menonton", + "showLess": "Lebih sedikit", + "views": "tayangan", + "episodeOfTotal": "Episode {current} / {total}", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "clearScreen": "Bersihkan layar", + "exitClearScreen": "Keluar dari layar bersih", + "playbackSpeed": "Kecepatan putar", + "minutesAgo": "{count} mnt lalu", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, + "artworkOnly": "Gambar", + "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", + "videoPending": "Video belum siap", + "watchNow": "Tonton gratis", + "noSearchResults": "Tidak ada drama yang cocok.", + "emptyHint": "Konten baru akan muncul di sini.", + "demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.", + "myTab": "Saya", + "localViewer": "Penonton Lokal", + "localDemoProfile": "Profil demo lokal", + "continueWatching": "Lanjut Tonton", + "watchedPercent": "Sudah ditonton {percent}%", + "@watchedPercent": { + "placeholders": { + "percent": { + "type": "int" + } + } + }, + "resume": "Lanjutkan", + "history": "Riwayat", + "watchlist": "Daftar Tonton", + "likedTab": "Disukai", + "all": "Semua", + "inProgress": "Sedang Berjalan", + "completed": "Selesai", + "noSavedShows": "Belum ada tontonan di sini.", + "preferences": "Preferensi", + "appLanguage": "Bahasa Aplikasi", + "followingTab": "Mengikuti", + "animatedTab": "Animasi", + "recommendedTab": "Rekomendasi", + "noCategoryDramas": "Belum ada drama di kategori ini.", + "allDramas": "Semua Drama", + "trendingTab": "Sedang Tren", + "liveActionTab": "Live Action", + "search": "Cari", + "free": "Gratis", + "mute": "Bisukan", + "unmute": "Nyalakan suara", + "like": "Suka", + "save": "Simpan", + "comments": "Komentar", + "share": "Bagikan", + "noComments": "Belum ada komentar.", + "writeComment": "Tulis komentar", + "postComment": "Kirim", + "loadMore": "Muat lebih banyak", + "commentsLoadFailed": "Komentar tidak dapat dimuat.", + "commentPostFailed": "Komentar tidak dapat dikirim.", + "delete": "Hapus", + "deleteComment": "Hapus komentar", + "deleteCommentTitle": "Hapus komentar ini?", + "deleteCommentMessage": "Komentar ini akan dihapus dari percakapan.", + "commentDeleteFailed": "Komentar tidak dapat dihapus.", + "underReview": "Sedang ditinjau", + "reply": "Balas", + "replyingTo": "Membalas {name}", + "@replyingTo": { + "placeholders": { + "name": {} + } + }, + "cancelReply": "Batal membalas", + "viewReplies": "Lihat balasan ({count})", + "@viewReplies": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "hideReplies": "Sembunyikan balasan", + "anonymousUser": "Penonton", + "more": "Lainnya", + "likeFailed": "Status suka tidak dapat diperbarui.", + "reportComment": "Laporkan komentar", + "reportDrama": "Laporkan drama", + "reportReason": "Alasan", + "reportReasonSpam": "Spam", + "reportReasonAbuse": "Pelecehan", + "reportReasonPorn": "Konten seksual", + "reportReasonAd": "Iklan", + "reportReasonOther": "Lainnya", + "reportDescriptionHint": "Jelaskan masalah (opsional)", + "reportSubmit": "Kirim laporan", + "reportSubmitted": "Laporan terkirim. Terima kasih.", + "reportFailed": "Laporan tidak dapat dikirim.", + "shareFailed": "Tidak bisa membuka menu berbagi.", + "nowPlaying": "Sedang diputar", + "pictureInPicture": "Gambar dalam gambar", + "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", + "originalTag": "Original", + "dramaTag": "Drama pendek", + "episodeLabel": "Episode {number}", + "@episodeLabel": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "exploreSeries": "Lihat serial", + "episodeCount": "{count} EPISODE", + "@episodeCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "technicalDemoCount": "PRATINJAU GRATIS · {count} EPISODE", + "@technicalDemoCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "episodes": "Episode", + "watchEpisodeOne": "Tonton episode 1", + "continueEpisode": "Lanjut episode {number}", + "@continueEpisode": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "freeTestMedia": "Klip demo sama · gratis", + "lockedStoreUnavailable": "Terkunci · toko belum tersedia", + "freeTestClip": "Klip uji gratis", + "locked": "Terkunci", + "freeSamplesNotice": "Dua sampel gratis · pengaturan toko belum selesai", + "demoTestMedia": "GRATIS · KLIP DEMO SAMA", + "episodeProgress": "EPISODE {number} / {total}", + "@episodeProgress": { + "placeholders": { + "number": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "testVideoNotice": "Video uji teknis buatan sendiri", + "lockedTitle": "Episode {number} terkunci", + "@lockedTitle": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.", + "playbackError": "Klip ini tidak bisa diputar.", + "catalogError": "Katalog gagal dimuat.", + "categoriesError": "Kategori gagal dimuat.", + "detailError": "Daftar episode gagal dimuat.", + "noMediaNotice": "Episode ini belum punya media yang bisa diputar.", + "noStories": "Belum ada cerita.", + "notices": "Notifikasi", + "noNotices": "Belum ada notifikasi.", + "noticesLoadFailed": "Notifikasi tidak dapat dimuat.", + "noticeDetails": "Detail notifikasi", + "noticeAnnouncement": "Pengumuman", + "close": "Tutup", + "carouselSemantics": "Banner {current} dari {total}", + "@carouselSemantics": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "retry": "Coba lagi", + "backToSeries": "Kembali ke serial", + "nextEpisode": "Episode berikutnya", + "pause": "Jeda", + "play": "Putar", + "seekVideo": "Geser posisi video", + "seekFailed": "Gagal mengubah posisi putar.", + "captions": "Subtitle", + "captionsOff": "Subtitle nonaktif", + "englishCaptions": "Subtitle bahasa Inggris", + "chineseCaptions": "Subtitle bahasa Mandarin", + "language": "Bahasa", + "systemLanguage": "Sistem", + "english": "English", + "simplifiedChinese": "简体中文", + "bahasaIndonesia": "Bahasa Indonesia", + "episodeOneTitle": "Cahaya Pertama", + "episodeTwoTitle": "Frekuensi Baru", + "episodeThreeTitle": "Menyeberang", + "episodeFourTitle": "Setelah Gema", + "dramaSignalTitle": "Sinyal", + "dramaSignalSynopsis": "Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.", + "dramaCityTitle": "Cahaya Kota & Malam Beludru", + "dramaCitySynopsis": "Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.", + "dramaVengeanceTitle": "Badai Pembalasan", + "dramaVengeanceSynopsis": "Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.", + "dramaEchoesTitle": "Gema Takdir", + "genreSuspenseMystery": "Thriller · Misteri", + "genreUrbanRomance": "Romansa Kota", + "genreActionThriller": "Aksi · Thriller", + "genreDramaMystery": "Drama · Misteri", + "continueWatchingHeading": "LANJUT TONTON", + "preferencesHeading": "PREFERENSI", + "brandMark": "FRUITSHORTS", + "shareFooter": "FruitShorts", + "shortsTab": "Drama Pendek", + "myListTab": "Daftar Saya", + "watchFull": "Tonton Penuh", + "swipeForNext": "Geser ke atas untuk lanjut", + "logIn": "Masuk", + "logInHint": "Simpan koin dan daftar kamu", + "rewards": "Hadiah", + "forYou": "Untuk Kamu", + "hot": "Populer", + "newest": "Terbaru", + "favorite": "Favorit", + "myAccount": "Akun saya", + "coins": "Koin", + "topUpWallet": "Isi Ulang Dompet", + "vipMembership": "Keanggotaan VIP", + "notActive": "Tidak Aktif", + "joinVip": "Gabung VIP", + "earnRewards": "Dapatkan Hadiah", + "freeBonus": "Bonus Gratis", + "watchHistory": "Riwayat Tontonan", + "settings": "Pengaturan", + "serverAddress": "Alamat server", + "serverSettings": "Pengaturan server", + "serverAddressHint": "http(s)://host:port", + "saveAddress": "Simpan alamat", + "restartToApply": "Mulai ulang aplikasi untuk menerapkan.", + "restoreDefault": "Pulihkan default", + "invalidServerAddress": "Masukkan alamat server http(s) yang valid.", + "signOut": "Keluar", + "mockUserName": "Vicky sheng", + "mockUserId": "ID: M1CZWVYE", + "mockNotice": "Hanya data contoh.", + "topBadge": "TOP 1", + "signIn": "Masuk", + "signUp": "Daftar", + "authSignInTitle": "Selamat datang kembali", + "authSignUpTitle": "Buat akun kamu", + "authSubtitle": "Masuk dengan email dan kata sandi.", + "authSignUpSubtitle": "Buat akun untuk menyimpan daftar, riwayat, dan progresmu.", + "authBrandTagline": "Drama pendek untuk Indonesia", + "authEmailLabel": "Email", + "authPasswordLabel": "Kata sandi", + "authPasswordHint": "Minimal 8 karakter, berisi huruf dan angka", + "authEmailRequired": "Masukkan email kamu", + "authEmailInvalid": "Email itu sepertinya tidak benar", + "authPasswordRequired": "Masukkan kata sandi kamu", + "authPasswordLength": "Kata sandi harus 8-64 karakter", + "authPasswordWeak": "Kata sandi perlu huruf dan angka", + "authNetworkError": "Jaringan bermasalah, coba lagi", + "authSwitchToSignUp": "Baru di sini? Buat akun", + "authSwitchToSignIn": "Sudah punya akun? Masuk", + "authGateTitle": "Masuk untuk melanjutkan", + "authGateMessage": "Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.", + "authGateAction": "Masuk", + "showPassword": "Tampilkan kata sandi", + "hidePassword": "Sembunyikan kata sandi", + "cancel": "Nanti saja", + "noEpisodesYet": "Belum ada episode yang bisa diputar.", + "networkError": "Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.", + "progressError": "Posisi tonton gagal dimuat.", + "favoritesError": "Daftar favorit gagal dimuat.", + "favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.", + "myFavorites": "Favorit saya", + "noFavorites": "Belum ada favorit.", + "downloads": "Unduhan", + "downloadsError": "Pengaturan unduhan gagal dimuat.", + "noDownloads": "Belum ada izin unduhan.", + "downloadedItems": "Izin unduhan", + "downloadQuota": "{remaining} dari {max} izin tersedia", + "@downloadQuota": { + "placeholders": { + "remaining": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "downloadLicenseDays": "Izin berlaku {days} hari", + "@downloadLicenseDays": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "downloadWifiOnly": "Hanya Wi-Fi", + "downloadQuality": "Kualitas maksimum", + "downloadStatus": "Izin: {status}", + "@downloadStatus": { + "placeholders": { + "status": {} + } + }, + "downloadTaskStatus": "Tugas: {status}", + "@downloadTaskStatus": { + "placeholders": { + "status": {} + } + }, + "downloadProgress": "{progress}%", + "@downloadProgress": { + "placeholders": { + "progress": { + "type": "int" + } + } + }, + "downloadExpiresAt": "Berakhir {time}", + "@downloadExpiresAt": { + "placeholders": { + "time": {} + } + }, + "clearDownloadsTitle": "Hapus izin unduhan?", + "clearDownloadsMessage": "Semua izin unduhan untuk perangkat ini akan dicabut.", + "downloadSettingsFailed": "Gagal memperbarui pengaturan unduhan.", + "downloadDeleteFailed": "Gagal menghapus izin unduhan ini.", + "downloadClearFailed": "Gagal menghapus semua izin unduhan.", + "downloadGrantFailed": "Gagal meminta unduhan ini.", + "downloadGranted": "Izin unduhan sudah siap.", + "noWatchRecords": "Belum ada riwayat tontonan.", + "recordDeleteFailed": "Gagal menghapus riwayat ini. Coba lagi.", + "recordClearFailed": "Gagal menghapus semua riwayat. Coba lagi.", + "clearAll": "Hapus semua", + "clearWatchHistoryTitle": "Hapus riwayat tontonan?", + "clearWatchHistoryMessage": "Semua riwayat akan dihapus dari akun kamu.", + "confirmClear": "Hapus", + "detailLoadFailed": "Detail drama gagal dimuat", + "retryDetail": "Coba lagi", + "episodeUnavailable": "Episode tidak tersedia", + "updatedAtLabel": "Diperbarui {time}", + "@updatedAtLabel": { + "placeholders": { + "time": {} + } + }, + "remove": "Hapus", + "unlockComingSoon": "Pembukaan dengan koin segera hadir.", + "resumeProgress": "Lanjut di {position} / {total}", + "@resumeProgress": { + "placeholders": { + "position": {}, + "total": {} + } + }, + "accountCenter": "Akun", + "accountProfile": "Profil", + "accountDevices": "Perangkat masuk", + "accountDataExport": "Ekspor data", + "accountDeactivate": "Hapus akun", + "accountProfileLoadFailed": "Profil gagal dimuat.", + "accountProfileSaveFailed": "Profil gagal disimpan.", + "accountNickname": "Nama panggilan", + "accountNicknameInvalid": "Masukkan 1 hingga 30 karakter.", + "accountGender": "Jenis kelamin", + "accountMale": "Laki-laki", + "accountFemale": "Perempuan", + "accountUnknownGender": "Tidak ingin menyebutkan", + "accountSelectBirthday": "Atur tanggal lahir", + "accountBirthdayHint": "Tanggal lahir dapat diatur, tetapi belum dapat dihapus pada versi ini.", + "accountSaved": "Profil disimpan.", + "accountReadOnlyInfo": "Informasi akun", + "accountUid": "UID", + "accountMaskedAccount": "Akun", + "accountCountry": "Negara", + "accountCurrency": "Mata uang", + "accountStatus": "Status", + "accountLanguage": "Bahasa", + "accountTimezone": "Zona waktu", + "accountRegion": "Wilayah", + "accountVipLevel": "Level VIP", + "accountCreatedAt": "Terdaftar", + "accountLastLogin": "Terakhir masuk", + "accountDevicesEmpty": "Belum ada perangkat yang masuk.", + "accountDevicesLoadFailed": "Perangkat gagal dimuat.", + "accountPlatform": "Platform", + "accountDeviceId": "ID perangkat", + "accountFirstSeen": "Pertama terlihat", + "accountLastActive": "Terakhir aktif", + "accountDeviceActive": "Aktif", + "accountDeviceInactive": "Tidak aktif", + "accountExportLoadFailed": "Data akun gagal diekspor.", + "accountCredentials": "Kredensial masuk", + "accountConsentRecords": "Catatan persetujuan", + "accountDeviceCount": "Perangkat", + "accountCopyJson": "Salin JSON lengkap", + "accountCopied": "Data akun disalin.", + "accountVerified": "Terverifikasi", + "accountUnverified": "Belum terverifikasi", + "accountDeactivateWarning": "Permintaan masuk masa tenang 15 hari. Semua perangkat langsung keluar.", + "accountDeactivateReason": "Alasan (opsional)", + "accountDeactivateReasonHint": "Beri tahu alasan kamu", + "accountRequestDeactivate": "Ajukan penghapusan akun", + "accountConfirmDeactivateTitle": "Ajukan penghapusan akun?", + "accountConfirmDeactivateMessage": "Semua perangkat akan langsung keluar. Setelah masuk lagi, kamu dapat membatalkannya selama masa tenang 15 hari.", + "accountConfirmDeactivate": "Konfirmasi", + "accountScheduledFor": "Penghapusan dijadwalkan pada {time}", + "@accountScheduledFor": { + "placeholders": { + "time": {} + } + }, + "accountCancelDeactivation": "Batalkan permintaan penghapusan", + "accountCancelSucceeded": "Permintaan penghapusan dibatalkan.", + "accountDeactivateFailed": "Permintaan penghapusan gagal dikirim.", + "accountCancelFailed": "Permintaan penghapusan gagal dibatalkan." +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bc4494e..63446d1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1,5 +1,3 @@ -// Author: fruit.com.cn - import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -8,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart' as intl; import 'app_localizations_en.dart'; +import 'app_localizations_id.dart'; import 'app_localizations_zh.dart'; // ignore_for_file: type=lint @@ -97,13 +96,14 @@ abstract class AppLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ Locale('en'), + Locale('id'), Locale('zh'), ]; /// No description provided for @appName. /// /// In en, this message translates to: - /// **'Nontonki'** + /// **'FruitShorts'** String get appName; /// No description provided for @demo. @@ -190,6 +190,54 @@ abstract class AppLocalizations { /// **'Featured'** String get featured; + /// No description provided for @recommended. + /// + /// In en, this message translates to: + /// **'Recommended'** + String get recommended; + + /// No description provided for @fruitShortsBrand. + /// + /// In en, this message translates to: + /// **'FruitShorts'** + String get fruitShortsBrand; + + /// No description provided for @authLandingSubtitle. + /// + /// In en, this message translates to: + /// **'Sign in to sync progress, favorites and coins'** + String get authLandingSubtitle; + + /// No description provided for @comingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get comingSoon; + + /// No description provided for @authLegal. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to the Terms of Service and Privacy Policy.'** + String get authLegal; + + /// No description provided for @authContinueGoogle. + /// + /// In en, this message translates to: + /// **'Continue with Google'** + String get authContinueGoogle; + + /// No description provided for @authPhoneSignIn. + /// + /// In en, this message translates to: + /// **'Phone sign in'** + String get authPhoneSignIn; + + /// No description provided for @authEmailSignIn. + /// + /// In en, this message translates to: + /// **'Email sign in'** + String get authEmailSignIn; + /// No description provided for @suspense. /// /// In en, this message translates to: @@ -238,6 +286,84 @@ abstract class AppLocalizations { /// **'See All'** String get seeAll; + /// No description provided for @trendingSubtitle. + /// + /// In en, this message translates to: + /// **'Most binge-watched this week'** + String get trendingSubtitle; + + /// No description provided for @verticalMiniSeries. + /// + /// In en, this message translates to: + /// **'VERTICAL MINI-SERIES'** + String get verticalMiniSeries; + + /// No description provided for @followSeries. + /// + /// In en, this message translates to: + /// **'Follow Series'** + String get followSeries; + + /// No description provided for @seasonOneVerticalCinema. + /// + /// In en, this message translates to: + /// **'Season 1 • Vertical Cinema'** + String get seasonOneVerticalCinema; + + /// No description provided for @seasonOneOriginalRelease. + /// + /// In en, this message translates to: + /// **'Season 1 • Original Release'** + String get seasonOneOriginalRelease; + + /// No description provided for @startWatching. + /// + /// In en, this message translates to: + /// **'Start Watching'** + String get startWatching; + + /// No description provided for @showLess. + /// + /// In en, this message translates to: + /// **'Less'** + String get showLess; + + /// No description provided for @views. + /// + /// In en, this message translates to: + /// **'views'** + String get views; + + /// No description provided for @episodeOfTotal. + /// + /// In en, this message translates to: + /// **'Episode {current} / {total}'** + String episodeOfTotal(int current, int total); + + /// No description provided for @clearScreen. + /// + /// In en, this message translates to: + /// **'Clear screen'** + String get clearScreen; + + /// No description provided for @exitClearScreen. + /// + /// In en, this message translates to: + /// **'Exit clear screen'** + String get exitClearScreen; + + /// No description provided for @playbackSpeed. + /// + /// In en, this message translates to: + /// **'Playback speed'** + String get playbackSpeed; + + /// No description provided for @minutesAgo. + /// + /// In en, this message translates to: + /// **'{count} min ago'** + String minutesAgo(int count); + /// No description provided for @artworkOnly. /// /// In en, this message translates to: @@ -268,6 +394,12 @@ abstract class AppLocalizations { /// **'No matching dramas.'** String get noSearchResults; + /// No description provided for @emptyHint. + /// + /// In en, this message translates to: + /// **'New content will appear here.'** + String get emptyHint; + /// No description provided for @demoCatalogNotice. /// /// In en, this message translates to: @@ -319,7 +451,7 @@ abstract class AppLocalizations { /// No description provided for @watchlist. /// /// In en, this message translates to: - /// **'Watchlist'** + /// **'Favorites'** String get watchlist; /// No description provided for @likedTab. @@ -388,6 +520,12 @@ abstract class AppLocalizations { /// **'No dramas in this category yet.'** String get noCategoryDramas; + /// No description provided for @allDramas. + /// + /// In en, this message translates to: + /// **'All Dramas'** + String get allDramas; + /// No description provided for @trendingTab. /// /// In en, this message translates to: @@ -448,12 +586,6 @@ abstract class AppLocalizations { /// **'Share'** String get share; - /// No description provided for @localOnlyComments. - /// - /// In en, this message translates to: - /// **'Only visible on this device'** - String get localOnlyComments; - /// No description provided for @noComments. /// /// In en, this message translates to: @@ -472,6 +604,180 @@ abstract class AppLocalizations { /// **'Post'** String get postComment; + /// No description provided for @loadMore. + /// + /// In en, this message translates to: + /// **'Load more'** + String get loadMore; + + /// No description provided for @commentsLoadFailed. + /// + /// In en, this message translates to: + /// **'Comments could not be loaded.'** + String get commentsLoadFailed; + + /// No description provided for @commentPostFailed. + /// + /// In en, this message translates to: + /// **'Could not post your comment.'** + String get commentPostFailed; + + /// No description provided for @delete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// No description provided for @deleteComment. + /// + /// In en, this message translates to: + /// **'Delete comment'** + String get deleteComment; + + /// No description provided for @deleteCommentTitle. + /// + /// In en, this message translates to: + /// **'Delete this comment?'** + String get deleteCommentTitle; + + /// No description provided for @deleteCommentMessage. + /// + /// In en, this message translates to: + /// **'This removes your comment from the conversation.'** + String get deleteCommentMessage; + + /// No description provided for @commentDeleteFailed. + /// + /// In en, this message translates to: + /// **'Could not delete this comment.'** + String get commentDeleteFailed; + + /// No description provided for @underReview. + /// + /// In en, this message translates to: + /// **'Under review'** + String get underReview; + + /// No description provided for @reply. + /// + /// In en, this message translates to: + /// **'Reply'** + String get reply; + + /// No description provided for @replyingTo. + /// + /// In en, this message translates to: + /// **'Replying to {name}'** + String replyingTo(Object name); + + /// No description provided for @cancelReply. + /// + /// In en, this message translates to: + /// **'Cancel reply'** + String get cancelReply; + + /// No description provided for @viewReplies. + /// + /// In en, this message translates to: + /// **'View replies ({count})'** + String viewReplies(int count); + + /// No description provided for @hideReplies. + /// + /// In en, this message translates to: + /// **'Hide replies'** + String get hideReplies; + + /// No description provided for @anonymousUser. + /// + /// In en, this message translates to: + /// **'Viewer'** + String get anonymousUser; + + /// No description provided for @more. + /// + /// In en, this message translates to: + /// **'More'** + String get more; + + /// No description provided for @likeFailed. + /// + /// In en, this message translates to: + /// **'Could not update like. Please retry.'** + String get likeFailed; + + /// No description provided for @reportComment. + /// + /// In en, this message translates to: + /// **'Report comment'** + String get reportComment; + + /// No description provided for @reportDrama. + /// + /// In en, this message translates to: + /// **'Report drama'** + String get reportDrama; + + /// No description provided for @reportReason. + /// + /// In en, this message translates to: + /// **'Reason'** + String get reportReason; + + /// No description provided for @reportReasonSpam. + /// + /// In en, this message translates to: + /// **'Spam'** + String get reportReasonSpam; + + /// No description provided for @reportReasonAbuse. + /// + /// In en, this message translates to: + /// **'Harassment'** + String get reportReasonAbuse; + + /// No description provided for @reportReasonPorn. + /// + /// In en, this message translates to: + /// **'Sexual content'** + String get reportReasonPorn; + + /// No description provided for @reportReasonAd. + /// + /// In en, this message translates to: + /// **'Advertising'** + String get reportReasonAd; + + /// No description provided for @reportReasonOther. + /// + /// In en, this message translates to: + /// **'Other'** + String get reportReasonOther; + + /// No description provided for @reportDescriptionHint. + /// + /// In en, this message translates to: + /// **'Describe the issue (optional)'** + String get reportDescriptionHint; + + /// No description provided for @reportSubmit. + /// + /// In en, this message translates to: + /// **'Submit report'** + String get reportSubmit; + + /// No description provided for @reportSubmitted. + /// + /// In en, this message translates to: + /// **'Report submitted. Thank you.'** + String get reportSubmitted; + + /// No description provided for @reportFailed. + /// + /// In en, this message translates to: + /// **'Could not submit report.'** + String get reportFailed; + /// No description provided for @shareFailed. /// /// In en, this message translates to: @@ -484,18 +790,6 @@ abstract class AppLocalizations { /// **'Playing now'** String get nowPlaying; - /// No description provided for @watchFullscreen. - /// - /// In en, this message translates to: - /// **'Watch in landscape'** - String get watchFullscreen; - - /// No description provided for @exitFullscreen. - /// - /// In en, this message translates to: - /// **'Exit fullscreen'** - String get exitFullscreen; - /// No description provided for @pictureInPicture. /// /// In en, this message translates to: @@ -634,12 +928,72 @@ abstract class AppLocalizations { /// **'The catalog could not be loaded.'** String get catalogError; + /// No description provided for @categoriesError. + /// + /// In en, this message translates to: + /// **'Categories could not be loaded.'** + String get categoriesError; + + /// No description provided for @detailError. + /// + /// In en, this message translates to: + /// **'The episode list could not be loaded.'** + String get detailError; + + /// No description provided for @noMediaNotice. + /// + /// In en, this message translates to: + /// **'This episode has no playable media yet.'** + String get noMediaNotice; + /// No description provided for @noStories. /// /// In en, this message translates to: /// **'No stories available yet.'** String get noStories; + /// No description provided for @notices. + /// + /// In en, this message translates to: + /// **'Notices'** + String get notices; + + /// No description provided for @noNotices. + /// + /// In en, this message translates to: + /// **'No notices yet.'** + String get noNotices; + + /// No description provided for @noticesLoadFailed. + /// + /// In en, this message translates to: + /// **'Notices could not be loaded.'** + String get noticesLoadFailed; + + /// No description provided for @noticeDetails. + /// + /// In en, this message translates to: + /// **'Notice details'** + String get noticeDetails; + + /// No description provided for @noticeAnnouncement. + /// + /// In en, this message translates to: + /// **'Notice'** + String get noticeAnnouncement; + + /// No description provided for @close. + /// + /// In en, this message translates to: + /// **'Close'** + String get close; + + /// No description provided for @carouselSemantics. + /// + /// In en, this message translates to: + /// **'Banner {current} of {total}'** + String carouselSemantics(int current, int total); + /// No description provided for @retry. /// /// In en, this message translates to: @@ -730,6 +1084,12 @@ abstract class AppLocalizations { /// **'简体中文'** String get simplifiedChinese; + /// No description provided for @bahasaIndonesia. + /// + /// In en, this message translates to: + /// **'Bahasa Indonesia'** + String get bahasaIndonesia; + /// No description provided for @episodeOneTitle. /// /// In en, this message translates to: @@ -835,14 +1195,932 @@ abstract class AppLocalizations { /// No description provided for @brandMark. /// /// In en, this message translates to: - /// **'NONTONKI · DEMO'** + /// **'FRUITSHORTS'** String get brandMark; /// No description provided for @shareFooter. /// /// In en, this message translates to: - /// **'Nontonki demo'** + /// **'FruitShorts'** String get shareFooter; + + /// No description provided for @shortsTab. + /// + /// In en, this message translates to: + /// **'Shorts'** + String get shortsTab; + + /// No description provided for @myListTab. + /// + /// In en, this message translates to: + /// **'My List'** + String get myListTab; + + /// No description provided for @watchFull. + /// + /// In en, this message translates to: + /// **'Watch Full'** + String get watchFull; + + /// No description provided for @swipeForNext. + /// + /// In en, this message translates to: + /// **'Swipe up for next'** + String get swipeForNext; + + /// No description provided for @logIn. + /// + /// In en, this message translates to: + /// **'Log in'** + String get logIn; + + /// No description provided for @logInHint. + /// + /// In en, this message translates to: + /// **'Keep your coins and list'** + String get logInHint; + + /// No description provided for @rewards. + /// + /// In en, this message translates to: + /// **'Rewards'** + String get rewards; + + /// No description provided for @forYou. + /// + /// In en, this message translates to: + /// **'For You'** + String get forYou; + + /// No description provided for @hot. + /// + /// In en, this message translates to: + /// **'Hot'** + String get hot; + + /// No description provided for @newest. + /// + /// In en, this message translates to: + /// **'New'** + String get newest; + + /// No description provided for @favorite. + /// + /// In en, this message translates to: + /// **'Favorite'** + String get favorite; + + /// No description provided for @myAccount. + /// + /// In en, this message translates to: + /// **'My account'** + String get myAccount; + + /// No description provided for @coins. + /// + /// In en, this message translates to: + /// **'Coins'** + String get coins; + + /// No description provided for @topUpWallet. + /// + /// In en, this message translates to: + /// **'Top Up Wallet'** + String get topUpWallet; + + /// No description provided for @vipMembership. + /// + /// In en, this message translates to: + /// **'VIP Membership'** + String get vipMembership; + + /// No description provided for @notActive. + /// + /// In en, this message translates to: + /// **'Not Active'** + String get notActive; + + /// No description provided for @joinVip. + /// + /// In en, this message translates to: + /// **'Join VIP'** + String get joinVip; + + /// No description provided for @earnRewards. + /// + /// In en, this message translates to: + /// **'Earn Rewards'** + String get earnRewards; + + /// No description provided for @freeBonus. + /// + /// In en, this message translates to: + /// **'Get Free Bonus'** + String get freeBonus; + + /// No description provided for @watchHistory. + /// + /// In en, this message translates to: + /// **'Watch History'** + String get watchHistory; + + /// No description provided for @settings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// No description provided for @serverAddress. + /// + /// In en, this message translates to: + /// **'Server address'** + String get serverAddress; + + /// No description provided for @serverSettings. + /// + /// In en, this message translates to: + /// **'Server settings'** + String get serverSettings; + + /// No description provided for @serverAddressHint. + /// + /// In en, this message translates to: + /// **'http(s)://host:port'** + String get serverAddressHint; + + /// No description provided for @saveAddress. + /// + /// In en, this message translates to: + /// **'Save address'** + String get saveAddress; + + /// No description provided for @restartToApply. + /// + /// In en, this message translates to: + /// **'Restart the app to apply.'** + String get restartToApply; + + /// No description provided for @restoreDefault. + /// + /// In en, this message translates to: + /// **'Restore default'** + String get restoreDefault; + + /// No description provided for @invalidServerAddress. + /// + /// In en, this message translates to: + /// **'Enter a valid http(s) server address.'** + String get invalidServerAddress; + + /// No description provided for @signOut. + /// + /// In en, this message translates to: + /// **'Sign Out'** + String get signOut; + + /// No description provided for @mockUserName. + /// + /// In en, this message translates to: + /// **'Vicky sheng'** + String get mockUserName; + + /// No description provided for @mockUserId. + /// + /// In en, this message translates to: + /// **'ID: M1CZWVYE'** + String get mockUserId; + + /// No description provided for @mockNotice. + /// + /// In en, this message translates to: + /// **'Sample data only.'** + String get mockNotice; + + /// No description provided for @topBadge. + /// + /// In en, this message translates to: + /// **'TOP 1'** + String get topBadge; + + /// No description provided for @signIn. + /// + /// In en, this message translates to: + /// **'Sign In'** + String get signIn; + + /// No description provided for @signUp. + /// + /// In en, this message translates to: + /// **'Sign Up'** + String get signUp; + + /// No description provided for @authSignInTitle. + /// + /// In en, this message translates to: + /// **'Welcome back'** + String get authSignInTitle; + + /// No description provided for @authSignUpTitle. + /// + /// In en, this message translates to: + /// **'Create your account'** + String get authSignUpTitle; + + /// No description provided for @authSubtitle. + /// + /// In en, this message translates to: + /// **'Sign in with your email and password.'** + String get authSubtitle; + + /// No description provided for @authSignUpSubtitle. + /// + /// In en, this message translates to: + /// **'Create an account to keep your list, history, and progress.'** + String get authSignUpSubtitle; + + /// No description provided for @authBrandTagline. + /// + /// In en, this message translates to: + /// **'Short dramas made for Indonesia'** + String get authBrandTagline; + + /// No description provided for @authEmailLabel. + /// + /// In en, this message translates to: + /// **'Email'** + String get authEmailLabel; + + /// No description provided for @authPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password'** + String get authPasswordLabel; + + /// No description provided for @authPasswordHint. + /// + /// In en, this message translates to: + /// **'At least 8 characters, letters and numbers'** + String get authPasswordHint; + + /// No description provided for @authEmailRequired. + /// + /// In en, this message translates to: + /// **'Enter your email'** + String get authEmailRequired; + + /// No description provided for @authEmailInvalid. + /// + /// In en, this message translates to: + /// **'That email doesn\'t look right'** + String get authEmailInvalid; + + /// No description provided for @authPasswordRequired. + /// + /// In en, this message translates to: + /// **'Enter your password'** + String get authPasswordRequired; + + /// No description provided for @authPasswordLength. + /// + /// In en, this message translates to: + /// **'Password must be 8-64 characters'** + String get authPasswordLength; + + /// No description provided for @authPasswordWeak. + /// + /// In en, this message translates to: + /// **'Password needs both letters and numbers'** + String get authPasswordWeak; + + /// No description provided for @authNetworkError. + /// + /// In en, this message translates to: + /// **'Network error, please try again'** + String get authNetworkError; + + /// No description provided for @authSwitchToSignUp. + /// + /// In en, this message translates to: + /// **'New here? Create an account'** + String get authSwitchToSignUp; + + /// No description provided for @authSwitchToSignIn. + /// + /// In en, this message translates to: + /// **'Already have an account? Sign in'** + String get authSwitchToSignIn; + + /// No description provided for @authGateTitle. + /// + /// In en, this message translates to: + /// **'Sign in to continue'** + String get authGateTitle; + + /// No description provided for @authGateMessage. + /// + /// In en, this message translates to: + /// **'Watch history, favorites, comments and downloads need an account.'** + String get authGateMessage; + + /// No description provided for @authGateAction. + /// + /// In en, this message translates to: + /// **'Sign In'** + String get authGateAction; + + /// No description provided for @showPassword. + /// + /// In en, this message translates to: + /// **'Show password'** + String get showPassword; + + /// No description provided for @hidePassword. + /// + /// In en, this message translates to: + /// **'Hide password'** + String get hidePassword; + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Not now'** + String get cancel; + + /// No description provided for @noEpisodesYet. + /// + /// In en, this message translates to: + /// **'No playable episodes yet.'** + String get noEpisodesYet; + + /// No description provided for @networkError. + /// + /// In en, this message translates to: + /// **'Network is unreachable. Please check your connection and retry.'** + String get networkError; + + /// No description provided for @progressError. + /// + /// In en, this message translates to: + /// **'Your watch position could not be loaded.'** + String get progressError; + + /// No description provided for @favoritesError. + /// + /// In en, this message translates to: + /// **'Your favorites could not be loaded.'** + String get favoritesError; + + /// No description provided for @favoriteToggleFailed. + /// + /// In en, this message translates to: + /// **'Could not update favorites. Please retry.'** + String get favoriteToggleFailed; + + /// No description provided for @myFavorites. + /// + /// In en, this message translates to: + /// **'My favorites'** + String get myFavorites; + + /// No description provided for @noFavorites. + /// + /// In en, this message translates to: + /// **'No favorites yet.'** + String get noFavorites; + + /// No description provided for @downloads. + /// + /// In en, this message translates to: + /// **'Downloads'** + String get downloads; + + /// No description provided for @downloadsError. + /// + /// In en, this message translates to: + /// **'Download settings could not be loaded.'** + String get downloadsError; + + /// No description provided for @noDownloads. + /// + /// In en, this message translates to: + /// **'No download authorizations yet.'** + String get noDownloads; + + /// No description provided for @downloadedItems. + /// + /// In en, this message translates to: + /// **'Download authorizations'** + String get downloadedItems; + + /// No description provided for @downloadQuota. + /// + /// In en, this message translates to: + /// **'{remaining} of {max} authorizations available'** + String downloadQuota(int remaining, int max); + + /// No description provided for @downloadLicenseDays. + /// + /// In en, this message translates to: + /// **'Authorization valid for {days} days'** + String downloadLicenseDays(int days); + + /// No description provided for @downloadWifiOnly. + /// + /// In en, this message translates to: + /// **'Wi-Fi only'** + String get downloadWifiOnly; + + /// No description provided for @downloadQuality. + /// + /// In en, this message translates to: + /// **'Maximum quality'** + String get downloadQuality; + + /// No description provided for @downloadStatus. + /// + /// In en, this message translates to: + /// **'Authorization: {status}'** + String downloadStatus(Object status); + + /// No description provided for @downloadTaskStatus. + /// + /// In en, this message translates to: + /// **'Task: {status}'** + String downloadTaskStatus(Object status); + + /// No description provided for @downloadProgress. + /// + /// In en, this message translates to: + /// **'{progress}%'** + String downloadProgress(int progress); + + /// No description provided for @downloadExpiresAt. + /// + /// In en, this message translates to: + /// **'Expires {time}'** + String downloadExpiresAt(Object time); + + /// No description provided for @clearDownloadsTitle. + /// + /// In en, this message translates to: + /// **'Clear download authorizations?'** + String get clearDownloadsTitle; + + /// No description provided for @clearDownloadsMessage. + /// + /// In en, this message translates to: + /// **'This revokes every download authorization for this device.'** + String get clearDownloadsMessage; + + /// No description provided for @downloadSettingsFailed. + /// + /// In en, this message translates to: + /// **'Could not update download settings.'** + String get downloadSettingsFailed; + + /// No description provided for @downloadDeleteFailed. + /// + /// In en, this message translates to: + /// **'Could not remove this download authorization.'** + String get downloadDeleteFailed; + + /// No description provided for @downloadClearFailed. + /// + /// In en, this message translates to: + /// **'Could not clear download authorizations.'** + String get downloadClearFailed; + + /// No description provided for @downloadGrantFailed. + /// + /// In en, this message translates to: + /// **'Could not request this download.'** + String get downloadGrantFailed; + + /// No description provided for @downloadGranted. + /// + /// In en, this message translates to: + /// **'Download authorization is ready.'** + String get downloadGranted; + + /// No description provided for @noWatchRecords. + /// + /// In en, this message translates to: + /// **'No watch history yet.'** + String get noWatchRecords; + + /// No description provided for @recordDeleteFailed. + /// + /// In en, this message translates to: + /// **'Could not delete this record. Please retry.'** + String get recordDeleteFailed; + + /// No description provided for @recordClearFailed. + /// + /// In en, this message translates to: + /// **'Could not clear watch history. Please retry.'** + String get recordClearFailed; + + /// No description provided for @clearAll. + /// + /// In en, this message translates to: + /// **'Clear all'** + String get clearAll; + + /// No description provided for @clearWatchHistoryTitle. + /// + /// In en, this message translates to: + /// **'Clear watch history?'** + String get clearWatchHistoryTitle; + + /// No description provided for @clearWatchHistoryMessage. + /// + /// In en, this message translates to: + /// **'This removes the watch history from your account.'** + String get clearWatchHistoryMessage; + + /// No description provided for @confirmClear. + /// + /// In en, this message translates to: + /// **'Clear'** + String get confirmClear; + + /// No description provided for @detailLoadFailed. + /// + /// In en, this message translates to: + /// **'Details failed to load'** + String get detailLoadFailed; + + /// No description provided for @retryDetail. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retryDetail; + + /// No description provided for @episodeUnavailable. + /// + /// In en, this message translates to: + /// **'Episode unavailable'** + String get episodeUnavailable; + + /// No description provided for @updatedAtLabel. + /// + /// In en, this message translates to: + /// **'Updated {time}'** + String updatedAtLabel(Object time); + + /// No description provided for @remove. + /// + /// In en, this message translates to: + /// **'Remove'** + String get remove; + + /// No description provided for @unlockComingSoon. + /// + /// In en, this message translates to: + /// **'Unlocking with coins is coming soon.'** + String get unlockComingSoon; + + /// No description provided for @resumeProgress. + /// + /// In en, this message translates to: + /// **'Resume at {position} / {total}'** + String resumeProgress(Object position, Object total); + + /// No description provided for @accountCenter. + /// + /// In en, this message translates to: + /// **'Account'** + String get accountCenter; + + /// No description provided for @accountProfile. + /// + /// In en, this message translates to: + /// **'Profile'** + String get accountProfile; + + /// No description provided for @accountDevices. + /// + /// In en, this message translates to: + /// **'Login devices'** + String get accountDevices; + + /// No description provided for @accountDataExport. + /// + /// In en, this message translates to: + /// **'Data export'** + String get accountDataExport; + + /// No description provided for @accountDeactivate. + /// + /// In en, this message translates to: + /// **'Delete account'** + String get accountDeactivate; + + /// No description provided for @accountProfileLoadFailed. + /// + /// In en, this message translates to: + /// **'Could not load your profile.'** + String get accountProfileLoadFailed; + + /// No description provided for @accountProfileSaveFailed. + /// + /// In en, this message translates to: + /// **'Could not save your profile.'** + String get accountProfileSaveFailed; + + /// No description provided for @accountNickname. + /// + /// In en, this message translates to: + /// **'Nickname'** + String get accountNickname; + + /// No description provided for @accountNicknameInvalid. + /// + /// In en, this message translates to: + /// **'Enter 1 to 30 characters.'** + String get accountNicknameInvalid; + + /// No description provided for @accountGender. + /// + /// In en, this message translates to: + /// **'Gender'** + String get accountGender; + + /// No description provided for @accountMale. + /// + /// In en, this message translates to: + /// **'Male'** + String get accountMale; + + /// No description provided for @accountFemale. + /// + /// In en, this message translates to: + /// **'Female'** + String get accountFemale; + + /// No description provided for @accountUnknownGender. + /// + /// In en, this message translates to: + /// **'Prefer not to say'** + String get accountUnknownGender; + + /// No description provided for @accountSelectBirthday. + /// + /// In en, this message translates to: + /// **'Set birthday'** + String get accountSelectBirthday; + + /// No description provided for @accountBirthdayHint. + /// + /// In en, this message translates to: + /// **'Birthday can be set but not cleared in this version.'** + String get accountBirthdayHint; + + /// No description provided for @accountSaved. + /// + /// In en, this message translates to: + /// **'Profile saved.'** + String get accountSaved; + + /// No description provided for @accountReadOnlyInfo. + /// + /// In en, this message translates to: + /// **'Account information'** + String get accountReadOnlyInfo; + + /// No description provided for @accountUid. + /// + /// In en, this message translates to: + /// **'UID'** + String get accountUid; + + /// No description provided for @accountMaskedAccount. + /// + /// In en, this message translates to: + /// **'Account'** + String get accountMaskedAccount; + + /// No description provided for @accountCountry. + /// + /// In en, this message translates to: + /// **'Country'** + String get accountCountry; + + /// No description provided for @accountCurrency. + /// + /// In en, this message translates to: + /// **'Currency'** + String get accountCurrency; + + /// No description provided for @accountStatus. + /// + /// In en, this message translates to: + /// **'Status'** + String get accountStatus; + + /// No description provided for @accountLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get accountLanguage; + + /// No description provided for @accountTimezone. + /// + /// In en, this message translates to: + /// **'Time zone'** + String get accountTimezone; + + /// No description provided for @accountRegion. + /// + /// In en, this message translates to: + /// **'Region'** + String get accountRegion; + + /// No description provided for @accountVipLevel. + /// + /// In en, this message translates to: + /// **'VIP level'** + String get accountVipLevel; + + /// No description provided for @accountCreatedAt. + /// + /// In en, this message translates to: + /// **'Registered'** + String get accountCreatedAt; + + /// No description provided for @accountLastLogin. + /// + /// In en, this message translates to: + /// **'Last login'** + String get accountLastLogin; + + /// No description provided for @accountDevicesEmpty. + /// + /// In en, this message translates to: + /// **'No login devices found.'** + String get accountDevicesEmpty; + + /// No description provided for @accountDevicesLoadFailed. + /// + /// In en, this message translates to: + /// **'Could not load devices.'** + String get accountDevicesLoadFailed; + + /// No description provided for @accountPlatform. + /// + /// In en, this message translates to: + /// **'Platform'** + String get accountPlatform; + + /// No description provided for @accountDeviceId. + /// + /// In en, this message translates to: + /// **'Device ID'** + String get accountDeviceId; + + /// No description provided for @accountFirstSeen. + /// + /// In en, this message translates to: + /// **'First seen'** + String get accountFirstSeen; + + /// No description provided for @accountLastActive. + /// + /// In en, this message translates to: + /// **'Last active'** + String get accountLastActive; + + /// No description provided for @accountDeviceActive. + /// + /// In en, this message translates to: + /// **'Active'** + String get accountDeviceActive; + + /// No description provided for @accountDeviceInactive. + /// + /// In en, this message translates to: + /// **'Inactive'** + String get accountDeviceInactive; + + /// No description provided for @accountExportLoadFailed. + /// + /// In en, this message translates to: + /// **'Could not export account data.'** + String get accountExportLoadFailed; + + /// No description provided for @accountCredentials. + /// + /// In en, this message translates to: + /// **'Sign-in credentials'** + String get accountCredentials; + + /// No description provided for @accountConsentRecords. + /// + /// In en, this message translates to: + /// **'Consent records'** + String get accountConsentRecords; + + /// No description provided for @accountDeviceCount. + /// + /// In en, this message translates to: + /// **'Devices'** + String get accountDeviceCount; + + /// No description provided for @accountCopyJson. + /// + /// In en, this message translates to: + /// **'Copy full JSON'** + String get accountCopyJson; + + /// No description provided for @accountCopied. + /// + /// In en, this message translates to: + /// **'Account data copied.'** + String get accountCopied; + + /// No description provided for @accountVerified. + /// + /// In en, this message translates to: + /// **'Verified'** + String get accountVerified; + + /// No description provided for @accountUnverified. + /// + /// In en, this message translates to: + /// **'Unverified'** + String get accountUnverified; + + /// No description provided for @accountDeactivateWarning. + /// + /// In en, this message translates to: + /// **'Requests enter a 15-day cooling-off period. All devices are signed out immediately.'** + String get accountDeactivateWarning; + + /// No description provided for @accountDeactivateReason. + /// + /// In en, this message translates to: + /// **'Reason (optional)'** + String get accountDeactivateReason; + + /// No description provided for @accountDeactivateReasonHint. + /// + /// In en, this message translates to: + /// **'Tell us why you are leaving'** + String get accountDeactivateReasonHint; + + /// No description provided for @accountRequestDeactivate. + /// + /// In en, this message translates to: + /// **'Request account deletion'** + String get accountRequestDeactivate; + + /// No description provided for @accountConfirmDeactivateTitle. + /// + /// In en, this message translates to: + /// **'Request account deletion?'** + String get accountConfirmDeactivateTitle; + + /// No description provided for @accountConfirmDeactivateMessage. + /// + /// In en, this message translates to: + /// **'All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.'** + String get accountConfirmDeactivateMessage; + + /// No description provided for @accountConfirmDeactivate. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get accountConfirmDeactivate; + + /// No description provided for @accountScheduledFor. + /// + /// In en, this message translates to: + /// **'Deletion scheduled for {time}'** + String accountScheduledFor(Object time); + + /// No description provided for @accountCancelDeactivation. + /// + /// In en, this message translates to: + /// **'Cancel deletion request'** + String get accountCancelDeactivation; + + /// No description provided for @accountCancelSucceeded. + /// + /// In en, this message translates to: + /// **'Deletion request cancelled.'** + String get accountCancelSucceeded; + + /// No description provided for @accountDeactivateFailed. + /// + /// In en, this message translates to: + /// **'Could not request account deletion.'** + String get accountDeactivateFailed; + + /// No description provided for @accountCancelFailed. + /// + /// In en, this message translates to: + /// **'Could not cancel the deletion request.'** + String get accountCancelFailed; } class _AppLocalizationsDelegate @@ -856,7 +2134,7 @@ class _AppLocalizationsDelegate @override bool isSupported(Locale locale) => - ['en', 'zh'].contains(locale.languageCode); + ['en', 'id', 'zh'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; @@ -867,6 +2145,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { switch (locale.languageCode) { case 'en': return AppLocalizationsEn(); + case 'id': + return AppLocalizationsId(); case 'zh': return AppLocalizationsZh(); } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0cd5cb2..f777db2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1,5 +1,3 @@ -// Author: fruit.com.cn - // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -11,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get appName => 'Nontonki'; + String get appName => 'FruitShorts'; @override String get demo => 'DEMO'; @@ -56,6 +54,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get featured => 'Featured'; + @override + String get recommended => 'Recommended'; + + @override + String get fruitShortsBrand => 'FruitShorts'; + + @override + String get authLandingSubtitle => + 'Sign in to sync progress, favorites and coins'; + + @override + String get comingSoon => 'Coming soon'; + + @override + String get authLegal => + 'By continuing, you agree to the Terms of Service and Privacy Policy.'; + + @override + String get authContinueGoogle => 'Continue with Google'; + + @override + String get authPhoneSignIn => 'Phone sign in'; + + @override + String get authEmailSignIn => 'Email sign in'; + @override String get suspense => 'Suspense'; @@ -80,6 +104,49 @@ class AppLocalizationsEn extends AppLocalizations { @override String get seeAll => 'See All'; + @override + String get trendingSubtitle => 'Most binge-watched this week'; + + @override + String get verticalMiniSeries => 'VERTICAL MINI-SERIES'; + + @override + String get followSeries => 'Follow Series'; + + @override + String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema'; + + @override + String get seasonOneOriginalRelease => 'Season 1 • Original Release'; + + @override + String get startWatching => 'Start Watching'; + + @override + String get showLess => 'Less'; + + @override + String get views => 'views'; + + @override + String episodeOfTotal(int current, int total) { + return 'Episode $current / $total'; + } + + @override + String get clearScreen => 'Clear screen'; + + @override + String get exitClearScreen => 'Exit clear screen'; + + @override + String get playbackSpeed => 'Playback speed'; + + @override + String minutesAgo(int count) { + return '$count min ago'; + } + @override String get artworkOnly => 'Artwork'; @@ -96,6 +163,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noSearchResults => 'No matching dramas.'; + @override + String get emptyHint => 'New content will appear here.'; + @override String get demoCatalogNotice => 'Three demo dramas share the same local video.'; @@ -124,7 +194,7 @@ class AppLocalizationsEn extends AppLocalizations { String get history => 'History'; @override - String get watchlist => 'Watchlist'; + String get watchlist => 'Favorites'; @override String get likedTab => 'Liked'; @@ -159,6 +229,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noCategoryDramas => 'No dramas in this category yet.'; + @override + String get allDramas => 'All Dramas'; + @override String get trendingTab => 'Trending'; @@ -189,9 +262,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get share => 'Share'; - @override - String get localOnlyComments => 'Only visible on this device'; - @override String get noComments => 'No comments yet.'; @@ -201,18 +271,104 @@ class AppLocalizationsEn extends AppLocalizations { @override String get postComment => 'Post'; + @override + String get loadMore => 'Load more'; + + @override + String get commentsLoadFailed => 'Comments could not be loaded.'; + + @override + String get commentPostFailed => 'Could not post your comment.'; + + @override + String get delete => 'Delete'; + + @override + String get deleteComment => 'Delete comment'; + + @override + String get deleteCommentTitle => 'Delete this comment?'; + + @override + String get deleteCommentMessage => + 'This removes your comment from the conversation.'; + + @override + String get commentDeleteFailed => 'Could not delete this comment.'; + + @override + String get underReview => 'Under review'; + + @override + String get reply => 'Reply'; + + @override + String replyingTo(Object name) { + return 'Replying to $name'; + } + + @override + String get cancelReply => 'Cancel reply'; + + @override + String viewReplies(int count) { + return 'View replies ($count)'; + } + + @override + String get hideReplies => 'Hide replies'; + + @override + String get anonymousUser => 'Viewer'; + + @override + String get more => 'More'; + + @override + String get likeFailed => 'Could not update like. Please retry.'; + + @override + String get reportComment => 'Report comment'; + + @override + String get reportDrama => 'Report drama'; + + @override + String get reportReason => 'Reason'; + + @override + String get reportReasonSpam => 'Spam'; + + @override + String get reportReasonAbuse => 'Harassment'; + + @override + String get reportReasonPorn => 'Sexual content'; + + @override + String get reportReasonAd => 'Advertising'; + + @override + String get reportReasonOther => 'Other'; + + @override + String get reportDescriptionHint => 'Describe the issue (optional)'; + + @override + String get reportSubmit => 'Submit report'; + + @override + String get reportSubmitted => 'Report submitted. Thank you.'; + + @override + String get reportFailed => 'Could not submit report.'; + @override String get shareFailed => 'Could not open the share sheet.'; @override String get nowPlaying => 'Playing now'; - @override - String get watchFullscreen => 'Watch in landscape'; - - @override - String get exitFullscreen => 'Exit fullscreen'; - @override String get pictureInPicture => 'Picture in picture'; @@ -296,9 +452,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get catalogError => 'The catalog could not be loaded.'; + @override + String get categoriesError => 'Categories could not be loaded.'; + + @override + String get detailError => 'The episode list could not be loaded.'; + + @override + String get noMediaNotice => 'This episode has no playable media yet.'; + @override String get noStories => 'No stories available yet.'; + @override + String get notices => 'Notices'; + + @override + String get noNotices => 'No notices yet.'; + + @override + String get noticesLoadFailed => 'Notices could not be loaded.'; + + @override + String get noticeDetails => 'Notice details'; + + @override + String get noticeAnnouncement => 'Notice'; + + @override + String get close => 'Close'; + + @override + String carouselSemantics(int current, int total) { + return 'Banner $current of $total'; + } + @override String get retry => 'Retry'; @@ -344,6 +532,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get simplifiedChinese => '简体中文'; + @override + String get bahasaIndonesia => 'Bahasa Indonesia'; + @override String get episodeOneTitle => 'First Light'; @@ -399,8 +590,497 @@ class AppLocalizationsEn extends AppLocalizations { String get preferencesHeading => 'PREFERENCES'; @override - String get brandMark => 'NONTONKI · DEMO'; + String get brandMark => 'FRUITSHORTS'; @override - String get shareFooter => 'Nontonki demo'; + String get shareFooter => 'FruitShorts'; + + @override + String get shortsTab => 'Shorts'; + + @override + String get myListTab => 'My List'; + + @override + String get watchFull => 'Watch Full'; + + @override + String get swipeForNext => 'Swipe up for next'; + + @override + String get logIn => 'Log in'; + + @override + String get logInHint => 'Keep your coins and list'; + + @override + String get rewards => 'Rewards'; + + @override + String get forYou => 'For You'; + + @override + String get hot => 'Hot'; + + @override + String get newest => 'New'; + + @override + String get favorite => 'Favorite'; + + @override + String get myAccount => 'My account'; + + @override + String get coins => 'Coins'; + + @override + String get topUpWallet => 'Top Up Wallet'; + + @override + String get vipMembership => 'VIP Membership'; + + @override + String get notActive => 'Not Active'; + + @override + String get joinVip => 'Join VIP'; + + @override + String get earnRewards => 'Earn Rewards'; + + @override + String get freeBonus => 'Get Free Bonus'; + + @override + String get watchHistory => 'Watch History'; + + @override + String get settings => 'Settings'; + + @override + String get serverAddress => 'Server address'; + + @override + String get serverSettings => 'Server settings'; + + @override + String get serverAddressHint => 'http(s)://host:port'; + + @override + String get saveAddress => 'Save address'; + + @override + String get restartToApply => 'Restart the app to apply.'; + + @override + String get restoreDefault => 'Restore default'; + + @override + String get invalidServerAddress => 'Enter a valid http(s) server address.'; + + @override + String get signOut => 'Sign Out'; + + @override + String get mockUserName => 'Vicky sheng'; + + @override + String get mockUserId => 'ID: M1CZWVYE'; + + @override + String get mockNotice => 'Sample data only.'; + + @override + String get topBadge => 'TOP 1'; + + @override + String get signIn => 'Sign In'; + + @override + String get signUp => 'Sign Up'; + + @override + String get authSignInTitle => 'Welcome back'; + + @override + String get authSignUpTitle => 'Create your account'; + + @override + String get authSubtitle => 'Sign in with your email and password.'; + + @override + String get authSignUpSubtitle => + 'Create an account to keep your list, history, and progress.'; + + @override + String get authBrandTagline => 'Short dramas made for Indonesia'; + + @override + String get authEmailLabel => 'Email'; + + @override + String get authPasswordLabel => 'Password'; + + @override + String get authPasswordHint => 'At least 8 characters, letters and numbers'; + + @override + String get authEmailRequired => 'Enter your email'; + + @override + String get authEmailInvalid => 'That email doesn\'t look right'; + + @override + String get authPasswordRequired => 'Enter your password'; + + @override + String get authPasswordLength => 'Password must be 8-64 characters'; + + @override + String get authPasswordWeak => 'Password needs both letters and numbers'; + + @override + String get authNetworkError => 'Network error, please try again'; + + @override + String get authSwitchToSignUp => 'New here? Create an account'; + + @override + String get authSwitchToSignIn => 'Already have an account? Sign in'; + + @override + String get authGateTitle => 'Sign in to continue'; + + @override + String get authGateMessage => + 'Watch history, favorites, comments and downloads need an account.'; + + @override + String get authGateAction => 'Sign In'; + + @override + String get showPassword => 'Show password'; + + @override + String get hidePassword => 'Hide password'; + + @override + String get cancel => 'Not now'; + + @override + String get noEpisodesYet => 'No playable episodes yet.'; + + @override + String get networkError => + 'Network is unreachable. Please check your connection and retry.'; + + @override + String get progressError => 'Your watch position could not be loaded.'; + + @override + String get favoritesError => 'Your favorites could not be loaded.'; + + @override + String get favoriteToggleFailed => + 'Could not update favorites. Please retry.'; + + @override + String get myFavorites => 'My favorites'; + + @override + String get noFavorites => 'No favorites yet.'; + + @override + String get downloads => 'Downloads'; + + @override + String get downloadsError => 'Download settings could not be loaded.'; + + @override + String get noDownloads => 'No download authorizations yet.'; + + @override + String get downloadedItems => 'Download authorizations'; + + @override + String downloadQuota(int remaining, int max) { + return '$remaining of $max authorizations available'; + } + + @override + String downloadLicenseDays(int days) { + return 'Authorization valid for $days days'; + } + + @override + String get downloadWifiOnly => 'Wi-Fi only'; + + @override + String get downloadQuality => 'Maximum quality'; + + @override + String downloadStatus(Object status) { + return 'Authorization: $status'; + } + + @override + String downloadTaskStatus(Object status) { + return 'Task: $status'; + } + + @override + String downloadProgress(int progress) { + return '$progress%'; + } + + @override + String downloadExpiresAt(Object time) { + return 'Expires $time'; + } + + @override + String get clearDownloadsTitle => 'Clear download authorizations?'; + + @override + String get clearDownloadsMessage => + 'This revokes every download authorization for this device.'; + + @override + String get downloadSettingsFailed => 'Could not update download settings.'; + + @override + String get downloadDeleteFailed => + 'Could not remove this download authorization.'; + + @override + String get downloadClearFailed => 'Could not clear download authorizations.'; + + @override + String get downloadGrantFailed => 'Could not request this download.'; + + @override + String get downloadGranted => 'Download authorization is ready.'; + + @override + String get noWatchRecords => 'No watch history yet.'; + + @override + String get recordDeleteFailed => + 'Could not delete this record. Please retry.'; + + @override + String get recordClearFailed => + 'Could not clear watch history. Please retry.'; + + @override + String get clearAll => 'Clear all'; + + @override + String get clearWatchHistoryTitle => 'Clear watch history?'; + + @override + String get clearWatchHistoryMessage => + 'This removes the watch history from your account.'; + + @override + String get confirmClear => 'Clear'; + + @override + String get detailLoadFailed => 'Details failed to load'; + + @override + String get retryDetail => 'Retry'; + + @override + String get episodeUnavailable => 'Episode unavailable'; + + @override + String updatedAtLabel(Object time) { + return 'Updated $time'; + } + + @override + String get remove => 'Remove'; + + @override + String get unlockComingSoon => 'Unlocking with coins is coming soon.'; + + @override + String resumeProgress(Object position, Object total) { + return 'Resume at $position / $total'; + } + + @override + String get accountCenter => 'Account'; + + @override + String get accountProfile => 'Profile'; + + @override + String get accountDevices => 'Login devices'; + + @override + String get accountDataExport => 'Data export'; + + @override + String get accountDeactivate => 'Delete account'; + + @override + String get accountProfileLoadFailed => 'Could not load your profile.'; + + @override + String get accountProfileSaveFailed => 'Could not save your profile.'; + + @override + String get accountNickname => 'Nickname'; + + @override + String get accountNicknameInvalid => 'Enter 1 to 30 characters.'; + + @override + String get accountGender => 'Gender'; + + @override + String get accountMale => 'Male'; + + @override + String get accountFemale => 'Female'; + + @override + String get accountUnknownGender => 'Prefer not to say'; + + @override + String get accountSelectBirthday => 'Set birthday'; + + @override + String get accountBirthdayHint => + 'Birthday can be set but not cleared in this version.'; + + @override + String get accountSaved => 'Profile saved.'; + + @override + String get accountReadOnlyInfo => 'Account information'; + + @override + String get accountUid => 'UID'; + + @override + String get accountMaskedAccount => 'Account'; + + @override + String get accountCountry => 'Country'; + + @override + String get accountCurrency => 'Currency'; + + @override + String get accountStatus => 'Status'; + + @override + String get accountLanguage => 'Language'; + + @override + String get accountTimezone => 'Time zone'; + + @override + String get accountRegion => 'Region'; + + @override + String get accountVipLevel => 'VIP level'; + + @override + String get accountCreatedAt => 'Registered'; + + @override + String get accountLastLogin => 'Last login'; + + @override + String get accountDevicesEmpty => 'No login devices found.'; + + @override + String get accountDevicesLoadFailed => 'Could not load devices.'; + + @override + String get accountPlatform => 'Platform'; + + @override + String get accountDeviceId => 'Device ID'; + + @override + String get accountFirstSeen => 'First seen'; + + @override + String get accountLastActive => 'Last active'; + + @override + String get accountDeviceActive => 'Active'; + + @override + String get accountDeviceInactive => 'Inactive'; + + @override + String get accountExportLoadFailed => 'Could not export account data.'; + + @override + String get accountCredentials => 'Sign-in credentials'; + + @override + String get accountConsentRecords => 'Consent records'; + + @override + String get accountDeviceCount => 'Devices'; + + @override + String get accountCopyJson => 'Copy full JSON'; + + @override + String get accountCopied => 'Account data copied.'; + + @override + String get accountVerified => 'Verified'; + + @override + String get accountUnverified => 'Unverified'; + + @override + String get accountDeactivateWarning => + 'Requests enter a 15-day cooling-off period. All devices are signed out immediately.'; + + @override + String get accountDeactivateReason => 'Reason (optional)'; + + @override + String get accountDeactivateReasonHint => 'Tell us why you are leaving'; + + @override + String get accountRequestDeactivate => 'Request account deletion'; + + @override + String get accountConfirmDeactivateTitle => 'Request account deletion?'; + + @override + String get accountConfirmDeactivateMessage => + 'All devices will be signed out immediately. You can cancel during the 15-day cooling-off period after signing in again.'; + + @override + String get accountConfirmDeactivate => 'Confirm'; + + @override + String accountScheduledFor(Object time) { + return 'Deletion scheduled for $time'; + } + + @override + String get accountCancelDeactivation => 'Cancel deletion request'; + + @override + String get accountCancelSucceeded => 'Deletion request cancelled.'; + + @override + String get accountDeactivateFailed => 'Could not request account deletion.'; + + @override + String get accountCancelFailed => 'Could not cancel the deletion request.'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart new file mode 100644 index 0000000..a417c57 --- /dev/null +++ b/lib/l10n/app_localizations_id.dart @@ -0,0 +1,1085 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Indonesian (`id`). +class AppLocalizationsId extends AppLocalizations { + AppLocalizationsId([String locale = 'id']) : super(locale); + + @override + String get appName => 'FruitShorts'; + + @override + String get demo => 'Demo'; + + @override + String get homeHeadline => 'Tonton sekarang'; + + @override + String get homeSubtitle => 'Pratinjau drama pendek gratis.'; + + @override + String get mediaNotice => 'Video demo lokal dari pengguna · Pratinjau gratis'; + + @override + String get featuredDemo => 'UNGGULAN · DEMO TEKNIS'; + + @override + String get demoTitle => 'Sinyal'; + + @override + String get demoSynopsis => + 'Pratinjau video lokal singkat. Episode lain bisa ditambahkan saat medianya tersedia.'; + + @override + String get homeTab => 'Beranda'; + + @override + String get openQuickMenu => 'Buka menu cepat'; + + @override + String get quickAccess => 'Akses cepat'; + + @override + String get currentSeries => 'Serial saat ini'; + + @override + String get discoverTab => 'Temukan'; + + @override + String get searchDiscover => 'Cari drama, aktor, genre...'; + + @override + String get featured => 'Unggulan'; + + @override + String get recommended => 'Rekomendasi'; + + @override + String get fruitShortsBrand => 'FruitShorts'; + + @override + String get authLandingSubtitle => + 'Masuk untuk menyinkronkan progres, favorit, dan koin'; + + @override + String get comingSoon => 'Segera hadir'; + + @override + String get authLegal => + 'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.'; + + @override + String get authContinueGoogle => 'Lanjutkan dengan Google'; + + @override + String get authPhoneSignIn => 'Masuk dengan ponsel'; + + @override + String get authEmailSignIn => 'Masuk dengan email'; + + @override + String get suspense => 'Thriller'; + + @override + String get romance => 'Romantis'; + + @override + String get actionGenre => 'Aksi'; + + @override + String get filter => 'Filter'; + + @override + String get topCharts => 'Peringkat Teratas'; + + @override + String get newReleases => 'Rilisan Baru'; + + @override + String get trendingMicroDramas => 'Drama Mini Populer'; + + @override + String get seeAll => 'Lihat Semua'; + + @override + String get trendingSubtitle => 'Paling banyak ditonton minggu ini'; + + @override + String get verticalMiniSeries => 'SERIAL MINI VERTIKAL'; + + @override + String get followSeries => 'Ikuti Series'; + + @override + String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal'; + + @override + String get seasonOneOriginalRelease => 'Musim 1 • Rilis Orisinal'; + + @override + String get startWatching => 'Mulai Menonton'; + + @override + String get showLess => 'Lebih sedikit'; + + @override + String get views => 'tayangan'; + + @override + String episodeOfTotal(int current, int total) { + return 'Episode $current / $total'; + } + + @override + String get clearScreen => 'Bersihkan layar'; + + @override + String get exitClearScreen => 'Keluar dari layar bersih'; + + @override + String get playbackSpeed => 'Kecepatan putar'; + + @override + String minutesAgo(int count) { + return '$count mnt lalu'; + } + + @override + String get artworkOnly => 'Gambar'; + + @override + String get artworkOnlyNotice => + 'Ini hanya gambar katalog. Videonya belum tersedia.'; + + @override + String get videoPending => 'Video belum siap'; + + @override + String get watchNow => 'Tonton gratis'; + + @override + String get noSearchResults => 'Tidak ada drama yang cocok.'; + + @override + String get emptyHint => 'Konten baru akan muncul di sini.'; + + @override + String get demoCatalogNotice => + 'Tiga drama demo memakai video lokal yang sama.'; + + @override + String get myTab => 'Saya'; + + @override + String get localViewer => 'Penonton Lokal'; + + @override + String get localDemoProfile => 'Profil demo lokal'; + + @override + String get continueWatching => 'Lanjut Tonton'; + + @override + String watchedPercent(int percent) { + return 'Sudah ditonton $percent%'; + } + + @override + String get resume => 'Lanjutkan'; + + @override + String get history => 'Riwayat'; + + @override + String get watchlist => 'Daftar Tonton'; + + @override + String get likedTab => 'Disukai'; + + @override + String get all => 'Semua'; + + @override + String get inProgress => 'Sedang Berjalan'; + + @override + String get completed => 'Selesai'; + + @override + String get noSavedShows => 'Belum ada tontonan di sini.'; + + @override + String get preferences => 'Preferensi'; + + @override + String get appLanguage => 'Bahasa Aplikasi'; + + @override + String get followingTab => 'Mengikuti'; + + @override + String get animatedTab => 'Animasi'; + + @override + String get recommendedTab => 'Rekomendasi'; + + @override + String get noCategoryDramas => 'Belum ada drama di kategori ini.'; + + @override + String get allDramas => 'Semua Drama'; + + @override + String get trendingTab => 'Sedang Tren'; + + @override + String get liveActionTab => 'Live Action'; + + @override + String get search => 'Cari'; + + @override + String get free => 'Gratis'; + + @override + String get mute => 'Bisukan'; + + @override + String get unmute => 'Nyalakan suara'; + + @override + String get like => 'Suka'; + + @override + String get save => 'Simpan'; + + @override + String get comments => 'Komentar'; + + @override + String get share => 'Bagikan'; + + @override + String get noComments => 'Belum ada komentar.'; + + @override + String get writeComment => 'Tulis komentar'; + + @override + String get postComment => 'Kirim'; + + @override + String get loadMore => 'Muat lebih banyak'; + + @override + String get commentsLoadFailed => 'Komentar tidak dapat dimuat.'; + + @override + String get commentPostFailed => 'Komentar tidak dapat dikirim.'; + + @override + String get delete => 'Hapus'; + + @override + String get deleteComment => 'Hapus komentar'; + + @override + String get deleteCommentTitle => 'Hapus komentar ini?'; + + @override + String get deleteCommentMessage => + 'Komentar ini akan dihapus dari percakapan.'; + + @override + String get commentDeleteFailed => 'Komentar tidak dapat dihapus.'; + + @override + String get underReview => 'Sedang ditinjau'; + + @override + String get reply => 'Balas'; + + @override + String replyingTo(Object name) { + return 'Membalas $name'; + } + + @override + String get cancelReply => 'Batal membalas'; + + @override + String viewReplies(int count) { + return 'Lihat balasan ($count)'; + } + + @override + String get hideReplies => 'Sembunyikan balasan'; + + @override + String get anonymousUser => 'Penonton'; + + @override + String get more => 'Lainnya'; + + @override + String get likeFailed => 'Status suka tidak dapat diperbarui.'; + + @override + String get reportComment => 'Laporkan komentar'; + + @override + String get reportDrama => 'Laporkan drama'; + + @override + String get reportReason => 'Alasan'; + + @override + String get reportReasonSpam => 'Spam'; + + @override + String get reportReasonAbuse => 'Pelecehan'; + + @override + String get reportReasonPorn => 'Konten seksual'; + + @override + String get reportReasonAd => 'Iklan'; + + @override + String get reportReasonOther => 'Lainnya'; + + @override + String get reportDescriptionHint => 'Jelaskan masalah (opsional)'; + + @override + String get reportSubmit => 'Kirim laporan'; + + @override + String get reportSubmitted => 'Laporan terkirim. Terima kasih.'; + + @override + String get reportFailed => 'Laporan tidak dapat dikirim.'; + + @override + String get shareFailed => 'Tidak bisa membuka menu berbagi.'; + + @override + String get nowPlaying => 'Sedang diputar'; + + @override + String get pictureInPicture => 'Gambar dalam gambar'; + + @override + String get pipUnavailable => + 'Gambar dalam gambar tidak tersedia di perangkat ini'; + + @override + String get originalTag => 'Original'; + + @override + String get dramaTag => 'Drama pendek'; + + @override + String episodeLabel(int number) { + return 'Episode $number'; + } + + @override + String get exploreSeries => 'Lihat serial'; + + @override + String episodeCount(int count) { + return '$count EPISODE'; + } + + @override + String technicalDemoCount(int count) { + return 'PRATINJAU GRATIS · $count EPISODE'; + } + + @override + String get episodes => 'Episode'; + + @override + String get watchEpisodeOne => 'Tonton episode 1'; + + @override + String continueEpisode(int number) { + return 'Lanjut episode $number'; + } + + @override + String get freeTestMedia => 'Klip demo sama · gratis'; + + @override + String get lockedStoreUnavailable => 'Terkunci · toko belum tersedia'; + + @override + String get freeTestClip => 'Klip uji gratis'; + + @override + String get locked => 'Terkunci'; + + @override + String get freeSamplesNotice => + 'Dua sampel gratis · pengaturan toko belum selesai'; + + @override + String get demoTestMedia => 'GRATIS · KLIP DEMO SAMA'; + + @override + String episodeProgress(int number, int total) { + return 'EPISODE $number / $total'; + } + + @override + String get testVideoNotice => 'Video uji teknis buatan sendiri'; + + @override + String lockedTitle(int number) { + return 'Episode $number terkunci'; + } + + @override + String get lockedBody => + 'Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.'; + + @override + String get playbackError => 'Klip ini tidak bisa diputar.'; + + @override + String get catalogError => 'Katalog gagal dimuat.'; + + @override + String get categoriesError => 'Kategori gagal dimuat.'; + + @override + String get detailError => 'Daftar episode gagal dimuat.'; + + @override + String get noMediaNotice => + 'Episode ini belum punya media yang bisa diputar.'; + + @override + String get noStories => 'Belum ada cerita.'; + + @override + String get notices => 'Notifikasi'; + + @override + String get noNotices => 'Belum ada notifikasi.'; + + @override + String get noticesLoadFailed => 'Notifikasi tidak dapat dimuat.'; + + @override + String get noticeDetails => 'Detail notifikasi'; + + @override + String get noticeAnnouncement => 'Pengumuman'; + + @override + String get close => 'Tutup'; + + @override + String carouselSemantics(int current, int total) { + return 'Banner $current dari $total'; + } + + @override + String get retry => 'Coba lagi'; + + @override + String get backToSeries => 'Kembali ke serial'; + + @override + String get nextEpisode => 'Episode berikutnya'; + + @override + String get pause => 'Jeda'; + + @override + String get play => 'Putar'; + + @override + String get seekVideo => 'Geser posisi video'; + + @override + String get seekFailed => 'Gagal mengubah posisi putar.'; + + @override + String get captions => 'Subtitle'; + + @override + String get captionsOff => 'Subtitle nonaktif'; + + @override + String get englishCaptions => 'Subtitle bahasa Inggris'; + + @override + String get chineseCaptions => 'Subtitle bahasa Mandarin'; + + @override + String get language => 'Bahasa'; + + @override + String get systemLanguage => 'Sistem'; + + @override + String get english => 'English'; + + @override + String get simplifiedChinese => '简体中文'; + + @override + String get bahasaIndonesia => 'Bahasa Indonesia'; + + @override + String get episodeOneTitle => 'Cahaya Pertama'; + + @override + String get episodeTwoTitle => 'Frekuensi Baru'; + + @override + String get episodeThreeTitle => 'Menyeberang'; + + @override + String get episodeFourTitle => 'Setelah Gema'; + + @override + String get dramaSignalTitle => 'Sinyal'; + + @override + String get dramaSignalSynopsis => + 'Serial demo untuk tontonan vertikal. Setiap episode memakai satu klip lokal yang sama.'; + + @override + String get dramaCityTitle => 'Cahaya Kota & Malam Beludru'; + + @override + String get dramaCitySynopsis => + 'Serial romansa demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.'; + + @override + String get dramaVengeanceTitle => 'Badai Pembalasan'; + + @override + String get dramaVengeanceSynopsis => + 'Serial aksi demo. Video lokal yang sama dipakai untuk mencoba pindah episode dan lanjut tonton.'; + + @override + String get dramaEchoesTitle => 'Gema Takdir'; + + @override + String get genreSuspenseMystery => 'Thriller · Misteri'; + + @override + String get genreUrbanRomance => 'Romansa Kota'; + + @override + String get genreActionThriller => 'Aksi · Thriller'; + + @override + String get genreDramaMystery => 'Drama · Misteri'; + + @override + String get continueWatchingHeading => 'LANJUT TONTON'; + + @override + String get preferencesHeading => 'PREFERENSI'; + + @override + String get brandMark => 'FRUITSHORTS'; + + @override + String get shareFooter => 'FruitShorts'; + + @override + String get shortsTab => 'Drama Pendek'; + + @override + String get myListTab => 'Daftar Saya'; + + @override + String get watchFull => 'Tonton Penuh'; + + @override + String get swipeForNext => 'Geser ke atas untuk lanjut'; + + @override + String get logIn => 'Masuk'; + + @override + String get logInHint => 'Simpan koin dan daftar kamu'; + + @override + String get rewards => 'Hadiah'; + + @override + String get forYou => 'Untuk Kamu'; + + @override + String get hot => 'Populer'; + + @override + String get newest => 'Terbaru'; + + @override + String get favorite => 'Favorit'; + + @override + String get myAccount => 'Akun saya'; + + @override + String get coins => 'Koin'; + + @override + String get topUpWallet => 'Isi Ulang Dompet'; + + @override + String get vipMembership => 'Keanggotaan VIP'; + + @override + String get notActive => 'Tidak Aktif'; + + @override + String get joinVip => 'Gabung VIP'; + + @override + String get earnRewards => 'Dapatkan Hadiah'; + + @override + String get freeBonus => 'Bonus Gratis'; + + @override + String get watchHistory => 'Riwayat Tontonan'; + + @override + String get settings => 'Pengaturan'; + + @override + String get serverAddress => 'Alamat server'; + + @override + String get serverSettings => 'Pengaturan server'; + + @override + String get serverAddressHint => 'http(s)://host:port'; + + @override + String get saveAddress => 'Simpan alamat'; + + @override + String get restartToApply => 'Mulai ulang aplikasi untuk menerapkan.'; + + @override + String get restoreDefault => 'Pulihkan default'; + + @override + String get invalidServerAddress => + 'Masukkan alamat server http(s) yang valid.'; + + @override + String get signOut => 'Keluar'; + + @override + String get mockUserName => 'Vicky sheng'; + + @override + String get mockUserId => 'ID: M1CZWVYE'; + + @override + String get mockNotice => 'Hanya data contoh.'; + + @override + String get topBadge => 'TOP 1'; + + @override + String get signIn => 'Masuk'; + + @override + String get signUp => 'Daftar'; + + @override + String get authSignInTitle => 'Selamat datang kembali'; + + @override + String get authSignUpTitle => 'Buat akun kamu'; + + @override + String get authSubtitle => 'Masuk dengan email dan kata sandi.'; + + @override + String get authSignUpSubtitle => + 'Buat akun untuk menyimpan daftar, riwayat, dan progresmu.'; + + @override + String get authBrandTagline => 'Drama pendek untuk Indonesia'; + + @override + String get authEmailLabel => 'Email'; + + @override + String get authPasswordLabel => 'Kata sandi'; + + @override + String get authPasswordHint => 'Minimal 8 karakter, berisi huruf dan angka'; + + @override + String get authEmailRequired => 'Masukkan email kamu'; + + @override + String get authEmailInvalid => 'Email itu sepertinya tidak benar'; + + @override + String get authPasswordRequired => 'Masukkan kata sandi kamu'; + + @override + String get authPasswordLength => 'Kata sandi harus 8-64 karakter'; + + @override + String get authPasswordWeak => 'Kata sandi perlu huruf dan angka'; + + @override + String get authNetworkError => 'Jaringan bermasalah, coba lagi'; + + @override + String get authSwitchToSignUp => 'Baru di sini? Buat akun'; + + @override + String get authSwitchToSignIn => 'Sudah punya akun? Masuk'; + + @override + String get authGateTitle => 'Masuk untuk melanjutkan'; + + @override + String get authGateMessage => + 'Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.'; + + @override + String get authGateAction => 'Masuk'; + + @override + String get showPassword => 'Tampilkan kata sandi'; + + @override + String get hidePassword => 'Sembunyikan kata sandi'; + + @override + String get cancel => 'Nanti saja'; + + @override + String get noEpisodesYet => 'Belum ada episode yang bisa diputar.'; + + @override + String get networkError => + 'Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.'; + + @override + String get progressError => 'Posisi tonton gagal dimuat.'; + + @override + String get favoritesError => 'Daftar favorit gagal dimuat.'; + + @override + String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.'; + + @override + String get myFavorites => 'Favorit saya'; + + @override + String get noFavorites => 'Belum ada favorit.'; + + @override + String get downloads => 'Unduhan'; + + @override + String get downloadsError => 'Pengaturan unduhan gagal dimuat.'; + + @override + String get noDownloads => 'Belum ada izin unduhan.'; + + @override + String get downloadedItems => 'Izin unduhan'; + + @override + String downloadQuota(int remaining, int max) { + return '$remaining dari $max izin tersedia'; + } + + @override + String downloadLicenseDays(int days) { + return 'Izin berlaku $days hari'; + } + + @override + String get downloadWifiOnly => 'Hanya Wi-Fi'; + + @override + String get downloadQuality => 'Kualitas maksimum'; + + @override + String downloadStatus(Object status) { + return 'Izin: $status'; + } + + @override + String downloadTaskStatus(Object status) { + return 'Tugas: $status'; + } + + @override + String downloadProgress(int progress) { + return '$progress%'; + } + + @override + String downloadExpiresAt(Object time) { + return 'Berakhir $time'; + } + + @override + String get clearDownloadsTitle => 'Hapus izin unduhan?'; + + @override + String get clearDownloadsMessage => + 'Semua izin unduhan untuk perangkat ini akan dicabut.'; + + @override + String get downloadSettingsFailed => 'Gagal memperbarui pengaturan unduhan.'; + + @override + String get downloadDeleteFailed => 'Gagal menghapus izin unduhan ini.'; + + @override + String get downloadClearFailed => 'Gagal menghapus semua izin unduhan.'; + + @override + String get downloadGrantFailed => 'Gagal meminta unduhan ini.'; + + @override + String get downloadGranted => 'Izin unduhan sudah siap.'; + + @override + String get noWatchRecords => 'Belum ada riwayat tontonan.'; + + @override + String get recordDeleteFailed => 'Gagal menghapus riwayat ini. Coba lagi.'; + + @override + String get recordClearFailed => 'Gagal menghapus semua riwayat. Coba lagi.'; + + @override + String get clearAll => 'Hapus semua'; + + @override + String get clearWatchHistoryTitle => 'Hapus riwayat tontonan?'; + + @override + String get clearWatchHistoryMessage => + 'Semua riwayat akan dihapus dari akun kamu.'; + + @override + String get confirmClear => 'Hapus'; + + @override + String get detailLoadFailed => 'Detail drama gagal dimuat'; + + @override + String get retryDetail => 'Coba lagi'; + + @override + String get episodeUnavailable => 'Episode tidak tersedia'; + + @override + String updatedAtLabel(Object time) { + return 'Diperbarui $time'; + } + + @override + String get remove => 'Hapus'; + + @override + String get unlockComingSoon => 'Pembukaan dengan koin segera hadir.'; + + @override + String resumeProgress(Object position, Object total) { + return 'Lanjut di $position / $total'; + } + + @override + String get accountCenter => 'Akun'; + + @override + String get accountProfile => 'Profil'; + + @override + String get accountDevices => 'Perangkat masuk'; + + @override + String get accountDataExport => 'Ekspor data'; + + @override + String get accountDeactivate => 'Hapus akun'; + + @override + String get accountProfileLoadFailed => 'Profil gagal dimuat.'; + + @override + String get accountProfileSaveFailed => 'Profil gagal disimpan.'; + + @override + String get accountNickname => 'Nama panggilan'; + + @override + String get accountNicknameInvalid => 'Masukkan 1 hingga 30 karakter.'; + + @override + String get accountGender => 'Jenis kelamin'; + + @override + String get accountMale => 'Laki-laki'; + + @override + String get accountFemale => 'Perempuan'; + + @override + String get accountUnknownGender => 'Tidak ingin menyebutkan'; + + @override + String get accountSelectBirthday => 'Atur tanggal lahir'; + + @override + String get accountBirthdayHint => + 'Tanggal lahir dapat diatur, tetapi belum dapat dihapus pada versi ini.'; + + @override + String get accountSaved => 'Profil disimpan.'; + + @override + String get accountReadOnlyInfo => 'Informasi akun'; + + @override + String get accountUid => 'UID'; + + @override + String get accountMaskedAccount => 'Akun'; + + @override + String get accountCountry => 'Negara'; + + @override + String get accountCurrency => 'Mata uang'; + + @override + String get accountStatus => 'Status'; + + @override + String get accountLanguage => 'Bahasa'; + + @override + String get accountTimezone => 'Zona waktu'; + + @override + String get accountRegion => 'Wilayah'; + + @override + String get accountVipLevel => 'Level VIP'; + + @override + String get accountCreatedAt => 'Terdaftar'; + + @override + String get accountLastLogin => 'Terakhir masuk'; + + @override + String get accountDevicesEmpty => 'Belum ada perangkat yang masuk.'; + + @override + String get accountDevicesLoadFailed => 'Perangkat gagal dimuat.'; + + @override + String get accountPlatform => 'Platform'; + + @override + String get accountDeviceId => 'ID perangkat'; + + @override + String get accountFirstSeen => 'Pertama terlihat'; + + @override + String get accountLastActive => 'Terakhir aktif'; + + @override + String get accountDeviceActive => 'Aktif'; + + @override + String get accountDeviceInactive => 'Tidak aktif'; + + @override + String get accountExportLoadFailed => 'Data akun gagal diekspor.'; + + @override + String get accountCredentials => 'Kredensial masuk'; + + @override + String get accountConsentRecords => 'Catatan persetujuan'; + + @override + String get accountDeviceCount => 'Perangkat'; + + @override + String get accountCopyJson => 'Salin JSON lengkap'; + + @override + String get accountCopied => 'Data akun disalin.'; + + @override + String get accountVerified => 'Terverifikasi'; + + @override + String get accountUnverified => 'Belum terverifikasi'; + + @override + String get accountDeactivateWarning => + 'Permintaan masuk masa tenang 15 hari. Semua perangkat langsung keluar.'; + + @override + String get accountDeactivateReason => 'Alasan (opsional)'; + + @override + String get accountDeactivateReasonHint => 'Beri tahu alasan kamu'; + + @override + String get accountRequestDeactivate => 'Ajukan penghapusan akun'; + + @override + String get accountConfirmDeactivateTitle => 'Ajukan penghapusan akun?'; + + @override + String get accountConfirmDeactivateMessage => + 'Semua perangkat akan langsung keluar. Setelah masuk lagi, kamu dapat membatalkannya selama masa tenang 15 hari.'; + + @override + String get accountConfirmDeactivate => 'Konfirmasi'; + + @override + String accountScheduledFor(Object time) { + return 'Penghapusan dijadwalkan pada $time'; + } + + @override + String get accountCancelDeactivation => 'Batalkan permintaan penghapusan'; + + @override + String get accountCancelSucceeded => 'Permintaan penghapusan dibatalkan.'; + + @override + String get accountDeactivateFailed => 'Permintaan penghapusan gagal dikirim.'; + + @override + String get accountCancelFailed => 'Permintaan penghapusan gagal dibatalkan.'; +} diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index cdc3442..ea78f10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1,5 +1,3 @@ -// Author: fruit.com.cn - // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -11,7 +9,7 @@ class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); @override - String get appName => 'Nontonki'; + String get appName => 'FruitShorts'; @override String get demo => '演示'; @@ -47,7 +45,7 @@ class AppLocalizationsZh extends AppLocalizations { String get currentSeries => '当前短剧'; @override - String get discoverTab => '找剧'; + String get discoverTab => '发现'; @override String get searchDiscover => '搜索短剧、演员、类型…'; @@ -55,6 +53,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get featured => '精选'; + @override + String get recommended => '推荐'; + + @override + String get fruitShortsBrand => '水果短剧'; + + @override + String get authLandingSubtitle => '登录后同步进度、收藏与金币权益'; + + @override + String get comingSoon => '即将开放'; + + @override + String get authLegal => '继续即代表同意《用户协议》与《隐私政策》'; + + @override + String get authContinueGoogle => '使用 Google 继续'; + + @override + String get authPhoneSignIn => '手机号登录'; + + @override + String get authEmailSignIn => '邮箱登录'; + @override String get suspense => '悬疑'; @@ -79,6 +101,49 @@ class AppLocalizationsZh extends AppLocalizations { @override String get seeAll => '查看全部'; + @override + String get trendingSubtitle => '本周追看最多'; + + @override + String get verticalMiniSeries => '竖屏短剧'; + + @override + String get followSeries => '追剧'; + + @override + String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院'; + + @override + String get seasonOneOriginalRelease => '第 1 季 · 原创上线'; + + @override + String get startWatching => '开始观看'; + + @override + String get showLess => '收起'; + + @override + String get views => '次播放'; + + @override + String episodeOfTotal(int current, int total) { + return '第 $current 集 / 共 $total 集'; + } + + @override + String get clearScreen => '清屏'; + + @override + String get exitClearScreen => '退出清屏'; + + @override + String get playbackSpeed => '倍速'; + + @override + String minutesAgo(int count) { + return '$count 分钟前'; + } + @override String get artworkOnly => '海报展示'; @@ -94,6 +159,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get noSearchResults => '没有匹配的短剧。'; + @override + String get emptyHint => '有新内容时会显示在这里。'; + @override String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。'; @@ -121,7 +189,7 @@ class AppLocalizationsZh extends AppLocalizations { String get history => '历史'; @override - String get watchlist => '追剧'; + String get watchlist => '收藏'; @override String get likedTab => '喜欢'; @@ -156,6 +224,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get noCategoryDramas => '这个分类暂时没有剧集。'; + @override + String get allDramas => '全部剧目'; + @override String get trendingTab => '热门'; @@ -186,9 +257,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get share => '分享'; - @override - String get localOnlyComments => '仅在本机可见'; - @override String get noComments => '还没有评论。'; @@ -198,18 +266,103 @@ class AppLocalizationsZh extends AppLocalizations { @override String get postComment => '发布'; + @override + String get loadMore => '加载更多'; + + @override + String get commentsLoadFailed => '评论加载失败,请重试。'; + + @override + String get commentPostFailed => '评论发布失败,请重试。'; + + @override + String get delete => '删除'; + + @override + String get deleteComment => '删除评论'; + + @override + String get deleteCommentTitle => '删除这条评论?'; + + @override + String get deleteCommentMessage => '删除后,这条评论将从评论列表中移除。'; + + @override + String get commentDeleteFailed => '评论删除失败,请重试。'; + + @override + String get underReview => '审核中'; + + @override + String get reply => '回复'; + + @override + String replyingTo(Object name) { + return '回复 $name'; + } + + @override + String get cancelReply => '取消回复'; + + @override + String viewReplies(int count) { + return '查看回复($count)'; + } + + @override + String get hideReplies => '收起回复'; + + @override + String get anonymousUser => '观众'; + + @override + String get more => '更多'; + + @override + String get likeFailed => '点赞状态更新失败,请重试。'; + + @override + String get reportComment => '举报评论'; + + @override + String get reportDrama => '举报短剧'; + + @override + String get reportReason => '举报原因'; + + @override + String get reportReasonSpam => '垃圾广告'; + + @override + String get reportReasonAbuse => '辱骂骚扰'; + + @override + String get reportReasonPorn => '色情低俗'; + + @override + String get reportReasonAd => '引流广告'; + + @override + String get reportReasonOther => '其他'; + + @override + String get reportDescriptionHint => '补充说明(选填)'; + + @override + String get reportSubmit => '提交举报'; + + @override + String get reportSubmitted => '举报已提交,感谢反馈。'; + + @override + String get reportFailed => '举报提交失败,请重试。'; + @override String get shareFailed => '无法打开系统分享面板。'; @override String get nowPlaying => '正在播放'; - @override - String get watchFullscreen => '横屏全屏观看'; - - @override - String get exitFullscreen => '退出全屏'; - @override String get pictureInPicture => '画中画'; @@ -291,9 +444,41 @@ class AppLocalizationsZh extends AppLocalizations { @override String get catalogError => '剧目列表加载失败。'; + @override + String get categoriesError => '分类加载失败。'; + + @override + String get detailError => '剧集信息加载失败。'; + + @override + String get noMediaNotice => '该剧集还没有可播放的片源。'; + @override String get noStories => '暂无剧目。'; + @override + String get notices => '通知'; + + @override + String get noNotices => '暂无通知。'; + + @override + String get noticesLoadFailed => '通知加载失败,请重试。'; + + @override + String get noticeDetails => '通知详情'; + + @override + String get noticeAnnouncement => '公告'; + + @override + String get close => '关闭'; + + @override + String carouselSemantics(int current, int total) { + return '第 $current 张,共 $total 张'; + } + @override String get retry => '重试'; @@ -339,6 +524,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get simplifiedChinese => '简体中文'; + @override + String get bahasaIndonesia => '印尼语'; + @override String get episodeOneTitle => '初见微光'; @@ -391,8 +579,486 @@ class AppLocalizationsZh extends AppLocalizations { String get preferencesHeading => '偏好设置'; @override - String get brandMark => 'NONTONKI · 演示'; + String get brandMark => 'FRUITSHORTS'; @override - String get shareFooter => 'Nontonki 演示'; + String get shareFooter => 'FruitShorts'; + + @override + String get shortsTab => '短剧'; + + @override + String get myListTab => '片单'; + + @override + String get watchFull => '看全集'; + + @override + String get swipeForNext => '上滑看下一部'; + + @override + String get logIn => '登录'; + + @override + String get logInHint => '登录后保存金币和片单'; + + @override + String get rewards => '奖励'; + + @override + String get forYou => '为你推荐'; + + @override + String get hot => '热门'; + + @override + String get newest => '最新'; + + @override + String get favorite => '收藏'; + + @override + String get myAccount => '我的账户'; + + @override + String get coins => '金币'; + + @override + String get topUpWallet => '充值'; + + @override + String get vipMembership => '会员'; + + @override + String get notActive => '未开通'; + + @override + String get joinVip => '开通会员'; + + @override + String get earnRewards => '赚奖励'; + + @override + String get freeBonus => '领免费奖励'; + + @override + String get watchHistory => '观看历史'; + + @override + String get settings => '设置'; + + @override + String get serverAddress => '服务器地址'; + + @override + String get serverSettings => '服务器设置'; + + @override + String get serverAddressHint => 'http(s)://主机:端口'; + + @override + String get saveAddress => '保存地址'; + + @override + String get restartToApply => '重启 App 生效'; + + @override + String get restoreDefault => '恢复默认'; + + @override + String get invalidServerAddress => '请输入有效的 http(s) 服务器地址。'; + + @override + String get signOut => '退出登录'; + + @override + String get mockUserName => 'Vicky sheng'; + + @override + String get mockUserId => 'ID: M1CZWVYE'; + + @override + String get mockNotice => '当前是示例数据。'; + + @override + String get topBadge => 'TOP 1'; + + @override + String get signIn => '登录'; + + @override + String get signUp => '注册'; + + @override + String get authSignInTitle => '欢迎回来'; + + @override + String get authSignUpTitle => '创建账号'; + + @override + String get authSubtitle => '用邮箱和密码登录。'; + + @override + String get authSignUpSubtitle => '创建账号,保存你的片单、观看记录和进度。'; + + @override + String get authBrandTagline => '为印尼市场打造的短剧体验'; + + @override + String get authEmailLabel => '邮箱'; + + @override + String get authPasswordLabel => '密码'; + + @override + String get authPasswordHint => '至少 8 位,需包含字母和数字'; + + @override + String get authEmailRequired => '请输入邮箱'; + + @override + String get authEmailInvalid => '邮箱格式不正确'; + + @override + String get authPasswordRequired => '请输入密码'; + + @override + String get authPasswordLength => '密码长度需在 8-64 位之间'; + + @override + String get authPasswordWeak => '密码需同时包含字母和数字'; + + @override + String get authNetworkError => '网络异常,请稍后重试'; + + @override + String get authSwitchToSignUp => '还没有账号?去注册'; + + @override + String get authSwitchToSignIn => '已有账号?去登录'; + + @override + String get authGateTitle => '登录后可继续'; + + @override + String get authGateMessage => '观看记录、收藏、评论和下载需要登录。'; + + @override + String get authGateAction => '去登录'; + + @override + String get showPassword => '显示密码'; + + @override + String get hidePassword => '隐藏密码'; + + @override + String get cancel => '暂不'; + + @override + String get noEpisodesYet => '还没有可播放的剧集。'; + + @override + String get networkError => '网络异常,请检查网络后重试。'; + + @override + String get progressError => '续看进度加载失败。'; + + @override + String get favoritesError => '收藏列表加载失败。'; + + @override + String get favoriteToggleFailed => '收藏操作失败,请重试。'; + + @override + String get myFavorites => '我的收藏'; + + @override + String get noFavorites => '还没有收藏。'; + + @override + String get downloads => '下载'; + + @override + String get downloadsError => '下载设置加载失败。'; + + @override + String get noDownloads => '还没有下载授权。'; + + @override + String get downloadedItems => '下载授权'; + + @override + String downloadQuota(int remaining, int max) { + return '剩余 $remaining / $max 个授权'; + } + + @override + String downloadLicenseDays(int days) { + return '授权有效期 $days 天'; + } + + @override + String get downloadWifiOnly => '仅 Wi-Fi 下载'; + + @override + String get downloadQuality => '最高清晰度'; + + @override + String downloadStatus(Object status) { + return '授权:$status'; + } + + @override + String downloadTaskStatus(Object status) { + return '任务:$status'; + } + + @override + String downloadProgress(int progress) { + return '$progress%'; + } + + @override + String downloadExpiresAt(Object time) { + return '到期时间 $time'; + } + + @override + String get clearDownloadsTitle => '清空下载授权?'; + + @override + String get clearDownloadsMessage => '这会吊销当前设备的全部下载授权。'; + + @override + String get downloadSettingsFailed => '下载设置更新失败。'; + + @override + String get downloadDeleteFailed => '删除下载授权失败。'; + + @override + String get downloadClearFailed => '清空下载授权失败。'; + + @override + String get downloadGrantFailed => '申请下载失败。'; + + @override + String get downloadGranted => '下载授权已就绪。'; + + @override + String get noWatchRecords => '还没有观看记录。'; + + @override + String get recordDeleteFailed => '删除记录失败,请重试。'; + + @override + String get recordClearFailed => '清空观看记录失败,请重试。'; + + @override + String get clearAll => '清空'; + + @override + String get clearWatchHistoryTitle => '清空观看记录?'; + + @override + String get clearWatchHistoryMessage => '这会从当前账号移除全部观看记录。'; + + @override + String get confirmClear => '清空'; + + @override + String get detailLoadFailed => '剧集信息加载失败'; + + @override + String get retryDetail => '重试'; + + @override + String get episodeUnavailable => '集数未知'; + + @override + String updatedAtLabel(Object time) { + return '更新于 $time'; + } + + @override + String get remove => '移除'; + + @override + String get unlockComingSoon => '金币解锁功能即将开放。'; + + @override + String resumeProgress(Object position, Object total) { + return '续看至 $position / $total'; + } + + @override + String get accountCenter => '账号'; + + @override + String get accountProfile => '个人资料'; + + @override + String get accountDevices => '登录设备'; + + @override + String get accountDataExport => '数据导出'; + + @override + String get accountDeactivate => '注销账号'; + + @override + String get accountProfileLoadFailed => '个人资料加载失败。'; + + @override + String get accountProfileSaveFailed => '个人资料保存失败。'; + + @override + String get accountNickname => '昵称'; + + @override + String get accountNicknameInvalid => '请输入 1 至 30 个字符。'; + + @override + String get accountGender => '性别'; + + @override + String get accountMale => '男'; + + @override + String get accountFemale => '女'; + + @override + String get accountUnknownGender => '不愿透露'; + + @override + String get accountSelectBirthday => '设置生日'; + + @override + String get accountBirthdayHint => '当前版本只能设置生日,不能清空。'; + + @override + String get accountSaved => '个人资料已保存。'; + + @override + String get accountReadOnlyInfo => '账号信息'; + + @override + String get accountUid => 'UID'; + + @override + String get accountMaskedAccount => '账号'; + + @override + String get accountCountry => '国家'; + + @override + String get accountCurrency => '币种'; + + @override + String get accountStatus => '状态'; + + @override + String get accountLanguage => '语言'; + + @override + String get accountTimezone => '时区'; + + @override + String get accountRegion => '地区'; + + @override + String get accountVipLevel => 'VIP 等级'; + + @override + String get accountCreatedAt => '注册时间'; + + @override + String get accountLastLogin => '最近登录'; + + @override + String get accountDevicesEmpty => '暂无登录设备。'; + + @override + String get accountDevicesLoadFailed => '登录设备加载失败。'; + + @override + String get accountPlatform => '平台'; + + @override + String get accountDeviceId => '设备号'; + + @override + String get accountFirstSeen => '首次登录'; + + @override + String get accountLastActive => '最近活跃'; + + @override + String get accountDeviceActive => '活跃'; + + @override + String get accountDeviceInactive => '不活跃'; + + @override + String get accountExportLoadFailed => '账号数据导出失败。'; + + @override + String get accountCredentials => '登录凭据'; + + @override + String get accountConsentRecords => '授权记录'; + + @override + String get accountDeviceCount => '设备数'; + + @override + String get accountCopyJson => '复制完整 JSON'; + + @override + String get accountCopied => '账号数据已复制。'; + + @override + String get accountVerified => '已验证'; + + @override + String get accountUnverified => '未验证'; + + @override + String get accountDeactivateWarning => '提交后进入 15 天冷静期,所有设备会立即退出登录。'; + + @override + String get accountDeactivateReason => '注销原因(选填)'; + + @override + String get accountDeactivateReasonHint => '告诉我们你离开的原因'; + + @override + String get accountRequestDeactivate => '申请注销账号'; + + @override + String get accountConfirmDeactivateTitle => '确认申请注销?'; + + @override + String get accountConfirmDeactivateMessage => + '所有设备会立即退出登录。重新登录后,可在 15 天冷静期内撤销申请。'; + + @override + String get accountConfirmDeactivate => '确认注销'; + + @override + String accountScheduledFor(Object time) { + return '预计注销时间:$time'; + } + + @override + String get accountCancelDeactivation => '撤销注销申请'; + + @override + String get accountCancelSucceeded => '注销申请已撤销。'; + + @override + String get accountDeactivateFailed => '注销申请提交失败。'; + + @override + String get accountCancelFailed => '注销申请撤销失败。'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0f1d215..5267de7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,6 +1,6 @@ { "@@locale": "zh", - "appName": "Nontonki", + "appName": "FruitShorts", "demo": "演示", "homeHeadline": "即刻观看", "homeSubtitle": "免费短剧预览。", @@ -12,9 +12,17 @@ "openQuickMenu": "打开快捷菜单", "quickAccess": "快捷入口", "currentSeries": "当前短剧", - "discoverTab": "找剧", + "discoverTab": "发现", "searchDiscover": "搜索短剧、演员、类型…", "featured": "精选", + "recommended": "推荐", + "fruitShortsBrand": "水果短剧", + "authLandingSubtitle": "登录后同步进度、收藏与金币权益", + "comingSoon": "即将开放", + "authLegal": "继续即代表同意《用户协议》与《隐私政策》", + "authContinueGoogle": "使用 Google 继续", + "authPhoneSignIn": "手机号登录", + "authEmailSignIn": "邮箱登录", "suspense": "悬疑", "romance": "都市爱情", "actionGenre": "动作", @@ -23,21 +31,52 @@ "newReleases": "新剧", "trendingMicroDramas": "热门短剧", "seeAll": "查看全部", + "trendingSubtitle": "本周追看最多", + "verticalMiniSeries": "竖屏短剧", + "followSeries": "追剧", + "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", + "seasonOneOriginalRelease": "第 1 季 · 原创上线", + "startWatching": "开始观看", + "showLess": "收起", + "views": "次播放", + "episodeOfTotal": "第 {current} 集 / 共 {total} 集", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "clearScreen": "清屏", + "exitClearScreen": "退出清屏", + "playbackSpeed": "倍速", + "minutesAgo": "{count} 分钟前", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", "watchNow": "免费观看", "noSearchResults": "没有匹配的短剧。", + "emptyHint": "有新内容时会显示在这里。", "demoCatalogNotice": "三部演示短剧共用同一条本地视频。", "myTab": "我的", "localViewer": "本地观众", "localDemoProfile": "本地演示档案", "continueWatching": "继续观看", "watchedPercent": "已观看 {percent}%", - "@watchedPercent": {"placeholders": {"percent": {"type": "int"}}}, + "@watchedPercent": { + "placeholders": { + "percent": { + "type": "int" + } + } + }, "resume": "继续播放", "history": "历史", - "watchlist": "追剧", + "watchlist": "收藏", "likedTab": "喜欢", "all": "全部", "inProgress": "未看完", @@ -49,6 +88,7 @@ "animatedTab": "漫剧", "recommendedTab": "推荐", "noCategoryDramas": "这个分类暂时没有剧集。", + "allDramas": "全部剧目", "trendingTab": "热门", "liveActionTab": "真人剧", "search": "搜索", @@ -59,20 +99,64 @@ "save": "收藏", "comments": "评论", "share": "分享", - "localOnlyComments": "仅在本机可见", "noComments": "还没有评论。", "writeComment": "写评论", "postComment": "发布", + "loadMore": "加载更多", + "commentsLoadFailed": "评论加载失败,请重试。", + "commentPostFailed": "评论发布失败,请重试。", + "delete": "删除", + "deleteComment": "删除评论", + "deleteCommentTitle": "删除这条评论?", + "deleteCommentMessage": "删除后,这条评论将从评论列表中移除。", + "commentDeleteFailed": "评论删除失败,请重试。", + "underReview": "审核中", + "reply": "回复", + "replyingTo": "回复 {name}", + "@replyingTo": { + "placeholders": { + "name": {} + } + }, + "cancelReply": "取消回复", + "viewReplies": "查看回复({count})", + "@viewReplies": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "hideReplies": "收起回复", + "anonymousUser": "观众", + "more": "更多", + "likeFailed": "点赞状态更新失败,请重试。", + "reportComment": "举报评论", + "reportDrama": "举报短剧", + "reportReason": "举报原因", + "reportReasonSpam": "垃圾广告", + "reportReasonAbuse": "辱骂骚扰", + "reportReasonPorn": "色情低俗", + "reportReasonAd": "引流广告", + "reportReasonOther": "其他", + "reportDescriptionHint": "补充说明(选填)", + "reportSubmit": "提交举报", + "reportSubmitted": "举报已提交,感谢反馈。", + "reportFailed": "举报提交失败,请重试。", "shareFailed": "无法打开系统分享面板。", "nowPlaying": "正在播放", - "watchFullscreen": "横屏全屏观看", - "exitFullscreen": "退出全屏", "pictureInPicture": "画中画", "pipUnavailable": "此设备无法使用画中画", "originalTag": "原创", "dramaTag": "短剧", "episodeLabel": "第 {number} 集", - "@episodeLabel": {"placeholders": {"number": {"type": "int"}}}, + "@episodeLabel": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "exploreSeries": "查看剧集", "episodeCount": "{count} 集", "technicalDemoCount": "免费试看 · {count} 集", @@ -91,7 +175,27 @@ "lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。", "playbackError": "此视频暂时无法播放。", "catalogError": "剧目列表加载失败。", + "categoriesError": "分类加载失败。", + "detailError": "剧集信息加载失败。", + "noMediaNotice": "该剧集还没有可播放的片源。", "noStories": "暂无剧目。", + "notices": "通知", + "noNotices": "暂无通知。", + "noticesLoadFailed": "通知加载失败,请重试。", + "noticeDetails": "通知详情", + "noticeAnnouncement": "公告", + "close": "关闭", + "carouselSemantics": "第 {current} 张,共 {total} 张", + "@carouselSemantics": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "retry": "重试", "backToSeries": "返回剧集详情", "nextEpisode": "下一集", @@ -107,6 +211,7 @@ "systemLanguage": "跟随系统", "english": "English", "simplifiedChinese": "简体中文", + "bahasaIndonesia": "印尼语", "episodeOneTitle": "初见微光", "episodeTwoTitle": "新的频率", "episodeThreeTitle": "越过边界", @@ -124,6 +229,214 @@ "genreDramaMystery": "剧情 · 悬疑", "continueWatchingHeading": "继续观看", "preferencesHeading": "偏好设置", - "brandMark": "NONTONKI · 演示", - "shareFooter": "Nontonki 演示" + "brandMark": "FRUITSHORTS", + "shareFooter": "FruitShorts", + "shortsTab": "短剧", + "myListTab": "片单", + "watchFull": "看全集", + "swipeForNext": "上滑看下一部", + "logIn": "登录", + "logInHint": "登录后保存金币和片单", + "rewards": "奖励", + "forYou": "为你推荐", + "hot": "热门", + "newest": "最新", + "favorite": "收藏", + "myAccount": "我的账户", + "coins": "金币", + "topUpWallet": "充值", + "vipMembership": "会员", + "notActive": "未开通", + "joinVip": "开通会员", + "earnRewards": "赚奖励", + "freeBonus": "领免费奖励", + "watchHistory": "观看历史", + "settings": "设置", + "serverAddress": "服务器地址", + "serverSettings": "服务器设置", + "serverAddressHint": "http(s)://主机:端口", + "saveAddress": "保存地址", + "restartToApply": "重启 App 生效", + "restoreDefault": "恢复默认", + "invalidServerAddress": "请输入有效的 http(s) 服务器地址。", + "signOut": "退出登录", + "mockUserName": "Vicky sheng", + "mockUserId": "ID: M1CZWVYE", + "mockNotice": "当前是示例数据。", + "topBadge": "TOP 1", + "signIn": "登录", + "signUp": "注册", + "authSignInTitle": "欢迎回来", + "authSignUpTitle": "创建账号", + "authSubtitle": "用邮箱和密码登录。", + "authSignUpSubtitle": "创建账号,保存你的片单、观看记录和进度。", + "authBrandTagline": "为印尼市场打造的短剧体验", + "authEmailLabel": "邮箱", + "authPasswordLabel": "密码", + "authPasswordHint": "至少 8 位,需包含字母和数字", + "authEmailRequired": "请输入邮箱", + "authEmailInvalid": "邮箱格式不正确", + "authPasswordRequired": "请输入密码", + "authPasswordLength": "密码长度需在 8-64 位之间", + "authPasswordWeak": "密码需同时包含字母和数字", + "authNetworkError": "网络异常,请稍后重试", + "authSwitchToSignUp": "还没有账号?去注册", + "authSwitchToSignIn": "已有账号?去登录", + "authGateTitle": "登录后可继续", + "authGateMessage": "观看记录、收藏、评论和下载需要登录。", + "authGateAction": "去登录", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", + "cancel": "暂不", + "noEpisodesYet": "还没有可播放的剧集。", + "networkError": "网络异常,请检查网络后重试。", + "progressError": "续看进度加载失败。", + "favoritesError": "收藏列表加载失败。", + "favoriteToggleFailed": "收藏操作失败,请重试。", + "myFavorites": "我的收藏", + "noFavorites": "还没有收藏。", + "downloads": "下载", + "downloadsError": "下载设置加载失败。", + "noDownloads": "还没有下载授权。", + "downloadedItems": "下载授权", + "downloadQuota": "剩余 {remaining} / {max} 个授权", + "@downloadQuota": { + "placeholders": { + "remaining": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "downloadLicenseDays": "授权有效期 {days} 天", + "@downloadLicenseDays": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "downloadWifiOnly": "仅 Wi-Fi 下载", + "downloadQuality": "最高清晰度", + "downloadStatus": "授权:{status}", + "@downloadStatus": { + "placeholders": { + "status": {} + } + }, + "downloadTaskStatus": "任务:{status}", + "@downloadTaskStatus": { + "placeholders": { + "status": {} + } + }, + "downloadProgress": "{progress}%", + "@downloadProgress": { + "placeholders": { + "progress": { + "type": "int" + } + } + }, + "downloadExpiresAt": "到期时间 {time}", + "@downloadExpiresAt": { + "placeholders": { + "time": {} + } + }, + "clearDownloadsTitle": "清空下载授权?", + "clearDownloadsMessage": "这会吊销当前设备的全部下载授权。", + "downloadSettingsFailed": "下载设置更新失败。", + "downloadDeleteFailed": "删除下载授权失败。", + "downloadClearFailed": "清空下载授权失败。", + "downloadGrantFailed": "申请下载失败。", + "downloadGranted": "下载授权已就绪。", + "noWatchRecords": "还没有观看记录。", + "recordDeleteFailed": "删除记录失败,请重试。", + "recordClearFailed": "清空观看记录失败,请重试。", + "clearAll": "清空", + "clearWatchHistoryTitle": "清空观看记录?", + "clearWatchHistoryMessage": "这会从当前账号移除全部观看记录。", + "confirmClear": "清空", + "detailLoadFailed": "剧集信息加载失败", + "retryDetail": "重试", + "episodeUnavailable": "集数未知", + "updatedAtLabel": "更新于 {time}", + "@updatedAtLabel": { + "placeholders": { + "time": {} + } + }, + "remove": "移除", + "unlockComingSoon": "金币解锁功能即将开放。", + "resumeProgress": "续看至 {position} / {total}", + "@resumeProgress": { + "placeholders": { + "position": {}, + "total": {} + } + }, + "accountCenter": "账号", + "accountProfile": "个人资料", + "accountDevices": "登录设备", + "accountDataExport": "数据导出", + "accountDeactivate": "注销账号", + "accountProfileLoadFailed": "个人资料加载失败。", + "accountProfileSaveFailed": "个人资料保存失败。", + "accountNickname": "昵称", + "accountNicknameInvalid": "请输入 1 至 30 个字符。", + "accountGender": "性别", + "accountMale": "男", + "accountFemale": "女", + "accountUnknownGender": "不愿透露", + "accountSelectBirthday": "设置生日", + "accountBirthdayHint": "当前版本只能设置生日,不能清空。", + "accountSaved": "个人资料已保存。", + "accountReadOnlyInfo": "账号信息", + "accountUid": "UID", + "accountMaskedAccount": "账号", + "accountCountry": "国家", + "accountCurrency": "币种", + "accountStatus": "状态", + "accountLanguage": "语言", + "accountTimezone": "时区", + "accountRegion": "地区", + "accountVipLevel": "VIP 等级", + "accountCreatedAt": "注册时间", + "accountLastLogin": "最近登录", + "accountDevicesEmpty": "暂无登录设备。", + "accountDevicesLoadFailed": "登录设备加载失败。", + "accountPlatform": "平台", + "accountDeviceId": "设备号", + "accountFirstSeen": "首次登录", + "accountLastActive": "最近活跃", + "accountDeviceActive": "活跃", + "accountDeviceInactive": "不活跃", + "accountExportLoadFailed": "账号数据导出失败。", + "accountCredentials": "登录凭据", + "accountConsentRecords": "授权记录", + "accountDeviceCount": "设备数", + "accountCopyJson": "复制完整 JSON", + "accountCopied": "账号数据已复制。", + "accountVerified": "已验证", + "accountUnverified": "未验证", + "accountDeactivateWarning": "提交后进入 15 天冷静期,所有设备会立即退出登录。", + "accountDeactivateReason": "注销原因(选填)", + "accountDeactivateReasonHint": "告诉我们你离开的原因", + "accountRequestDeactivate": "申请注销账号", + "accountConfirmDeactivateTitle": "确认申请注销?", + "accountConfirmDeactivateMessage": "所有设备会立即退出登录。重新登录后,可在 15 天冷静期内撤销申请。", + "accountConfirmDeactivate": "确认注销", + "accountScheduledFor": "预计注销时间:{time}", + "@accountScheduledFor": { + "placeholders": { + "time": {} + } + }, + "accountCancelDeactivation": "撤销注销申请", + "accountCancelSucceeded": "注销申请已撤销。", + "accountDeactivateFailed": "注销申请提交失败。", + "accountCancelFailed": "注销申请撤销失败。" } diff --git a/lib/main.dart b/lib/main.dart index 23b1398..bb45154 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,10 +2,17 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import 'app.dart'; +import 'core/config/app_config.dart'; +import 'core/config/server_endpoint.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + await Future.wait([ + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), + ServerEndpoint.load(), + ]); + await AppConfig.probeAndApplyBaseUrl(); runApp(const LumaDramaApp()); } diff --git a/lib/pages/account/account_bindings.dart b/lib/pages/account/account_bindings.dart new file mode 100644 index 0000000..117da87 --- /dev/null +++ b/lib/pages/account/account_bindings.dart @@ -0,0 +1,40 @@ +// Author: fruit.com.cn + +import '../../core/data/models/account_profile.dart'; +import '../../core/data/repositories/account_repository.dart'; +import '../../core/data/services/auth_controller.dart'; +import 'account_deactivate_logic.dart'; +import 'account_devices_logic.dart'; +import 'account_export_logic.dart'; +import 'account_profile_logic.dart'; + +/// 账号中心页面依赖装配。 +class AccountProfileBinding { + AccountProfileLogic create( + AccountRepository repository, + AuthController auth, + ) => AccountProfileLogic( + repository, + onProfileUpdated: (AccountProfile profile) => + auth.applyProfile(profile.toAuthUser()), + ); +} + +class AccountDevicesBinding { + AccountDevicesLogic create(AccountRepository repository) => + AccountDevicesLogic(repository); +} + +class AccountExportBinding { + AccountExportLogic create( + AccountRepository repository, + Future Function(String text) copyText, + ) => AccountExportLogic(repository, copyText: copyText); +} + +class AccountDeactivateBinding { + AccountDeactivateLogic create( + AccountRepository repository, + AuthController auth, + ) => AccountDeactivateLogic(repository, clearSession: auth.clear); +} diff --git a/lib/pages/account/account_deactivate_logic.dart b/lib/pages/account/account_deactivate_logic.dart new file mode 100644 index 0000000..ccead0b --- /dev/null +++ b/lib/pages/account/account_deactivate_logic.dart @@ -0,0 +1,82 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/account_repository.dart'; + +/// 注销申请与撤销状态。 +class AccountDeactivateLogic extends ChangeNotifier { + AccountDeactivateLogic(this._repository, {required this.clearSession}); + + static const maxReasonLength = 200; + + final AccountRepository _repository; + final Future Function() clearSession; + DateTime? scheduledAt; + bool requesting = false; + bool cancelling = false; + String? error; + String? actionMessage; + bool cancelSucceeded = false; + bool _disposed = false; + + /// 申请注销;成功后立即清理本地会话。 + Future request(String reason) async { + requesting = true; + error = null; + cancelSucceeded = false; + _notify(); + try { + scheduledAt = await _repository.deactivate( + reason.trim().substring( + 0, + reason.trim().length > maxReasonLength + ? maxReasonLength + : reason.trim().length, + ), + ); + await clearSession(); + return true; + } catch (failure) { + error = failure is ApiException + ? failure.message + : 'accountDeactivateFailed'; + return false; + } finally { + requesting = false; + _notify(); + } + } + + /// 撤销待处理申请;失败时保留后端文案。 + Future cancel() async { + cancelling = true; + actionMessage = null; + cancelSucceeded = false; + _notify(); + try { + await _repository.cancelDeactivation(); + cancelSucceeded = true; + return true; + } catch (failure) { + actionMessage = failure is ApiException + ? failure.message + : 'accountCancelFailed'; + return false; + } finally { + cancelling = false; + _notify(); + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/account/account_deactivate_page.dart b/lib/pages/account/account_deactivate_page.dart new file mode 100644 index 0000000..cf1799f --- /dev/null +++ b/lib/pages/account/account_deactivate_page.dart @@ -0,0 +1,217 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/repositories/account_repository.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/formatting/localized_date_time.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../l10n/app_localizations.dart'; +import 'account_bindings.dart'; +import 'account_deactivate_logic.dart'; + +/// 注销申请与冷静期撤销页。 +class AccountDeactivatePage extends StatefulWidget { + const AccountDeactivatePage({super.key}); + + @override + State createState() => _AccountDeactivatePageState(); +} + +class _AccountDeactivatePageState extends State { + late final AccountDeactivateLogic _logic; + final _reason = TextEditingController(); + + @override + void initState() { + super.initState(); + _logic = AccountDeactivateBinding().create( + context.read(), + context.read(), + ); + } + + @override + void dispose() { + _logic.dispose(); + _reason.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + final canShow = signedIn || _logic.scheduledAt != null; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('account-deactivate-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(l10n.accountDeactivate), + ), + body: SafeArea( + top: false, + child: canShow + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), + ), + ), + ); + } + + Widget _body(AppLocalizations l10n) { + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + children: [ + _warning(l10n), + const SizedBox(height: 14), + if (_logic.scheduledAt != null) + _scheduledPanel(l10n) + else + _requestPanel(l10n), + const SizedBox(height: 14), + _cancelPanel(l10n), + ], + ); + } + + Widget _warning(AppLocalizations l10n) { + final errorColor = Theme.of(context).colorScheme.error; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: errorColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: errorColor.withValues(alpha: 0.35)), + ), + child: Text( + l10n.accountDeactivateWarning, + style: TextStyle(color: errorColor, height: 1.5), + ), + ); + } + + Widget _scheduledPanel(AppLocalizations l10n) => Container( + key: const Key('account-deactivate-scheduled'), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + ), + child: Text( + l10n.accountScheduledFor( + formatLocalizedDateTime(context, _logic.scheduledAt!), + ), + ), + ); + + Widget _requestPanel(AppLocalizations l10n) => Column( + children: [ + TextField( + key: const Key('account-deactivate-reason'), + controller: _reason, + maxLength: AccountDeactivateLogic.maxReasonLength, + maxLines: 4, + decoration: InputDecoration( + labelText: l10n.accountDeactivateReason, + hintText: l10n.accountDeactivateReasonHint, + ), + ), + if (_logic.error != null) + _errorText(_message(l10n, _logic.error, l10n.accountDeactivateFailed)), + const SizedBox(height: 12), + FilledButton( + key: const Key('account-deactivate-submit'), + onPressed: _logic.requesting ? null : _confirm, + child: _logic.requesting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.accountRequestDeactivate), + ), + ], + ); + + Widget _cancelPanel(AppLocalizations l10n) => Column( + children: [ + OutlinedButton( + key: const Key('account-deactivate-cancel'), + onPressed: _logic.cancelling ? null : _cancel, + child: Text(l10n.accountCancelDeactivation), + ), + if (_logic.actionMessage != null) ...[ + const SizedBox(height: 8), + _errorText( + _message(l10n, _logic.actionMessage, l10n.accountCancelFailed), + ), + ], + ], + ); + + Widget _errorText(String value) => Text( + value, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: AppDimens.caption, + ), + ); + + Future _confirm() async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.accountConfirmDeactivateTitle), + content: Text(l10n.accountConfirmDeactivateMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + key: const Key('account-deactivate-confirm'), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.accountConfirmDeactivate), + ), + ], + ), + ); + if (confirmed != true) return; + await _logic.request(_reason.text); + } + + Future _cancel() async { + final ok = await _logic.cancel(); + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + if (ok) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.accountCancelSucceeded))); + } + } + + String _message(AppLocalizations l10n, String? value, String fallback) { + if (value == null || value.isEmpty) return fallback; + return switch (value) { + 'accountDeactivateFailed' => l10n.accountDeactivateFailed, + 'accountCancelFailed' => l10n.accountCancelFailed, + _ => value, + }; + } +} diff --git a/lib/pages/account/account_devices_logic.dart b/lib/pages/account/account_devices_logic.dart new file mode 100644 index 0000000..f4776de --- /dev/null +++ b/lib/pages/account/account_devices_logic.dart @@ -0,0 +1,43 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/account_device.dart'; +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/account_repository.dart'; + +/// 只读登录设备列表状态。 +class AccountDevicesLogic extends ChangeNotifier { + AccountDevicesLogic(this._repository); + + final AccountRepository _repository; + List devices = const []; + bool loading = false; + String? error; + bool _disposed = false; + + /// 拉取当前账号的设备列表。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + devices = await _repository.devices(); + } catch (failure) { + error = failure is ApiException ? failure.message : 'networkError'; + } finally { + loading = false; + _notify(); + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/account/account_devices_page.dart b/lib/pages/account/account_devices_page.dart new file mode 100644 index 0000000..b64be9c --- /dev/null +++ b/lib/pages/account/account_devices_page.dart @@ -0,0 +1,222 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/account_device.dart'; +import '../../core/data/repositories/account_repository.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_date_time.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import 'account_bindings.dart'; +import 'account_devices_logic.dart'; + +const _maskVisible = 4; + +/// 登录设备只读列表页。 +class AccountDevicesPage extends StatefulWidget { + const AccountDevicesPage({super.key}); + + @override + State createState() => _AccountDevicesPageState(); +} + +class _AccountDevicesPageState extends State { + late final AccountDevicesLogic _logic; + + @override + void initState() { + super.initState(); + _logic = AccountDevicesBinding().create(context.read()); + unawaited(_logic.load()); + } + + @override + void dispose() { + _logic.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('account-devices-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(l10n.accountDevices), + ), + body: SafeArea( + top: false, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), + ), + ), + ); + } + + Widget _body(AppLocalizations l10n) { + if (_logic.loading && _logic.devices.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_logic.error != null && _logic.devices.isEmpty) { + return EmptyState( + key: const Key('account-devices-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, _logic.error, l10n.accountDevicesLoadFailed), + actionLabel: l10n.retry, + actionKey: const Key('account-devices-retry'), + onAction: _logic.load, + ); + } + if (_logic.devices.isEmpty) { + return EmptyState( + key: const Key('account-devices-empty'), + icon: Icons.devices_other_rounded, + title: l10n.accountDevicesEmpty, + ); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + itemCount: _logic.devices.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (_, index) => + _DeviceTile(device: _logic.devices[index], l10n: l10n), + ); + } +} + +class _DeviceTile extends StatelessWidget { + const _DeviceTile({required this.device, required this.l10n}); + + final AccountDevice device; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return Container( + key: ValueKey('account-device-${device.id}'), + padding: const EdgeInsets.all(AppDimens.cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.outlineVariantDark), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.phone_android_rounded, + color: AppColors.blueSoft, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + device.deviceModel.isEmpty + ? device.platform + : device.deviceModel, + style: const TextStyle( + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + ), + _StatusChip(status: device.status, l10n: l10n), + ], + ), + const SizedBox(height: 10), + _line(l10n.accountPlatform, device.platform), + _line(l10n.accountDeviceId, _maskDeviceId(device.deviceId)), + if (device.firstSeenAt != null) + _line( + l10n.accountFirstSeen, + formatLocalizedDateTime(context, device.firstSeenAt!), + ), + if (device.lastSeenAt != null) + _line( + l10n.accountLastActive, + formatLocalizedDateTime(context, device.lastSeenAt!), + ), + ], + ), + ); + } + + Widget _line(String label, String value) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ), + Flexible( + child: Text( + value.isEmpty ? '-' : value, + textAlign: TextAlign.right, + style: const TextStyle(fontSize: AppDimens.caption), + ), + ), + ], + ), + ); +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status, required this.l10n}); + + final String status; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final active = status.toLowerCase() == 'active'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: active + ? AppColors.gold.withValues(alpha: 0.16) + : AppColors.raised, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + active ? l10n.accountDeviceActive : l10n.accountDeviceInactive, + style: TextStyle( + color: active ? AppColors.gold : AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ); + } +} + +String _maskDeviceId(String value) { + if (value.length <= _maskVisible * 2) return value; + final head = value.substring(0, _maskVisible); + final tail = value.substring(value.length - _maskVisible); + return '$head****$tail'; +} diff --git a/lib/pages/account/account_entry_card.dart b/lib/pages/account/account_entry_card.dart new file mode 100644 index 0000000..e12dc83 --- /dev/null +++ b/lib/pages/account/account_entry_card.dart @@ -0,0 +1,141 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../l10n/app_localizations.dart'; +import 'account_deactivate_page.dart'; +import 'account_devices_page.dart'; +import 'account_export_page.dart'; +import 'account_profile_page.dart'; + +const _accountTitleWidth = 64.0; + +/// 我的页账号入口卡;只在登录态由父页面展示。 +class AccountEntryCard extends StatelessWidget { + const AccountEntryCard({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Material( + key: const Key('mine-account-card'), + color: AppColors.card, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: AppDimens.accountCardHeight, + child: Row( + children: [ + SizedBox( + width: _accountTitleWidth, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Text( + l10n.accountCenter, + style: const TextStyle( + fontSize: AppDimens.caption, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + const VerticalDivider( + width: 1, + color: AppColors.outlineVariantDark, + ), + Expanded( + child: Row( + children: [ + _entry( + context, + key: const Key('mine-account-profile'), + icon: Icons.person_outline_rounded, + title: l10n.accountProfile, + page: const AccountProfilePage(), + divider: true, + ), + _entry( + context, + key: const Key('mine-account-devices'), + icon: Icons.devices_other_rounded, + title: l10n.accountDevices, + page: const AccountDevicesPage(), + divider: true, + ), + _entry( + context, + key: const Key('mine-account-export'), + icon: Icons.file_download_outlined, + title: l10n.accountDataExport, + page: const AccountExportPage(), + divider: true, + ), + _entry( + context, + key: const Key('mine-account-deactivate'), + icon: Icons.person_remove_outlined, + title: l10n.accountDeactivate, + page: const AccountDeactivatePage(), + divider: false, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _entry( + BuildContext context, { + required Key key, + required IconData icon, + required String title, + required Widget page, + required bool divider, + }) { + return Expanded( + child: Row( + children: [ + Expanded( + child: InkWell( + key: key, + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => page)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + color: AppColors.blueSoft, + size: AppDimens.accountIcon, + ), + const SizedBox(height: 2), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: AppDimens.accountLabel, + height: 1.15, + ), + ), + ], + ), + ), + ), + if (divider) + const VerticalDivider( + width: 1, + color: AppColors.outlineVariantDark, + ), + ], + ), + ); + } +} diff --git a/lib/pages/account/account_export_logic.dart b/lib/pages/account/account_export_logic.dart new file mode 100644 index 0000000..45864ec --- /dev/null +++ b/lib/pages/account/account_export_logic.dart @@ -0,0 +1,52 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/account_export.dart'; +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/account_repository.dart'; + +/// 账号数据导出状态与复制动作。 +class AccountExportLogic extends ChangeNotifier { + AccountExportLogic(this._repository, {required this.copyText}); + + final AccountRepository _repository; + final Future Function(String text) copyText; + AccountExport? data; + bool loading = false; + String? error; + bool _disposed = false; + + /// 加载完整导出数据。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + data = await _repository.export(); + } catch (failure) { + error = failure is ApiException ? failure.message : 'networkError'; + } finally { + loading = false; + _notify(); + } + } + + /// 把后端下发的完整 JSON 写入剪贴板。 + Future copy() async { + final current = data; + if (current == null) return false; + await copyText(current.rawJson); + return true; + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/account/account_export_page.dart b/lib/pages/account/account_export_page.dart new file mode 100644 index 0000000..d17cde7 --- /dev/null +++ b/lib/pages/account/account_export_page.dart @@ -0,0 +1,197 @@ +// 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/models/account_export.dart'; +import '../../core/data/repositories/account_repository.dart'; +import '../../core/data/services/auth_controller.dart'; +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/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import 'account_bindings.dart'; +import 'account_export_logic.dart'; + +/// 账号数据导出摘要页。 +class AccountExportPage extends StatefulWidget { + const AccountExportPage({super.key}); + + @override + State createState() => _AccountExportPageState(); +} + +class _AccountExportPageState extends State { + late final AccountExportLogic _logic; + + @override + void initState() { + super.initState(); + _logic = AccountExportBinding().create( + context.read(), + (text) => Clipboard.setData(ClipboardData(text: text)), + ); + unawaited(_logic.load()); + } + + @override + void dispose() { + _logic.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('account-export-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(l10n.accountDataExport), + ), + body: SafeArea( + top: false, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), + ), + ), + ); + } + + Widget _body(AppLocalizations l10n) { + if (_logic.loading && _logic.data == null) { + return const Center(child: CircularProgressIndicator()); + } + final data = _logic.data; + if (data == null) { + return EmptyState( + key: const Key('account-export-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, _logic.error, l10n.accountExportLoadFailed), + actionLabel: l10n.retry, + actionKey: const Key('account-export-retry'), + onAction: _logic.load, + ); + } + return ListView( + key: const Key('account-export-content'), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + children: [ + _profileCard(data, l10n), + const SizedBox(height: 12), + _credentialsCard(data, l10n), + const SizedBox(height: 16), + _copyButton(l10n), + ], + ); + } + + Widget _profileCard(AccountExport data, AppLocalizations l10n) => _card( + children: [ + Text( + data.profile.nickname, + style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + '${l10n.accountUid}: ${data.profile.uid}', + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ); + + Widget _credentialsCard(AccountExport data, AppLocalizations l10n) => _card( + children: [ + Text( + l10n.accountCredentials, + style: const TextStyle( + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + for (final credential in data.credentials) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon( + Icons.verified_user_outlined, + color: AppColors.blueSoft, + ), + title: Text(credential.masked), + subtitle: Text(credential.type), + trailing: Text( + credential.verified ? l10n.accountVerified : l10n.accountUnverified, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ), + _summary(l10n.accountConsentRecords, '${data.consentCount}'), + _summary(l10n.accountDeviceCount, '${data.deviceCount}'), + ], + ); + + Widget _copyButton(AppLocalizations l10n) => FilledButton.icon( + key: const Key('account-export-copy'), + onPressed: _copy, + icon: const Icon(Icons.copy_rounded), + label: Text(l10n.accountCopyJson), + ); + + Widget _card({required List children}) => Container( + padding: const EdgeInsets.all(AppDimens.cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.outlineVariantDark), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ); + + Widget _summary(String label, String value) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ), + Text(value, style: const TextStyle(fontWeight: FontWeight.w700)), + ], + ), + ); + + Future _copy() async { + final ok = await _logic.copy(); + if (!ok || !mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.accountCopied)), + ); + } +} diff --git a/lib/pages/account/account_profile_logic.dart b/lib/pages/account/account_profile_logic.dart new file mode 100644 index 0000000..53c9018 --- /dev/null +++ b/lib/pages/account/account_profile_logic.dart @@ -0,0 +1,91 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../../core/data/models/account_profile.dart'; +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/account_repository.dart'; + +/// 个人资料页状态与保存动作。 +class AccountProfileLogic extends ChangeNotifier { + AccountProfileLogic(this._repository, {required this.onProfileUpdated}); + + static const maxNicknameLength = 30; + + final AccountRepository _repository; + final Future Function(AccountProfile profile) onProfileUpdated; + + AccountProfile? profile; + bool loading = false; + bool saving = false; + String? error; + String? fieldError; + bool _disposed = false; + + /// 进入页面时加载当前资料。 + Future load() async { + loading = true; + error = null; + _notify(); + try { + profile = await _repository.profile(); + } catch (failure) { + error = _message(failure); + } finally { + loading = false; + _notify(); + } + } + + /// 只提交本次改动;成功后同步登录态摘要。 + Future save({ + required String nickname, + required String gender, + String? birthday, + }) async { + final current = profile; + if (current == null) return false; + final trimmed = nickname.trim(); + if (trimmed.isEmpty || trimmed.length > maxNicknameLength) { + fieldError = 'accountNicknameInvalid'; + _notify(); + return false; + } + final update = AccountProfileUpdate( + nickname: trimmed == current.nickname ? null : trimmed, + gender: gender.isEmpty || gender == current.gender ? null : gender, + birthday: birthday == null || birthday.isEmpty ? null : birthday, + ); + if (update.toJson().isEmpty) return true; + + saving = true; + error = null; + fieldError = null; + _notify(); + try { + final next = await _repository.updateProfile(update); + profile = next; + await onProfileUpdated(next); + return true; + } catch (failure) { + error = _message(failure); + return false; + } finally { + saving = false; + _notify(); + } + } + + String _message(Object failure) => + failure is ApiException ? failure.message : 'networkError'; + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart new file mode 100644 index 0000000..d3aeac9 --- /dev/null +++ b/lib/pages/account/account_profile_page.dart @@ -0,0 +1,364 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/account_profile.dart'; +import '../../core/data/repositories/account_repository.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_date_time.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/remote_image.dart'; +import '../../l10n/app_localizations.dart'; +import 'account_bindings.dart'; +import 'account_profile_logic.dart'; + +const _avatarSize = 64.0; +const _fieldGap = 14.0; +const _datePadWidth = 2; + +/// 个人资料编辑页。 +class AccountProfilePage extends StatefulWidget { + const AccountProfilePage({super.key}); + + @override + State createState() => _AccountProfilePageState(); +} + +class _AccountProfilePageState extends State { + late final AccountProfileLogic _logic; + final _nickname = TextEditingController(); + var _gender = 'unknown'; + String? _birthday; + bool _formReady = false; + + @override + void initState() { + super.initState(); + _logic = AccountProfileBinding().create( + context.read(), + context.read(), + )..addListener(_syncProfile); + unawaited(_logic.load()); + } + + void _syncProfile() { + final profile = _logic.profile; + if (profile == null || _formReady) return; + _nickname.text = profile.nickname; + _gender = _knownGender(profile.gender); + _formReady = true; + } + + @override + void dispose() { + _logic + ..removeListener(_syncProfile) + ..dispose(); + _nickname.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('account-profile-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(l10n.accountProfile), + ), + body: SafeArea( + top: false, + child: signedIn + ? ListenableBuilder( + listenable: _logic, + builder: (context, _) => _body(l10n), + ) + : const AuthGatePanel(), + ), + ), + ); + } + + Widget _body(AppLocalizations l10n) { + if (_logic.loading && _logic.profile == null) { + return const Center(child: CircularProgressIndicator()); + } + final profile = _logic.profile; + if (profile == null) { + return EmptyState( + key: const Key('account-profile-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, _logic.error, l10n.accountProfileLoadFailed), + actionLabel: l10n.retry, + actionKey: const Key('account-profile-retry'), + onAction: _logic.load, + ); + } + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + children: [ + _identity(profile, l10n), + const SizedBox(height: 16), + _editableForm(l10n), + const SizedBox(height: 18), + _readOnlyCard(profile, l10n), + ], + ); + } + + Widget _identity(AccountProfile profile, AppLocalizations l10n) { + return Row( + children: [ + ClipOval( + child: SizedBox( + width: _avatarSize, + height: _avatarSize, + child: profile.avatarUrl.isEmpty + ? const ColoredBox( + color: AppColors.raised, + child: Icon(Icons.person_rounded, size: 34), + ) + : RemoteImage(profile.avatarUrl), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.nickname, + style: const TextStyle( + fontSize: AppDimens.pageTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '${l10n.accountUid}: ${profile.uid}', + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _editableForm(AppLocalizations l10n) { + return _AccountCard( + child: Column( + children: [ + _nicknameField(l10n), + const SizedBox(height: _fieldGap), + _genderField(l10n), + const SizedBox(height: _fieldGap), + _birthdayField(l10n), + if (_logic.error != null) _saveError(l10n), + const SizedBox(height: 16), + _saveButton(l10n), + ], + ), + ); + } + + Widget _nicknameField(AppLocalizations l10n) => TextField( + key: const Key('account-nickname'), + controller: _nickname, + maxLength: AccountProfileLogic.maxNicknameLength, + decoration: InputDecoration( + labelText: l10n.accountNickname, + errorText: _logic.fieldError == null ? null : l10n.accountNicknameInvalid, + ), + ); + + Widget _genderField(AppLocalizations l10n) => DropdownButtonFormField( + key: const Key('account-gender'), + initialValue: _gender, + decoration: InputDecoration(labelText: l10n.accountGender), + items: [ + DropdownMenuItem(value: 'male', child: Text(l10n.accountMale)), + DropdownMenuItem(value: 'female', child: Text(l10n.accountFemale)), + DropdownMenuItem( + value: 'unknown', + child: Text(l10n.accountUnknownGender), + ), + ], + onChanged: (value) { + if (value != null) setState(() => _gender = value); + }, + ); + + Widget _birthdayField(AppLocalizations l10n) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + key: const Key('account-birthday'), + onPressed: _pickBirthday, + icon: const Icon(Icons.cake_outlined), + label: Text(_birthday ?? l10n.accountSelectBirthday), + ), + const SizedBox(height: 6), + Text( + l10n.accountBirthdayHint, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ); + + Widget _saveError(AppLocalizations l10n) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + errorText(l10n, _logic.error, l10n.accountProfileSaveFailed), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: AppDimens.caption, + ), + ), + ); + + Widget _saveButton(AppLocalizations l10n) => SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + key: const Key('account-profile-save'), + onPressed: _logic.saving ? null : _save, + child: _logic.saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.save), + ), + ); + + Widget _readOnlyCard(AccountProfile profile, AppLocalizations l10n) { + return _AccountCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.accountReadOnlyInfo, + style: const TextStyle( + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + _info(l10n.accountMaskedAccount, profile.maskedTarget), + _info(l10n.accountCountry, profile.countryCode), + _info(l10n.accountCurrency, profile.currency), + _info(l10n.accountStatus, profile.status), + _info(l10n.accountLanguage, profile.language), + _info(l10n.accountTimezone, profile.timezone), + _info(l10n.accountRegion, profile.regionCode), + _info(l10n.accountVipLevel, '${profile.vipLevel}'), + if (profile.createdAt != null) + _info( + l10n.accountCreatedAt, + formatLocalizedDateTime(context, profile.createdAt!), + ), + if (profile.lastLoginAt != null) + _info( + l10n.accountLastLogin, + formatLocalizedDateTime(context, profile.lastLoginAt!), + ), + ], + ), + ); + } + + Widget _info(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ), + Flexible( + child: Text( + value.isEmpty ? '-' : value, + textAlign: TextAlign.right, + style: const TextStyle(fontSize: AppDimens.body), + ), + ), + ], + ), + ); + + Future _pickBirthday() async { + final value = await showDatePicker( + context: context, + initialDate: DateTime(2000), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (value != null && mounted) { + setState(() => _birthday = _formatDate(value)); + } + } + + Future _save() async { + final ok = await _logic.save( + nickname: _nickname.text, + gender: _gender, + birthday: _birthday, + ); + if (!ok || !mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.accountSaved)), + ); + } + + String _knownGender(String value) => + const {'male', 'female', 'unknown'}.contains(value) ? value : 'unknown'; +} + +String _formatDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(_datePadWidth, '0')}-' + '${value.day.toString().padLeft(_datePadWidth, '0')}'; + +class _AccountCard extends StatelessWidget { + const _AccountCard({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(AppDimens.cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.outlineVariantDark), + ), + child: child, + ); +} diff --git a/lib/pages/auth/auth_binding.dart b/lib/pages/auth/auth_binding.dart new file mode 100644 index 0000000..dbc17f7 --- /dev/null +++ b/lib/pages/auth/auth_binding.dart @@ -0,0 +1,6 @@ +import 'auth_logic.dart'; + +/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。 +class AuthBinding { + AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode); +} diff --git a/lib/pages/auth/auth_form_widgets.dart b/lib/pages/auth/auth_form_widgets.dart new file mode 100644 index 0000000..576da35 --- /dev/null +++ b/lib/pages/auth/auth_form_widgets.dart @@ -0,0 +1,416 @@ +// Author: fruit.com.cn + +part of 'auth_view.dart'; + +class _AuthFields extends StatelessWidget { + const _AuthFields({required this.state, required this.callbacks}); + + final _AuthCardState state; + final _AuthCardCallbacks callbacks; + + @override + Widget build(BuildContext context) { + final error = state.status.fieldError; + final emailError = + error == AuthFieldError.emailRequired || + error == AuthFieldError.emailInvalid; + final passwordError = + error == AuthFieldError.passwordRequired || + error == AuthFieldError.passwordLength || + error == AuthFieldError.passwordWeak; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldLabel(state.l10n.authEmailLabel), + const SizedBox(height: 8), + _emailField(context, hasError: emailError), + const SizedBox(height: 16), + _fieldLabel(state.l10n.authPasswordLabel), + const SizedBox(height: 8), + _passwordField(context, hasError: passwordError), + if (state.status.signUpMode) ...[ + const SizedBox(height: 8), + Text( + state.l10n.authPasswordHint, + key: const Key('auth-password-rule'), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ], + ); + } + + Widget _emailField(BuildContext context, {required bool hasError}) { + final enabled = !state.status.busy; + return ListenableBuilder( + listenable: state.fields.emailFocus, + builder: (context, _) => SizedBox( + height: _fieldHeight, + child: TextField( + key: const Key('auth-email'), + controller: state.fields.email, + focusNode: state.fields.emailFocus, + enabled: enabled, + autofocus: true, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + onChanged: callbacks.onEmailChanged, + onSubmitted: (_) => state.fields.passwordFocus.requestFocus(), + style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + decoration: _decoration( + context: context, + hint: 'name@example.com', + icon: Icons.mail_outline_rounded, + focused: state.fields.emailFocus.hasFocus, + hasError: hasError, + ), + ), + ), + ); + } + + Widget _passwordField(BuildContext context, {required bool hasError}) { + final enabled = !state.status.busy; + final l10n = state.l10n; + return ListenableBuilder( + listenable: state.fields.passwordFocus, + builder: (context, _) => SizedBox( + height: _fieldHeight, + child: TextField( + key: const Key('auth-password'), + controller: state.fields.password, + focusNode: state.fields.passwordFocus, + enabled: enabled, + obscureText: state.status.obscurePassword, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.password], + onChanged: callbacks.onPasswordChanged, + onSubmitted: (_) => callbacks.onSubmit(), + style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + decoration: _decoration( + context: context, + hint: l10n.authPasswordHint, + icon: Icons.lock_outline_rounded, + focused: state.fields.passwordFocus.hasFocus, + hasError: hasError, + suffix: IconButton( + key: const Key('auth-password-visibility'), + constraints: const BoxConstraints.tightFor( + width: _minTapTarget, + height: _minTapTarget, + ), + tooltip: state.status.obscurePassword + ? l10n.showPassword + : l10n.hidePassword, + onPressed: enabled ? callbacks.onTogglePasswordVisibility : null, + icon: Icon( + state.status.obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.muted, + ), + ), + ), + ), + ), + ); + } + + Widget _fieldLabel(String label) => Text( + label, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ); + + InputDecoration _decoration({ + required BuildContext context, + required String hint, + required IconData icon, + required bool focused, + required bool hasError, + Widget? suffix, + }) { + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: BorderSide.none, + ); + final errorColor = Theme.of(context).colorScheme.error; + final errorBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: BorderSide(color: errorColor, width: _fieldBorderWidth), + ); + return InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), + prefixIcon: Icon( + icon, + size: 21, + color: focused ? AppColors.blueSoft : AppColors.muted, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: _minTapTarget, + minHeight: _minTapTarget, + ), + suffixIcon: suffix, + filled: true, + fillColor: AppColors.bar, + isDense: true, + contentPadding: const EdgeInsets.only(right: 16), + border: border, + enabledBorder: border, + disabledBorder: border, + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: const BorderSide( + color: AppColors.blue, + width: _fieldBorderWidth, + ), + ), + errorBorder: errorBorder, + focusedErrorBorder: errorBorder, + ); + } +} + +class _AuthSubmitButton extends StatelessWidget { + const _AuthSubmitButton({required this.state, required this.onSubmit}); + + final _AuthCardState state; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + final status = state.status; + final l10n = state.l10n; + return SizedBox( + width: double.infinity, + height: _buttonHeight, + child: FilledButton( + key: const Key('auth-submit'), + onPressed: status.busy ? null : onSubmit, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return AppColors.blue.withValues(alpha: 0.5); + } + if (states.contains(WidgetState.pressed)) { + return AppColors.bluePressed; + } + return AppColors.blue; + }), + foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary), + elevation: const WidgetStatePropertyAll(2), + shadowColor: WidgetStatePropertyAll( + AppColors.blue.withValues(alpha: 0.22), + ), + padding: const WidgetStatePropertyAll(EdgeInsets.zero), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_buttonRadius), + ), + ), + ), + child: status.busy + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onPrimary, + ), + ) + : Text( + status.signUpMode ? l10n.signUp : l10n.signIn, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +class _ModeSegment extends StatelessWidget { + const _ModeSegment({ + required this.segmentKey, + required this.label, + required this.hiddenLabel, + required this.selected, + required this.onTap, + }); + + final Key segmentKey; + final String label; + final String hiddenLabel; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: segmentKey, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedContainer( + duration: _segmentDuration, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? AppColors.blue : Colors.transparent, + borderRadius: BorderRadius.circular(18), + ), + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + Positioned.fill( + child: ExcludeSemantics( + child: Opacity( + opacity: 0, + child: Center( + child: Text( + hiddenLabel, + maxLines: 1, + softWrap: false, + style: const TextStyle(fontSize: 14), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _AuthBrand extends StatelessWidget { + const _AuthBrand({required this.tagline, required this.compact}); + + final String tagline; + final bool compact; + + @override + Widget build(BuildContext context) { + final copy = _brandCopy(context, centered: !compact); + if (compact) { + return Row( + key: const Key('auth-brand'), + children: [ + _brandMark(), + const SizedBox(width: 14), + Expanded(child: copy), + ], + ); + } + return Column( + key: const Key('auth-brand'), + mainAxisSize: MainAxisSize.min, + children: [_brandMark(), const SizedBox(height: 14), copy], + ); + } + + Widget _brandMark() => Container( + key: const Key('auth-brand-mark'), + width: _brandMarkSize, + height: _brandMarkSize, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: AppColors.blue.withValues(alpha: 0.32), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.asset( + 'assets/icon/splash_mark.png', + width: _brandMarkSize, + height: _brandMarkSize, + fit: BoxFit.cover, + ), + ), + ); + + Widget _brandCopy(BuildContext context, {required bool centered}) => Column( + crossAxisAlignment: centered + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.appName, + textAlign: centered ? TextAlign.center : TextAlign.start, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 26, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const SizedBox(height: 6), + Text( + tagline, + textAlign: centered ? TextAlign.center : TextAlign.start, + style: const TextStyle( + color: AppColors.blueSoft, + fontSize: 14, + height: 1.35, + ), + ), + ], + ); +} + +class _AuthError extends StatelessWidget { + const _AuthError({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.error; + return Container( + key: const Key('auth-error-capsule'), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: _errorOpacity), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: _errorBorderOpacity)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline_rounded, color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + key: const Key('auth-error'), + style: TextStyle(color: color, fontSize: 13, height: 1.35), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/auth/auth_logic.dart b/lib/pages/auth/auth_logic.dart new file mode 100644 index 0000000..91dd5db --- /dev/null +++ b/lib/pages/auth/auth_logic.dart @@ -0,0 +1,65 @@ +/// 登录 / 注册页的表单状态与提交逻辑。 +/// +/// 校验规则与后端一致(邮箱格式、密码 8-64 位且含字母数字),本地先拦一次是为了少一次网络往返、 +/// 也让用户立刻看到哪个字段不合格;后端仍会再校验一遍(前端校验不作为安全边界)。 +class AuthLogic { + bool signUpMode; + String email = ''; + String password = ''; + bool busy = false; + String? errorMessage; + AuthFieldError? fieldError; + + AuthLogic({this.signUpMode = false, this.errorMessage}); + + void toggleMode() { + signUpMode = !signUpMode; + errorMessage = null; + fieldError = null; + } + + /// 返回 null 表示校验通过,否则返回错误文案(由视图用 l10n 映射) + AuthFieldError? validate() { + final mail = email.trim(); + if (mail.isEmpty) return _record(AuthFieldError.emailRequired); + if (!_emailPattern.hasMatch(mail)) { + return _record(AuthFieldError.emailInvalid); + } + if (password.isEmpty) return _record(AuthFieldError.passwordRequired); + if (password.length < 8 || password.length > 64) { + return _record(AuthFieldError.passwordLength); + } + if (!_hasLetter.hasMatch(password) || !_hasDigit.hasMatch(password)) { + return _record(AuthFieldError.passwordWeak); + } + fieldError = null; + return null; + } + + /// 清除当前字段错误;输入变化时由页面调用。 + void clearFieldError() { + fieldError = null; + } + + AuthFieldError _record(AuthFieldError error) { + fieldError = error; + return error; + } + + String get normalizedEmail => email.trim().toLowerCase(); + + static final _emailPattern = RegExp( + r'^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?' + r'(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,}$', + ); + static final _hasLetter = RegExp('[A-Za-z]'); + static final _hasDigit = RegExp('[0-9]'); +} + +enum AuthFieldError { + emailRequired, + emailInvalid, + passwordRequired, + passwordLength, + passwordWeak, +} diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart new file mode 100644 index 0000000..5ddbde0 --- /dev/null +++ b/lib/pages/auth/auth_view.dart @@ -0,0 +1,474 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/remote/api_client.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/platform/haptics.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 'auth_logic.dart'; + +part 'auth_widgets.dart'; +part 'auth_form_widgets.dart'; + +const double _minTapTarget = 44; +const double _brandMarkSize = 48; +const double _contentMaxWidth = 440; +// Auth keeps its approved 20px card silhouette. +const double _cardRadius = AppDimens.authCardRadius; +const double _cardPadding = AppDimens.cardPadding; +const double _cardBorderWidth = 1; +const double _fieldHeight = 52; +const double _fieldRadius = 14; +const double _fieldBorderWidth = 1.5; +const double _buttonHeight = 52; +const double _buttonRadius = 26; +const double _glowOpacity = 0.18; +const double _errorOpacity = 0.10; +const double _errorBorderOpacity = 0.28; +const double _enterSlideDistance = 16; +const double _topContentPadding = 64; +const double _bottomContentPadding = 28; +const double _compactTopContentPadding = 48; +const double _compactHeightBreakpoint = 720; +const double _compactBrandGap = 16; +const double _warmGlowSize = 260; +const double _warmGlowOpacity = 0.06; +const Duration _enterDuration = Duration(milliseconds: 220); +const Duration _segmentDuration = Duration(milliseconds: 160); + +/// 登录 / 注册页:邮箱 + 密码。 +class AuthPage extends StatefulWidget { + const AuthPage({super.key, this.signUpMode = false}); + + final bool signUpMode; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + late AuthLogic _logic; + late final TextEditingController _emailController; + late final TextEditingController _passwordController; + late final FocusNode _emailFocus; + late final FocusNode _passwordFocus; + bool _obscurePassword = true; + bool _emailEntryOpen = false; + + @override + void initState() { + super.initState(); + _logic = AuthLogic(signUpMode: widget.signUpMode); + _emailController = TextEditingController(); + _passwordController = TextEditingController(); + _emailFocus = FocusNode(debugLabel: 'auth-email-focus'); + _passwordFocus = FocusNode(debugLabel: 'auth-password-focus'); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + String _errorText(AppLocalizations l10n, AuthFieldError error) { + switch (error) { + case AuthFieldError.emailRequired: + return l10n.authEmailRequired; + case AuthFieldError.emailInvalid: + return l10n.authEmailInvalid; + case AuthFieldError.passwordRequired: + return l10n.authPasswordRequired; + case AuthFieldError.passwordLength: + return l10n.authPasswordLength; + case AuthFieldError.passwordWeak: + return l10n.authPasswordWeak; + } + } + + Future _submit() async { + if (_logic.busy) return; + final l10n = AppLocalizations.of(context)!; + final invalid = _logic.validate(); + if (invalid != null) { + setState(() => _logic.errorMessage = _errorText(l10n, invalid)); + return; + } + unawaited(context.read().lightImpact()); + FocusScope.of(context).unfocus(); + setState(() { + _logic.busy = true; + _logic.errorMessage = null; + }); + final auth = context.read(); + try { + if (_logic.signUpMode) { + await auth.register( + email: _logic.normalizedEmail, + password: _logic.password, + ); + } else { + await auth.signIn( + email: _logic.normalizedEmail, + password: _logic.password, + ); + } + if (!mounted) return; + Navigator.of(context).maybePop(); + } on ApiException catch (error) { + if (!mounted) return; + setState(() => _logic.errorMessage = error.message); + } catch (_) { + if (!mounted) return; + setState(() => _logic.errorMessage = l10n.authNetworkError); + } finally { + if (mounted) setState(() => _logic.busy = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + resizeToAvoidBottomInset: true, + backgroundColor: AppColors.ink, + body: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + height: 420, + child: IgnorePointer( + child: Container( + key: const Key('auth-background-glow'), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blue.withValues(alpha: _glowOpacity), + AppColors.blue.withValues(alpha: 0.05), + AppColors.ink.withValues(alpha: 0), + ], + stops: const [0, 0.55, 1], + ), + ), + ), + ), + ), + Positioned( + top: 72, + right: -80, + width: _warmGlowSize, + height: _warmGlowSize, + child: IgnorePointer( + child: Container( + key: const Key('auth-background-warm-glow'), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.gold.withValues(alpha: _warmGlowOpacity), + AppColors.gold.withValues(alpha: 0), + ], + ), + ), + ), + ), + ), + SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = + constraints.maxHeight < _compactHeightBreakpoint; + final top = compact + ? _compactTopContentPadding + : _topContentPadding; + final bottom = + _bottomContentPadding + + MediaQuery.viewInsetsOf(context).bottom; + final available = constraints.maxHeight - top - bottom; + final minHeight = available > 0 ? available : 0.0; + return AutofillGroup( + child: SingleChildScrollView( + key: const Key('auth-scroll'), + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + top, + AppDimens.pageHorizontal, + bottom, + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: _contentMaxWidth, + ), + child: _content(l10n, compact: compact), + ), + ), + ), + ), + ); + }, + ), + ), + if (Navigator.of(context).canPop()) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 4, top: 4), + child: IconButton( + key: const Key('auth-back'), + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () { + if (_emailEntryOpen) { + setState(() => _emailEntryOpen = false); + } else { + Navigator.of(context).pop(); + } + }, + icon: const Icon( + Icons.arrow_back_rounded, + color: AppColors.onPrimary, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _content(AppLocalizations l10n, {required bool compact}) => + TweenAnimationBuilder( + key: const Key('auth-enter-animation'), + tween: Tween(begin: 0, end: 1), + duration: _enterDuration, + curve: Curves.easeOutCubic, + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, _enterSlideDistance * (1 - value)), + child: child, + ), + ), + child: _emailEntryOpen + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), + SizedBox(height: compact ? _compactBrandGap : 28), + _authCard(l10n), + ], + ) + : _landing(l10n), + ); + + Widget _landing(AppLocalizations l10n) => Column( + key: const Key('auth-landing'), + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + key: const Key('auth-brand-logo'), + width: 64, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue, AppColors.secondary], + ), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 32, + ), + ), + ), + const SizedBox(height: 14), + Text( + l10n.fruitShortsBrand, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.authLandingSubtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11), + ), + const SizedBox(height: 22), + _AuthEntryButton( + key: const Key('auth-entry-google'), + label: 'G ${l10n.authContinueGoogle}', + background: Colors.white, + foreground: const Color(0xFF1A1A1A), + leadingColor: const Color(0xFF4285F4), + onTap: () => _comingSoon(l10n), + ), + const SizedBox(height: 11), + _AuthEntryButton( + key: const Key('auth-entry-phone'), + label: l10n.authPhoneSignIn, + icon: Icons.phone_android_rounded, + background: AppColors.input, + foreground: AppColors.onSurface, + onTap: () => _comingSoon(l10n), + ), + const SizedBox(height: 11), + _AuthEntryButton( + key: const Key('auth-entry-email'), + label: l10n.authEmailSignIn, + icon: Icons.mail_outline_rounded, + background: AppColors.input, + foreground: AppColors.onSurface, + onTap: () => setState(() => _emailEntryOpen = true), + ), + const SizedBox(height: 18), + Text( + l10n.authLegal, + key: const Key('auth-legal'), + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF6B6772), + fontSize: 9, + height: 1.6, + ), + ), + ], + ); + + void _comingSoon(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + } + + Widget _authCard(AppLocalizations l10n) => _AuthCard( + state: _AuthCardState( + l10n: l10n, + status: _AuthFormStatus( + signUpMode: _logic.signUpMode, + busy: _logic.busy, + errorMessage: _logic.errorMessage, + fieldError: _logic.fieldError, + obscurePassword: _obscurePassword, + ), + fields: _AuthFieldControllers( + email: _emailController, + password: _passwordController, + emailFocus: _emailFocus, + passwordFocus: _passwordFocus, + ), + ), + callbacks: _AuthCardCallbacks( + onModeSelected: _selectMode, + onEmailChanged: (value) => setState(() { + _logic.email = value; + _logic.clearFieldError(); + }), + onPasswordChanged: (value) => setState(() { + _logic.password = value; + _logic.clearFieldError(); + }), + onTogglePasswordVisibility: () => + setState(() => _obscurePassword = !_obscurePassword), + onSubmit: _submit, + ), + ); + + void _selectMode(bool signUpMode) { + if (_logic.busy || _logic.signUpMode == signUpMode) return; + setState(() => _logic.toggleMode()); + } +} + +class _AuthEntryButton extends StatelessWidget { + const _AuthEntryButton({ + super.key, + required this.label, + required this.background, + required this.foreground, + required this.onTap, + this.icon, + this.leadingColor, + }); + + final String label; + final Color background; + final Color foreground; + final VoidCallback onTap; + final IconData? icon; + final Color? leadingColor; + + @override + Widget build(BuildContext context) { + return Material( + color: background, + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 44, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) + Icon(icon, size: 16, color: leadingColor ?? foreground), + if (icon != null) const SizedBox(width: 7), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/auth/auth_widgets.dart b/lib/pages/auth/auth_widgets.dart new file mode 100644 index 0000000..3934899 --- /dev/null +++ b/lib/pages/auth/auth_widgets.dart @@ -0,0 +1,185 @@ +// Author: fruit.com.cn + +part of 'auth_view.dart'; + +class _AuthCardState { + const _AuthCardState({ + required this.l10n, + required this.status, + required this.fields, + }); + + final AppLocalizations l10n; + final _AuthFormStatus status; + final _AuthFieldControllers fields; +} + +class _AuthFormStatus { + const _AuthFormStatus({ + required this.signUpMode, + required this.busy, + required this.errorMessage, + required this.fieldError, + required this.obscurePassword, + }); + + final bool signUpMode; + final bool busy; + final String? errorMessage; + final AuthFieldError? fieldError; + final bool obscurePassword; +} + +class _AuthFieldControllers { + const _AuthFieldControllers({ + required this.email, + required this.password, + required this.emailFocus, + required this.passwordFocus, + }); + + final TextEditingController email; + final TextEditingController password; + final FocusNode emailFocus; + final FocusNode passwordFocus; +} + +class _AuthCardCallbacks { + const _AuthCardCallbacks({ + required this.onModeSelected, + required this.onEmailChanged, + required this.onPasswordChanged, + required this.onTogglePasswordVisibility, + required this.onSubmit, + }); + + final ValueChanged onModeSelected; + final ValueChanged onEmailChanged; + final ValueChanged onPasswordChanged; + final VoidCallback onTogglePasswordVisibility; + final VoidCallback onSubmit; +} + +class _AuthCard extends StatelessWidget { + const _AuthCard({required this.state, required this.callbacks}); + + final _AuthCardState state; + final _AuthCardCallbacks callbacks; + + @override + Widget build(BuildContext context) { + final l10n = state.l10n; + final status = state.status; + return Container( + key: const Key('auth-card'), + padding: const EdgeInsets.all(_cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + border: Border.all( + color: AppColors.outlineVariantDark, + width: _cardBorderWidth, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthModeSwitch( + l10n: l10n, + signUpMode: status.signUpMode, + busy: status.busy, + onSelected: callbacks.onModeSelected, + ), + const SizedBox(height: 22), + Text( + status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 25, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + const SizedBox(height: 6), + Text( + status.signUpMode ? l10n.authSignUpSubtitle : l10n.authSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + height: 1.45, + ), + ), + const SizedBox(height: 22), + _AuthFields(state: state, callbacks: callbacks), + if (status.errorMessage != null) ...[ + const SizedBox(height: 14), + _AuthError(message: status.errorMessage!), + ], + const SizedBox(height: 22), + _AuthSubmitButton(state: state, onSubmit: callbacks.onSubmit), + ], + ), + ); + } +} + +class _AuthModeSwitch extends StatelessWidget { + const _AuthModeSwitch({ + required this.l10n, + required this.signUpMode, + required this.busy, + required this.onSelected, + }); + + final AppLocalizations l10n; + final bool signUpMode; + final bool busy; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return TextButton( + key: const Key('auth-switch-mode'), + onPressed: busy ? null : () => onSelected(!signUpMode), + style: TextButton.styleFrom( + minimumSize: const Size(0, _minTapTarget), + padding: EdgeInsets.zero, + overlayColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), + ), + child: Container( + height: _minTapTarget, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.bar, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: AppColors.outlineVariantDark), + ), + child: Row( + children: [ + Expanded( + child: _ModeSegment( + segmentKey: const Key('auth-mode-sign-in'), + label: l10n.signIn, + hiddenLabel: l10n.authSwitchToSignIn, + selected: !signUpMode, + onTap: busy ? null : () => onSelected(false), + ), + ), + const SizedBox(width: 4), + Expanded( + child: _ModeSegment( + segmentKey: const Key('auth-mode-sign-up'), + label: l10n.signUp, + hiddenLabel: l10n.authSwitchToSignUp, + selected: signUpMode, + onTap: busy ? null : () => onSelected(true), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index 27eab13..89024ce 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -7,9 +7,6 @@ import 'discover_logic.dart'; class DiscoverBinding { DiscoverLogic create({ required Drama drama, - WatchHistoryRepository? history, - }) => DiscoverLogic( - drama: drama, - history: history ?? SharedPreferencesWatchHistoryRepository(), - ); + required WatchHistoryRepository history, + }) => DiscoverLogic(drama: drama, history: history); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 37688e2..bff295a 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../l10n/app_localizations.dart'; @@ -16,10 +17,11 @@ class DiscoverLogic extends ChangeNotifier { final WatchHistoryRepository history; Drama drama; late Future position; + WatchProgress? resumeRecord; final TextEditingController search = TextEditingController(); final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); - int category = 0; + bool searchOpen = false; bool _disposed = false; void notify() { @@ -27,20 +29,61 @@ class DiscoverLogic extends ChangeNotifier { } void refreshPosition() { - position = history.positionFor( - demoProfileId, - drama.id, - drama.episodes.first.id, - ); + // 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看" + position = history + .resumePointFor(drama.id) + .then((point) => point?.position) + .catchError((Object _) => null); + refreshResume(); } - void selectCategory(int index) { - category = index; + Future refreshResume() async { + try { + final recent = await history.recent(limit: 1); + if (recent.isEmpty || recent.first.finished) { + resumeRecord = null; + } else { + resumeRecord = recent.first; + } + } catch (_) { + resumeRecord = null; + } notify(); } void onSearchChanged() => notify(); + List filtered(List dramas) { + final query = search.text.trim().toLowerCase(); + if (query.isEmpty) return dramas; + return dramas.where((drama) { + final haystack = [ + drama.title, + drama.genre, + drama.category, + drama.synopsis, + ].join(' ').toLowerCase(); + return haystack.contains(query); + }).toList(); + } + + Drama? resumeDrama(List dramas) { + final record = resumeRecord; + if (record == null) return null; + for (final drama in dramas) { + if (drama.id == record.dramaId) return drama; + } + return null; + } + + bool get showSearch => searchOpen || search.text.isNotEmpty; + + void openSearch() { + searchOpen = true; + notify(); + searchFocus.requestFocus(); + } + void syncDrama({ required bool active, required bool wasActive, @@ -56,6 +99,8 @@ class DiscoverLogic extends ChangeNotifier { notify(); } if (active && searchRequest != previousSearchRequest) { + searchOpen = true; + notify(); WidgetsBinding.instance.addPostFrameCallback((_) { if (isActive()) searchFocus.requestFocus(); }); @@ -94,7 +139,12 @@ class DiscoverLogic extends ChangeNotifier { } else if (embedded && onDramaSelected != null) { onDramaSelected(selected); } else { - act(context, DiscoverPageAction.detail, embedded: embedded, onAction: onAction); + act( + context, + DiscoverPageAction.detail, + embedded: embedded, + onAction: onAction, + ); } } @@ -112,41 +162,6 @@ class DiscoverLogic extends ChangeNotifier { ).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice))); } - void showFilters(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final labels = [ - l10n.featured, - l10n.suspense, - l10n.romance, - l10n.actionGenre, - ]; - showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFF202126), - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile(title: Text(l10n.filter)), - for (var index = 0; index < labels.length; index++) - ListTile( - title: Text(labels[index]), - trailing: index == category - ? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D)) - : null, - onTap: () { - selectCategory(index); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ); - } - @override void dispose() { _disposed = true; diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 9762b1c..656adcb 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -2,14 +2,25 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; -import '../../core/formatting/localized_content.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/category_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/empty_state.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; +part 'widgets/home_drama_grid.dart'; + class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, @@ -20,6 +31,10 @@ class DiscoverPage extends StatefulWidget { this.embedded = false, this.active = true, this.searchRequest = 0, + this.signedIn = false, + this.onSignIn, + this.onCoins, + this.onRewards, this.onAction, this.dramas, this.onDramaSelected, @@ -32,6 +47,10 @@ class DiscoverPage extends StatefulWidget { final bool embedded; final bool active; final int searchRequest; + final bool signedIn; + final VoidCallback? onSignIn; + final VoidCallback? onCoins; + final VoidCallback? onRewards; final ValueChanged? onAction; final List? dramas; final ValueChanged? onDramaSelected; @@ -48,8 +67,14 @@ class _DiscoverPageState extends State { super.initState(); logic = DiscoverBinding().create( drama: widget.drama, - history: widget.history, + history: widget.history ?? context.read(), ); + if (widget.focusSearch) { + logic.searchOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.active) logic.searchFocus.requestFocus(); + }); + } } @override @@ -82,53 +107,15 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final cards = _cardsFor(l10n, logic.category, logic.search.text); + final categories = context.watch(); + final allDramas = [...?widget.dramas, ...categories.visibleDramas]; + final visible = logic.filtered(categories.visibleDramas); final content = SafeArea( bottom: !widget.embedded, child: Column( children: [ - _topBar(l10n), - Expanded( - child: ListView( - controller: logic.scroll, - padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), - children: [ - _searchBar(l10n), - const SizedBox(height: 12), - _categories(l10n), - const SizedBox(height: 15), - _continueCard(l10n), - const SizedBox(height: 15), - _quickActions(l10n), - const SizedBox(height: 20), - _sectionTitle(l10n), - const SizedBox(height: 13), - if (cards.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - l10n.noSearchResults, - style: const TextStyle(color: Color(0xFFE6E1E3)), - ), - ) - else - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: cards.length, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 14, - childAspectRatio: 0.59, - ), - itemBuilder: (context, index) => - _posterCard(l10n, cards[index]), - ), - ], - ), - ), + _header(l10n), + Expanded(child: _feed(l10n, categories, allDramas, visible)), if (!widget.embedded) _bottomNav(l10n), ], ), @@ -136,563 +123,567 @@ class _DiscoverPageState extends State { return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Theme( - data: ThemeData.dark(useMaterial3: true), + data: AppTheme.dark(), child: widget.embedded ? content - : Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content), + : Scaffold(backgroundColor: AppColors.ink, body: content), ), ); } - Widget _topBar(AppLocalizations l10n) => SizedBox( - height: 51, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Container( - width: 29, - height: 29, - decoration: BoxDecoration( - color: _orange, - borderRadius: BorderRadius.circular(7), - ), - child: const Icon(Icons.play_arrow_rounded, color: Colors.white), + Widget _feed( + AppLocalizations l10n, + CategoryController categories, + List allDramas, + List visible, + ) => RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: () async { + await Future.wait([categories.load(), logic.refreshResume()]); + }, + child: CustomScrollView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _search()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), + child: _categories(l10n, categories), ), - const SizedBox(width: 7), - Text( - l10n.discoverTab, - style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800), - ), - const Spacer(), - IconButton( - tooltip: l10n.search, - onPressed: () => logic.searchFocus.requestFocus(), - icon: const Icon(Icons.search_rounded, size: 22), - ), - const SizedBox(width: 4), - InkWell( - onTap: () => logic.act( - context, - DiscoverPageAction.my, - embedded: widget.embedded, - onAction: widget.onAction, - ), - child: const CircleAvatar( - radius: 14, - backgroundColor: Color(0xFF34353B), - child: Icon(Icons.person_rounded, size: 19), - ), - ), - ], - ), - ), - ); - - Widget _searchBar(AppLocalizations l10n) => Container( - height: 43, - decoration: BoxDecoration( - color: const Color(0xFF25252A), - borderRadius: BorderRadius.circular(24), - ), - child: Row( - children: [ - const SizedBox(width: 11), - const Icon(Icons.search_rounded, color: Color(0xFFF2EEEF), size: 20), - const SizedBox(width: 7), - Expanded( - child: TextField( - controller: logic.search, - focusNode: logic.searchFocus, - autofocus: widget.focusSearch, - onChanged: (_) => logic.onSearchChanged(), - style: const TextStyle(fontSize: 12), - decoration: InputDecoration( - hintText: l10n.searchDiscover, - hintStyle: const TextStyle( - color: Color(0xFFE6E1E3), - fontSize: 12, + ), + if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _continueCard( + l10n, + logic.resumeDrama(allDramas)!, + logic.resumeRecord!, ), - border: InputBorder.none, - isDense: true, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _quickActions(l10n, categories), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), + child: Row( + children: [ + Container( + width: 3, + height: 18, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.trendingMicroDramas, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewSectionTitle, + fontWeight: FontWeight.w800, + ), + ), + ), + TextButton( + onPressed: () => + categories.selectCode(CategoryController.allCode), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + minimumSize: const Size(44, 44), + padding: EdgeInsets.zero, + ), + child: Text( + '${l10n.all} ›', + style: const TextStyle(fontSize: 10), + ), + ), + ], ), ), ), - IconButton( - tooltip: l10n.filter, - onPressed: () => logic.showFilters(context), - icon: const Icon(Icons.tune_rounded, size: 20), + _HomeDramaGrid( + l10n: l10n, + categories: categories, + dramas: visible, + searching: logic.search.text.trim().isNotEmpty, + onOpen: (drama) => logic.openCard( + context, + drama, + embedded: widget.embedded, + onDramaSelected: widget.onDramaSelected, + onAction: widget.onAction, + ), ), ], ), ); - Widget _categories(AppLocalizations l10n) { - final labels = [ - l10n.featured, - l10n.suspense, - l10n.romance, - l10n.actionGenre, - ]; + Widget _header(AppLocalizations l10n) { return SizedBox( - height: 30, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: labels.length, - separatorBuilder: (_, _) => const SizedBox(width: 7), - itemBuilder: (context, index) => ChoiceChip( - label: Text(labels[index]), - selected: logic.category == index, - onSelected: (_) => logic.selectCategory(index), - showCheckmark: false, - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 7), - backgroundColor: const Color(0xFF29272B), - selectedColor: _orange, - side: BorderSide.none, - labelStyle: TextStyle( - color: logic.category == index ? Colors.white : const Color(0xFFDDD5D7), - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } - - Widget _continueCard(AppLocalizations l10n) => FutureBuilder( - future: logic.position, - builder: (context, snapshot) { - final position = snapshot.data ?? Duration.zero; - final total = widget.duration > Duration.zero - ? widget.duration - : const Duration(seconds: 12); - final progress = (position.inMilliseconds / total.inMilliseconds).clamp( - 0.0, - 1.0, - ); - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF1C1D21), - borderRadius: BorderRadius.circular(12), - ), + height: 46, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(5), - child: SizedBox( - width: 55, - height: 69, - child: Stack( - fit: StackFit.expand, - children: [ - Image.asset(widget.drama.posterAsset, fit: BoxFit.cover), - const Positioned( - left: 0, - right: 0, - bottom: 0, - height: 12, - child: ColoredBox(color: Color(0xFF1C1D21)), - ), - ], - ), + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, ), ), - const SizedBox(width: 9), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.continueWatchingHeading, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFFFFA490), - fontSize: 9, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 3), - Text( - localizedDramaTitle(l10n, widget.drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - l10n.episodeProgress(1, widget.drama.episodes.length), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFFE6E1E3), - fontSize: 11, - ), - ), - const SizedBox(height: 7), - ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - value: progress, - minHeight: 3, - backgroundColor: const Color(0xFF474248), - color: _orange, - ), - ), - ], + const SizedBox(width: 8), + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, ), ), - const SizedBox(width: 9), - FilledButton.icon( - onPressed: () => logic.act( - context, - DiscoverPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFFFFBD5C), - foregroundColor: const Color(0xFF2A160B), - padding: const EdgeInsets.symmetric(horizontal: 10), - minimumSize: const Size(0, 32), - textStyle: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: logic.openSearch, + ), + const SizedBox(width: 8), + Semantics( + button: true, + label: widget.signedIn ? l10n.myTab : l10n.logIn, + child: InkWell( + key: const Key('discover-profile'), + customBorder: const CircleBorder(), + onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 18, + ), ), ), - icon: const Icon(Icons.play_arrow_rounded, size: 14), - label: Text(l10n.resume), ), ], ), - ); - }, - ); + ), + ); + } - Widget _quickActions(AppLocalizations l10n) { - final actions = [ - (Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)), - (Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)), - ( - Icons.auto_awesome_rounded, - l10n.newReleases, - () => logic.catalogInfo(context), - ), - ( - Icons.bookmark_outline_rounded, - l10n.watchlist, - () { - logic.act( - context, - DiscoverPageAction.watchlist, - embedded: widget.embedded, - onAction: widget.onAction, - ); - }, + Widget _search() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: SizedBox( + height: 36, + child: TextField( + key: const Key('discover-search'), + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + onTapOutside: (_) => logic.searchFocus.unfocus(), + textInputAction: TextInputAction.search, + style: const TextStyle(color: AppColors.onSurface, fontSize: 13), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: AppColors.input, + hintText: AppLocalizations.of(context)!.searchDiscover, + hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.subtle, + size: 17, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(color: AppColors.blue, width: 1), + ), + ), + ), ), + ); + } + + Widget _categories(AppLocalizations l10n, CategoryController categories) { + final choices = <({String code, String label})>[ + (code: CategoryController.allCode, label: l10n.recommended), + for (final category in categories.categories) + (code: 'category-${category.id}', label: category.name), ]; - return Row( - children: [ - for (var index = 0; index < actions.length; index++) ...[ - Expanded( - child: InkWell( - onTap: actions[index].$3, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 77, - padding: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - color: const Color(0xFF202126), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircleAvatar( - radius: 16, - backgroundColor: const Color(0xFF333036), - child: Icon( - actions[index].$1, - size: 19, - color: index == 1 - ? const Color(0xFFFFC15E) - : const Color(0xFFFFAB96), - ), + return SizedBox( + key: const Key('home-category-chips'), + height: AppDimens.tapTarget, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 14), + scrollDirection: Axis.horizontal, + itemCount: choices.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final choice = choices[index]; + final selected = categories.selectedCode == choice.code; + return Semantics( + selected: selected, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => categories.selectCode(choice.code), + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 15), + decoration: BoxDecoration( + color: selected ? AppColors.blue : const Color(0xFF29272B), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: selected ? AppColors.blue : Colors.transparent, ), - const SizedBox(height: 7), - Text( - actions[index].$2, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - ), + ), + child: Text( + choice.label, + maxLines: 1, + style: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: AppDimens.chipFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + height: 1.1, ), - ], + ), ), ), ), + ); + }, + ), + ); + } + + Widget _continueCard( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { + final progress = record.fraction; + return Container( + key: const Key('discover-continue-watching'), + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 52, + height: 66, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), + ), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.continueWatching, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.accent, + fontSize: 8, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + _resumeShort(drama, record), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: progress <= 0 ? null : progress, + minHeight: 3, + backgroundColor: const Color(0xFF474248), + valueColor: const AlwaysStoppedAnimation(AppColors.blue), + ), + ), + ], + ), + ), + const SizedBox(width: 10), + FilledButton( + key: const Key('discover-continue-play'), + onPressed: () => logic.openCard( + context, + drama, + embedded: widget.embedded, + onDramaSelected: widget.onDramaSelected, + onAction: widget.onAction, + ), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + minimumSize: const Size(56, 28), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + child: Text(l10n.resume), ), - if (index < actions.length - 1) const SizedBox(width: 7), ], + ), + ); + } + + String _resumeShort(Drama drama, WatchProgress record) { + final label = AppLocalizations.of(context)!; + Episode? episode; + for (final item in drama.episodes) { + if (item.id == record.episodeId) { + episode = item; + break; + } + } + if (episode == null) return _duration(record.position); + return label.episodeOfTotal(episode.number, drama.episodeCount); + } + + Widget _quickActions(AppLocalizations l10n, CategoryController categories) { + return Row( + children: [ + Expanded( + child: _QuickAction( + key: const Key('discover-quick-filter'), + icon: Icons.tune_rounded, + label: l10n.filter, + color: AppColors.secondary, + onTap: logic.openSearch, + ), + ), + const SizedBox(width: 7), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-charts'), + icon: Icons.local_fire_department_rounded, + label: l10n.topCharts, + color: AppColors.blue, + onTap: () => categories.selectCode(CategoryController.hotCode), + ), + ), + const SizedBox(width: 7), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-new'), + icon: Icons.auto_awesome_rounded, + label: l10n.newReleases, + color: AppColors.gold, + onTap: () => categories.selectCode(CategoryController.newCode), + ), + ), + const SizedBox(width: 7), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-watchlist'), + icon: Icons.bookmark_border_rounded, + label: l10n.watchlist, + color: AppColors.success, + onTap: () => logic.act( + context, + DiscoverPageAction.watchlist, + embedded: widget.embedded, + onAction: widget.onAction, + ), + ), + ), ], ); } - Widget _sectionTitle(AppLocalizations l10n) => Row( - children: [ - Container(width: 3, height: 20, color: _orange), - const SizedBox(width: 7), - Expanded( - child: Text( - l10n.trendingMicroDramas, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800), + Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( + items: [ + AppBottomNavItem( + tabKey: const Key('discover-nav-home'), + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, + label: l10n.homeTab, + onTap: () => logic.act( + context, + DiscoverPageAction.home, + embedded: widget.embedded, + onAction: widget.onAction, ), ), - TextButton( - onPressed: () => logic.selectCategory(0), - child: Text( - l10n.seeAll, - style: const TextStyle(color: Color(0xFFFFB3A1)), + AppBottomNavItem( + tabKey: const Key('discover-nav-current'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: l10n.discoverTab, + selected: true, + onTap: () {}, + ), + AppBottomNavItem( + tabKey: const Key('discover-nav-my'), + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, + label: l10n.myTab, + onTap: () => logic.act( + context, + DiscoverPageAction.my, + embedded: widget.embedded, + onAction: widget.onAction, ), ), ], ); +} - Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) { - final playable = - logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null; - return InkWell( - onTap: () => logic.openCard( - context, - logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]), - embedded: widget.embedded, - onDramaSelected: widget.onDramaSelected, - onAction: widget.onAction, - ), - borderRadius: BorderRadius.circular(11), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - Image.asset(card.image, fit: BoxFit.cover), - const Positioned( - left: 0, - right: 0, - bottom: 0, - height: 37, - child: ColoredBox(color: Color(0xFF101116)), - ), - Positioned( - top: 6, - left: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 3, - ), - decoration: BoxDecoration( - color: playable ? _orange : const Color(0xDD37343B), - borderRadius: BorderRadius.circular(7), - ), - child: Text( - playable ? l10n.free : l10n.artworkOnly, - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - Positioned( - bottom: 10, - left: 8, - right: 8, - child: Text( - playable ? l10n.freeTestMedia : l10n.videoPending, - style: const TextStyle( - color: Color(0xFFFFC789), - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 6), - Text( - card.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 3), - Text( - card.genre, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11), - ), - ], +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), ), ); } - - Widget _bottomNav(AppLocalizations l10n) => Container( - height: 63, - decoration: const BoxDecoration( - color: Color(0xFF0D0D11), - border: Border(top: BorderSide(color: Color(0xFF242329))), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _DiscoverNavItem( - icon: Icons.play_circle_outline_rounded, - label: l10n.homeTab, - onTap: () => logic.act( - context, - DiscoverPageAction.home, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - _DiscoverNavItem( - icon: Icons.explore_outlined, - label: l10n.discoverTab, - selected: true, - onTap: () {}, - ), - _DiscoverNavItem( - icon: Icons.video_library_outlined, - label: l10n.myTab, - onTap: () => logic.act( - context, - DiscoverPageAction.my, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - ], - ), - ); } -class _DiscoverNavItem extends StatelessWidget { - const _DiscoverNavItem({ +class _QuickAction extends StatelessWidget { + const _QuickAction({ + super.key, required this.icon, required this.label, + required this.color, required this.onTap, - this.selected = false, }); final IconData icon; final String label; + final Color color; final VoidCallback onTap; - final bool selected; @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - child: SizedBox( - width: 72, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 21, - color: selected ? _orange : const Color(0xFFCAC4C7), + Widget build(BuildContext context) { + return Material( + color: AppColors.quick, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Container( + height: 62, + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(height: 4), + Text( + label, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 9, + fontWeight: FontWeight.w500, + height: 1.05, + ), + ), + ], ), - Text( - label, - style: TextStyle( - color: selected ? _orange : const Color(0xFFCAC4C7), - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - ], + ), ), - ), - ); + ); + } } -class _ShowcaseCard { - const _ShowcaseCard({ - required this.title, - required this.genre, - required this.image, - required this.category, - this.dramaId, - }); - - final String title; - final String genre; - final String image; - final int category; - final String? dramaId; +String _duration(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; } - -List<_ShowcaseCard> _cardsFor( - AppLocalizations l10n, - int category, - String query, -) { - final cards = [ - _ShowcaseCard( - title: l10n.dramaSignalTitle, - genre: l10n.genreSuspenseMystery, - image: 'assets/images/stitch_signal_poster_clean.png', - category: 1, - dramaId: 'the-signal-demo', - ), - _ShowcaseCard( - title: l10n.dramaCityTitle, - genre: l10n.genreUrbanRomance, - image: 'assets/images/stitch_city_poster_clean.png', - category: 2, - dramaId: 'city-lights-demo', - ), - _ShowcaseCard( - title: l10n.dramaVengeanceTitle, - genre: l10n.genreActionThriller, - image: 'assets/images/stitch_vengeance_poster_clean.png', - category: 3, - dramaId: 'vengeance-demo', - ), - _ShowcaseCard( - title: l10n.dramaEchoesTitle, - genre: l10n.genreDramaMystery, - image: 'assets/images/stitch_rain_scene.jpg', - category: 1, - ), - ]; - final normalized = query.trim().toLowerCase(); - return cards.where((card) { - final categoryMatches = category == 0 || card.category == category; - final queryMatches = - normalized.isEmpty || - card.title.toLowerCase().contains(normalized) || - card.genre.toLowerCase().contains(normalized); - return categoryMatches && queryMatches; - }).toList(); -} - -const _orange = Color(0xFFFF5B3D); diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart new file mode 100644 index 0000000..d2bd04c --- /dev/null +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -0,0 +1,219 @@ +// Author: fruit.com.cn + +part of '../discover_view.dart'; + +class _HomeDramaGrid extends StatelessWidget { + const _HomeDramaGrid({ + required this.l10n, + required this.categories, + required this.dramas, + required this.searching, + required this.onOpen, + }); + + final AppLocalizations l10n; + final CategoryController categories; + final List dramas; + final bool searching; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + if (categories.loading && categories.feed.sections.isEmpty) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + if (categories.error != null) { + return _fill( + EmptyState( + key: const Key('home-categories-error'), + icon: Icons.cloud_off_rounded, + title: l10n.categoriesError, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: categories.load, + ), + ); + } + if (dramas.isEmpty) { + return _fill( + EmptyState( + key: const Key('home-all-dramas-empty'), + icon: searching + ? Icons.search_off_rounded + : Icons.movie_filter_outlined, + title: searching + ? l10n.noSearchResults + : categories.selectedSectionMissing + ? l10n.noCategoryDramas + : l10n.noSearchResults, + subtitle: l10n.emptyHint, + ), + ); + } + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter(child: _masonry(dramas)), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + } + + Widget _masonry(List items) { + return Padding( + key: const Key('home-all-dramas-section'), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + key: const Key('home-all-dramas-grid'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _column(items, 0)), + const SizedBox(width: 10), + Expanded(child: _column(items, 1)), + ], + ), + ); + } + + Widget _column(List items, int start) { + final children = []; + for (var index = start; index < items.length; index += 2) { + children.add( + _HomeDramaCard( + l10n: l10n, + drama: items[index], + onTap: () => onOpen(items[index]), + ), + ); + if (index + 2 < items.length) { + children.add(const SizedBox(height: 10)); + } + } + return Column(children: children); + } + + Widget _fill(Widget child) => + SliverFillRemaining(hasScrollBody: false, child: child); +} + +class _HomeDramaCard extends StatelessWidget { + const _HomeDramaCard({ + required this.l10n, + required this.drama, + required this.onTap, + }); + + final AppLocalizations l10n; + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; + final free = + drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); + return InkWell( + key: Key('home-drama-card-${drama.id}'), + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + key: Key('home-drama-cover-${drama.id}'), + borderRadius: BorderRadius.circular(10), + child: Stack( + children: [ + _DiscoverCoverImage(path: drama.posterUrl), + if (free) + Positioned( + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + l10n.free, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 8, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 5), + Text( + drama.title, + key: Key('home-drama-title-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + genre.isEmpty ? l10n.videoPending : genre, + key: Key('home-drama-genre-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ), + ); + } +} + +class _DiscoverCoverImage extends StatelessWidget { + const _DiscoverCoverImage({required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + final url = AppConfig.mediaUrl(path); + if (url.isEmpty) return _placeholder(); + return Image.network( + url, + width: double.infinity, + fit: BoxFit.fitWidth, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _placeholder(); + }, + errorBuilder: (_, _, _) => _placeholder(), + ); + } + + Widget _placeholder() { + return AspectRatio( + aspectRatio: 3 / 4, + child: ColoredBox( + color: AppColors.card, + child: const Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.muted, + size: 32, + ), + ), + ), + ); + } +} diff --git a/lib/pages/downloads/downloads_binding.dart b/lib/pages/downloads/downloads_binding.dart new file mode 100644 index 0000000..4448e47 --- /dev/null +++ b/lib/pages/downloads/downloads_binding.dart @@ -0,0 +1,9 @@ +// Author: fruit.com.cn + +import '../../core/data/services/download_controller.dart'; + +/// 下载管理页依赖装配。 +class DownloadsBinding { + /// 从服务端刷新策略与授权列表。 + Future load(DownloadController downloads) => downloads.load(); +} diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart new file mode 100644 index 0000000..ba7aff8 --- /dev/null +++ b/lib/pages/downloads/downloads_page.dart @@ -0,0 +1,404 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/download_license.dart'; +import '../../core/data/models/download_policy.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_date_time.dart'; +import '../../core/platform/haptics.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import 'downloads_binding.dart'; + +/// 下载授权与状态管理页;本批不落盘,只管理服务端授权。 +class DownloadsPage extends StatefulWidget { + const DownloadsPage({super.key}); + + @override + State createState() => _DownloadsPageState(); +} + +class _DownloadsPageState extends State { + bool _requested = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final signedIn = context.watch().isSignedIn; + if (!signedIn) { + _requested = false; + return; + } + if (_requested) return; + _requested = true; + final downloads = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(DownloadsBinding().load(downloads)); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('downloads-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: Colors.white, + title: Text(l10n.downloads), + ), + body: SafeArea( + top: false, + child: signedIn + ? _DownloadsBody(downloads: context.watch()) + : const AuthGatePanel(), + ), + ), + ); + } +} + +class _DownloadsBody extends StatelessWidget { + const _DownloadsBody({required this.downloads}); + + final DownloadController downloads; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (downloads.loading && downloads.policy == null) { + return const Center(child: CircularProgressIndicator()); + } + if (downloads.policy == null) { + return EmptyState( + key: const Key('downloads-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, downloads.error, l10n.downloadsError), + actionLabel: l10n.retry, + actionKey: const Key('downloads-retry'), + onAction: () => unawaited(downloads.load()), + ); + } + final policy = downloads.policy!; + return RefreshIndicator( + onRefresh: downloads.load, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), + children: [ + _PolicyPanel(downloads: downloads, policy: policy), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: Text( + l10n.downloadedItems, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + if (downloads.licenses.isNotEmpty) + TextButton.icon( + key: const Key('download-clear'), + onPressed: downloads.clearing + ? null + : () => _confirmClear(context), + icon: const Icon(Icons.delete_sweep_outlined), + label: Text(l10n.clearAll), + ), + ], + ), + if (downloads.licenses.isEmpty) + EmptyState( + key: const Key('downloads-empty'), + icon: Icons.download_outlined, + title: l10n.noDownloads, + subtitle: l10n.emptyHint, + ) + else + for (final license in downloads.licenses) ...[ + _LicenseTile( + license: license, + onRemove: () => _remove(context, license.episodeId), + ), + const SizedBox(height: 10), + ], + ], + ), + ); + } + + Future _remove(BuildContext context, String episodeId) async { + unawaited(context.read().mediumImpact()); + final ok = await downloads.remove(episodeId); + if (ok || !context.mounted) return; + _showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed); + } + + Future _confirmClear(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.clearDownloadsTitle), + content: Text(l10n.clearDownloadsMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.confirmClear), + ), + ], + ), + ); + if (confirmed != true) return; + if (context.mounted) { + unawaited(context.read().mediumImpact()); + } + final ok = await downloads.clear(); + if (!ok && context.mounted) { + _showMessage(context, l10n.downloadClearFailed); + } + } + + void _showMessage(BuildContext context, String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _PolicyPanel extends StatelessWidget { + const _PolicyPanel({required this.downloads, required this.policy}); + + final DownloadController downloads; + final DownloadPolicy policy; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('download-policy'), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + border: Border.all(color: AppColors.outlineVariantDark), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.downloadQuota(policy.remaining, policy.maxPerDevice), + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 5), + Text( + l10n.downloadLicenseDays(policy.licenseDays), + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + const SizedBox(height: 6), + Text( + policy.expirePolicyText, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + const Divider(height: 22, color: AppColors.outlineVariantDark), + Row( + children: [ + Expanded( + child: Text( + l10n.downloadWifiOnly, + style: const TextStyle( + color: Colors.white, + fontSize: AppDimens.body, + ), + ), + ), + Switch( + key: const Key('download-wifi-switch'), + value: policy.wifiOnly, + onChanged: (value) => _update(context, wifiOnly: value), + ), + ], + ), + Row( + children: [ + Expanded( + child: Text( + l10n.downloadQuality, + style: const TextStyle( + color: Colors.white, + fontSize: AppDimens.body, + ), + ), + ), + DropdownButton( + key: const Key('download-quality'), + value: + _qualityChoices( + policy.maxQuality, + ).contains(policy.maxQuality) + ? policy.maxQuality + : 'AUTO', + underline: const SizedBox.shrink(), + items: [ + for (final quality in _qualityChoices(policy.maxQuality)) + DropdownMenuItem(value: quality, child: Text(quality)), + ], + onChanged: (value) { + if (value == null || value == policy.maxQuality) return; + _update(context, maxQuality: value); + }, + ), + ], + ), + ], + ), + ); + } + + Future _update( + BuildContext context, { + bool? wifiOnly, + String? maxQuality, + }) async { + final ok = await downloads.updateSettings( + wifiOnly: wifiOnly, + maxQuality: maxQuality, + ); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.downloadSettingsFailed), + ), + ); + } +} + +class _LicenseTile extends StatelessWidget { + const _LicenseTile({required this.license, required this.onRemove}); + + final DownloadLicense license; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: ValueKey('download-license-${license.episodeId}'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + license.dramaTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + Text(l10n.episodeLabel(license.episodeNo)), + IconButton( + key: ValueKey('download-remove-${license.episodeId}'), + tooltip: l10n.remove, + onPressed: onRemove, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + Wrap( + spacing: 12, + runSpacing: 5, + children: [ + Text( + l10n.downloadStatus(license.status), + style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), + ), + Text( + l10n.downloadTaskStatus(license.taskStatus), + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + Text( + l10n.downloadProgress(license.progressPercent), + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + if (license.quality.isNotEmpty) + Text( + license.quality, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.progressRadius), + child: LinearProgressIndicator( + minHeight: AppDimens.progressHeight, + value: (license.progressPercent / 100).clamp(0.0, 1.0), + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), + ), + ), + if (license.expireAt != null) ...[ + const SizedBox(height: 7), + Text( + l10n.downloadExpiresAt( + formatLocalizedDateTime(context, license.expireAt!), + ), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ], + ), + ); + } +} + +List _qualityChoices(String current) { + final choices = ['AUTO']; + if (current.isNotEmpty && current != 'AUTO') choices.add(current); + for (final value in const ['720P', '1080P']) { + if (!choices.contains(value)) choices.add(value); + } + return choices; +} diff --git a/lib/pages/drama_detail/drama_detail_binding.dart b/lib/pages/drama_detail/drama_detail_binding.dart index a669da5..060dda3 100644 --- a/lib/pages/drama_detail/drama_detail_binding.dart +++ b/lib/pages/drama_detail/drama_detail_binding.dart @@ -1,15 +1,21 @@ // Author: fruit.com.cn import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/favorite_controller.dart'; import 'drama_detail_logic.dart'; class DramaDetailBinding { DramaDetailLogic create({ required Drama drama, - WatchHistoryRepository? history, + required WatchHistoryRepository history, + required PlaybackRepository playback, + required FavoriteController favorites, }) => DramaDetailLogic( drama: drama, - history: history ?? SharedPreferencesWatchHistoryRepository(), + history: history, + playback: playback, + favorites: favorites, ); } diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 9f3e69b..128dc7f 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -1,37 +1,106 @@ // Author: fruit.com.cn +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; +import '../../core/data/remote/api_client.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/repositories/playback_repository.dart'; +import '../../core/data/services/favorite_controller.dart'; import '../player/player_view.dart'; class DramaDetailLogic extends ChangeNotifier { - DramaDetailLogic({required this.drama, required this.history}); + DramaDetailLogic({ + required this.drama, + required this.history, + required this.playback, + required this.favorites, + }); - final Drama drama; + /// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。 + Drama drama; final WatchHistoryRepository history; + final PlaybackRepository playback; + final FavoriteController favorites; + bool loading = false; + bool failed = false; + + /// 接口给出的失败原因;为空时界面回退到本地文案。 + String? failureMessage; int? continueIndex; + WatchProgress? progress; + String? progressError; bool _disposed = false; void notify() { if (!_disposed) notifyListeners(); } + /// 列表卡片只带一集可播预览,进详情时补一次完整剧集列表。 + Future load(Future Function() fetch) async { + if (drama.detailLoaded) { + await loadContinue(); + return; + } + loading = true; + failed = false; + failureMessage = null; + notify(); + try { + drama = await fetch(); + await loadContinue(); + } on ApiException catch (exception) { + failed = true; + failureMessage = exception.message; + } on DioException { + // 断网/超时:交给 l10n 文案,不把 HTTP 细节丢给用户 + failed = true; + failureMessage = 'networkError'; + } catch (_) { + failed = true; + } finally { + loading = false; + notify(); + } + } + Future loadContinue() async { try { - final id = await history.lastEpisodeId(demoProfileId, drama.id); - if (id == null || _disposed) return; - final index = drama.episodes.indexWhere((episode) => episode.id == id); - if (index >= 0 && drama.episodes[index].isFree) { - continueIndex = index; - notify(); - } - } catch (_) { - // Keep the first episode available if preferences cannot be read. + final record = await history.progressFor(drama.id); + if (_disposed) return; + progress = record; + progressError = null; + continueIndex = record == null + ? null + : _indexOf(record.episodeId) ?? continueIndex; + notify(); + } catch (exception) { + if (_disposed) return; + // 拿不到续看位置不影响看第一集,但要让用户知道(可重试) + progressError = exception is ApiException + ? exception.message + : 'progressError'; + notify(); } } + int? _indexOf(String episodeId) { + final index = drama.episodes.indexWhere( + (episode) => episode.id == episodeId, + ); + return index >= 0 ? index : null; + } + + /// 该集的续看进度(0~1),用于剧集行上的进度条;没有记录返回 null。 + double? progressFractionFor(String episodeId) { + final record = progress; + if (record == null || record.episodeId != episodeId) return null; + if (!record.hasPosition) return null; + return record.fraction; + } + Future openPlayer(BuildContext context, int index) async { await Navigator.of(context).push( MaterialPageRoute( @@ -41,6 +110,9 @@ class DramaDetailLogic extends ChangeNotifier { await loadContinue(); } + /// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。 + Future toggleFavorite() => favorites.toggle(drama.id, card: drama); + @override void dispose() { _disposed = true; diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index a0e6e27..952dda7 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -1,15 +1,40 @@ // 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/repositories/playback_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/platform_share_service.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/platform/haptics.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/download_action.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; +import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; +const String _menuFavorite = 'favorite'; +const String _menuDownload = 'download'; +const String _menuShare = 'share'; +const String _menuReport = 'report'; + class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); + final Drama drama; @override @@ -22,8 +47,21 @@ class _DramaDetailPageState extends State { @override void initState() { super.initState(); - logic = DramaDetailBinding().create(drama: widget.drama); - logic.loadContinue(); + logic = DramaDetailBinding().create( + drama: widget.drama, + history: context.read(), + playback: context.read(), + favorites: context.read(), + ); + _load(); + } + + void _load() { + unawaited( + logic.load( + () => context.read().requireDetail(widget.drama), + ), + ); } @override @@ -34,101 +72,538 @@ class _DramaDetailPageState extends State { @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: logic, - builder: (context, _) => _page(context), + return Theme( + data: AppTheme.dark(), + child: ListenableBuilder( + listenable: logic, + builder: (context, _) => _page(context), + ), ); } Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; + if (logic.loading) return _loading(l10n); + if (logic.failed) return _failed(l10n); final drama = logic.drama; + if (drama.episodes.isEmpty) return _empty(l10n, drama); + return Scaffold( + backgroundColor: AppColors.ink, body: CustomScrollView( + key: const Key('detail-scroll'), slivers: [ - SliverAppBar( - expandedHeight: 300, - pinned: true, - backgroundColor: const Color(0xFF111111), - foregroundColor: Colors.white, - flexibleSpace: FlexibleSpaceBar( - background: Image.asset(drama.posterAsset, fit: BoxFit.cover), + SliverToBoxAdapter(child: _hero(context, l10n, drama)), + SliverToBoxAdapter(child: _content(context, l10n, drama)), + const SliverToBoxAdapter(child: SizedBox(height: 28)), + ], + ), + ); + } + + Widget _loading(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: const Center(child: CircularProgressIndicator()), + ); + + Widget _failed(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + errorText(l10n, logic.failureMessage, l10n.detailError), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted), + ), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: Text(l10n.retry)), + ], + ), + ), + ), + ); + + Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, drama)), + ), + body: EmptyState( + key: const Key('drama-detail-empty'), + icon: Icons.video_library_outlined, + title: l10n.noEpisodesYet, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: _load, + ), + ); + + Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { + return SizedBox( + key: const Key('detail-hero'), + height: 190, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x33000000), Color(0xCC0B0C10)], + ), ), ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Text( - l10n.technicalDemoCount(drama.episodes.length), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - letterSpacing: 1.4, - color: const Color(0xFF9278EA), - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - localizedDramaTitle(l10n, drama), - style: Theme.of(context).textTheme.headlineLarge?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 10), - Text( - localizedDramaSynopsis(l10n, drama), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: () => - logic.openPlayer(context, logic.continueIndex ?? 0), - icon: const Icon(Icons.play_arrow_rounded), - label: Text( - logic.continueIndex == null - ? l10n.watchEpisodeOne - : l10n.continueEpisode(logic.continueIndex! + 1), - ), - ), - const SizedBox(height: 30), - Text( - l10n.episodes, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ]), + Positioned( + top: MediaQuery.paddingOf(context).top + 14, + left: 14, + child: _RoundAction( + key: const Key('detail-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + onPressed: () => Navigator.of(context).maybePop(), ), ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 40), - sliver: SliverList.builder( - itemCount: drama.episodes.length, - itemBuilder: (context, index) { - final episode = drama.episodes[index]; - return Card( - margin: const EdgeInsets.only(bottom: 10), - child: ListTile( - minTileHeight: 76, - leading: CircleAvatar( - backgroundColor: const Color( - 0xFFFF6B45, - ).withValues(alpha: 0.18), - child: Text('${episode.number}'), - ), - title: Text(l10n.episodeLabel(episode.number)), - subtitle: Text(l10n.freeTestMedia), - trailing: const Icon(Icons.play_circle_outline_rounded), - onTap: () => logic.openPlayer(context, index), - ), - ); - }, - ), + Positioned( + top: MediaQuery.paddingOf(context).top + 14, + right: 14, + child: _moreMenu(context, l10n), ), ], ), ); } + + Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) { + final continueIndex = logic.continueIndex; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.episodeCount(drama.episodeCount), + key: const Key('detail-episode-count'), + style: const TextStyle( + color: AppColors.secondary, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 1, + ), + ), + const SizedBox(height: 7), + Text( + localizedDramaTitle(l10n, drama), + key: const Key('detail-title'), + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + const SizedBox(height: 10), + Text( + localizedDramaSynopsis(l10n, drama), + key: const Key('detail-synopsis'), + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + height: 1.6, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 40, + child: FilledButton.icon( + key: const Key('detail-cta'), + onPressed: () => logic.openPlayer(context, continueIndex ?? 0), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDimens.buttonRadius), + ), + textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text( + continueIndex == null + ? l10n.startWatching + : l10n.continueEpisode(continueIndex + 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (_continueFraction != null) ...[ + const SizedBox(height: 8), + _ResumeBar( + fraction: _continueFraction!, + label: l10n.resumeProgress( + _clock(logic.progress!.position), + _clock(logic.progress!.duration), + ), + ), + ], + if (logic.progressError != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + errorText(l10n, logic.progressError, l10n.progressError), + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ), + TextButton( + onPressed: () => unawaited(logic.loadContinue()), + child: Text(l10n.retry), + ), + ], + ), + ], + const SizedBox(height: 18), + Text( + l10n.episodes, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + final sixColumnsWidth = (constraints.maxWidth - 5 * 8) / 6; + final columns = sixColumnsWidth >= 40 ? 6 : 5; + final selectedIndex = continueIndex ?? 0; + final selectedNumber = drama.episodes.isEmpty + ? null + : drama.episodes[selectedIndex].number; + final ordered = [ + for (var index = 0; index < drama.episodes.length; index++) + (index: index, episode: drama.episodes[index]), + ]..sort((a, b) => a.episode.number.compareTo(b.episode.number)); + return GridView.builder( + key: const Key('detail-episode-chips'), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: ordered.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.35, + ), + itemBuilder: (context, position) { + final item = ordered[position]; + return _episodeChip( + context, + item.episode, + item.index, + selectedNumber, + ); + }, + ); + }, + ), + ], + ), + ); + } + + Widget _episodeChip( + BuildContext context, + Episode episode, + int index, + int? selectedNumber, + ) { + final selected = episode.number == selectedNumber; + final locked = episode.locked || (!episode.isFree && index > 0); + return InkWell( + key: Key('detail-episode-chip-${episode.number}'), + onTap: () => logic.openPlayer(context, index), + borderRadius: BorderRadius.circular(9), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.16) + : const Color(0xFF29272B), + borderRadius: BorderRadius.circular(9), + ), + child: Text( + '${episode.number}', + style: TextStyle( + color: selected + ? AppColors.onPrimary + : locked + ? AppColors.secondary + : AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + Widget _moreMenu(BuildContext context, AppLocalizations l10n) { + final saved = context.watch().isSaved(logic.drama.id); + return PopupMenuButton( + key: const Key('detail-more'), + tooltip: l10n.more, + color: AppColors.card, + onSelected: (value) { + switch (value) { + case _menuFavorite: + unawaited(_toggleFavorite(l10n)); + case _menuDownload: + _downloadCurrent(); + case _menuShare: + unawaited(_shareDrama(l10n, logic.drama)); + case _menuReport: + unawaited(_reportDrama(l10n)); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + key: const Key('detail-favorite'), + value: _menuFavorite, + child: Row( + children: [ + Icon( + saved ? Icons.star_rounded : Icons.star_border_rounded, + color: saved ? AppColors.blue : AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.favorite), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-download'), + value: _menuDownload, + child: Row( + children: [ + const Icon( + Icons.download_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.downloads), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-share'), + value: _menuShare, + child: Row( + children: [ + const Icon( + Icons.share_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.share), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-report'), + value: _menuReport, + child: Row( + children: [ + const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18), + const SizedBox(width: 9), + Text(l10n.reportDrama), + ], + ), + ), + ], + child: Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.more_horiz_rounded, + size: 18, + color: AppColors.onSurface, + ), + ), + ); + } + + Future _shareDrama(AppLocalizations l10n, Drama drama) async { + try { + await const PlatformShareService().shareDrama( + title: localizedDramaTitle(l10n, drama), + synopsis: localizedDramaSynopsis(l10n, drama), + footer: l10n.shareFooter, + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.shareFailed))); + } + } + + Future _toggleFavorite(AppLocalizations l10n) async { + if (!await ensureSignedIn(context)) return; + if (!mounted) return; + unawaited(context.read().lightImpact()); + final ok = await logic.toggleFavorite(); + if (ok || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + } + + void _downloadCurrent() { + final episodes = logic.drama.episodes; + if (episodes.isEmpty) return; + final index = (logic.continueIndex ?? 0).clamp(0, episodes.length - 1); + requestEpisodeDownload(context, episodes[index].id); + } + + Future _reportDrama(AppLocalizations l10n) async { + if (!await ensureSignedIn(context) || !mounted) return; + final draft = await showReportDialog(context, title: l10n.reportDrama); + if (draft == null || !mounted) return; + unawaited(context.read().lightImpact()); + final result = await context.read().reportDrama( + logic.drama.id, + reason: draft.reason, + description: draft.description, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.isSuccess + ? l10n.reportSubmitted + : result.errorMessage ?? l10n.reportFailed, + ), + ), + ); + } + + double? get _continueFraction { + final record = logic.progress; + if (record == null || !record.hasPosition) return null; + if (logic.continueIndex == null) return null; + return record.fraction; + } + + String _clock(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return duration.inHours > 0 + ? '${duration.inHours}:$minutes:$seconds' + : '$minutes:$seconds'; + } +} + +class _RoundAction extends StatelessWidget { + const _RoundAction({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: IconButton( + tooltip: tooltip.isEmpty ? null : tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon(icon, size: 18, color: AppColors.onSurface), + ), + ); + } +} + +class _ResumeBar extends StatelessWidget { + const _ResumeBar({required this.fraction, this.label}); + + final double fraction; + final String? label; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: fraction, + minHeight: 3, + backgroundColor: const Color(0xFF474248), + valueColor: const AlwaysStoppedAnimation(AppColors.blue), + ), + ), + if (label != null) ...[ + const SizedBox(height: 4), + Text( + label!, + style: const TextStyle(color: AppColors.muted, fontSize: 10), + ), + ], + ], + ); + } } diff --git a/lib/pages/favorites/favorites_binding.dart b/lib/pages/favorites/favorites_binding.dart new file mode 100644 index 0000000..720b097 --- /dev/null +++ b/lib/pages/favorites/favorites_binding.dart @@ -0,0 +1,9 @@ +// Author: fruit.com.cn + +import '../../core/data/services/favorite_controller.dart'; + +/// 收藏页依赖装配。 +class FavoritesBinding { + /// 从服务端刷新收藏列表。 + Future load(FavoriteController favorites) => favorites.load(); +} diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart new file mode 100644 index 0000000..d1626fb --- /dev/null +++ b/lib/pages/favorites/favorites_page.dart @@ -0,0 +1,144 @@ +// 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/auth_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/platform/haptics.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/library_drama_tile.dart'; +import '../../l10n/app_localizations.dart'; +import '../drama_detail/drama_detail_view.dart'; +import 'favorites_binding.dart'; + +/// 独立我的收藏页。 +class FavoritesPage extends StatefulWidget { + const FavoritesPage({super.key}); + + @override + State createState() => _FavoritesPageState(); +} + +class _FavoritesPageState extends State { + bool _requested = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final signedIn = context.watch().isSignedIn; + if (!signedIn) { + _requested = false; + return; + } + if (_requested) return; + _requested = true; + final favorites = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(FavoritesBinding().load(favorites)); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: Colors.white, + title: Text(l10n.myFavorites), + ), + body: SafeArea( + top: false, + child: signedIn + ? _FavoritesBody(favorites: context.watch()) + : const AuthGatePanel(), + ), + ), + ); + } +} + +class _FavoritesBody extends StatelessWidget { + const _FavoritesBody({required this.favorites}); + + final FavoriteController favorites; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (favorites.loading && favorites.cards.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (favorites.error != null && favorites.cards.isEmpty) { + return EmptyState( + key: const Key('favorites-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + actionKey: const Key('favorites-retry'), + onAction: () => unawaited(favorites.load()), + ); + } + if (favorites.cards.isEmpty) { + return EmptyState( + key: const Key('favorites-empty'), + icon: Icons.favorite_border_rounded, + title: l10n.noFavorites, + subtitle: l10n.emptyHint, + ); + } + return RefreshIndicator( + onRefresh: favorites.load, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + itemCount: favorites.cards.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final drama = favorites.cards[index]; + return LibraryDramaTile( + key: ValueKey('favorite-card-${drama.id}'), + drama: drama, + onTap: () => _open(context, drama), + trailing: IconButton( + key: ValueKey('favorite-remove-${drama.id}'), + tooltip: l10n.remove, + onPressed: () => _remove(context, drama.id), + icon: const Icon( + Icons.favorite_rounded, + color: AppColors.favorite, + ), + ), + ); + }, + ), + ); + } + + Future _open(BuildContext context, Drama drama) async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } + + Future _remove(BuildContext context, String dramaId) async { + unawaited(context.read().lightImpact()); + final ok = await favorites.remove(dramaId); + if (ok || !context.mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + } +} diff --git a/lib/pages/home/home_feed_sections.dart b/lib/pages/home/home_feed_sections.dart deleted file mode 100644 index f16cb8c..0000000 --- a/lib/pages/home/home_feed_sections.dart +++ /dev/null @@ -1,32 +0,0 @@ -// Author: fruit.com.cn - -import '../../core/data/models/drama.dart'; - -enum HomeFeedCategory { following, animated, liveAction, recommended } - -/// Maps catalog entries into home feeds without owning playback or UI state. -class HomeFeedSections { - HomeFeedSections(this.dramas, {this.followedDramaIds = const {}}); - - final List dramas; - final Set followedDramaIds; - - List indicesFor(HomeFeedCategory category) => [ - for (var index = 0; index < dramas.length; index++) - if (dramas[index].episodes.isNotEmpty && - switch (category) { - HomeFeedCategory.following => followedDramaIds.contains( - dramas[index].id, - ), - HomeFeedCategory.animated => - dramas[index].format == DramaFormat.animated, - HomeFeedCategory.liveAction => - dramas[index].format == DramaFormat.liveAction, - HomeFeedCategory.recommended => true, - }) - index, - ]; - - Drama dramaAt(HomeFeedCategory category, int categoryIndex) => - dramas[indicesFor(category)[categoryIndex]]; -} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 9f26508..307b42c 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -1,11 +1,14 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../discover/discover_logic.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_logic.dart'; +import '../player/player_view.dart'; class HomeLogic extends ChangeNotifier { HomeLogic(List dramas) : currentDrama = dramas.first; @@ -21,6 +24,7 @@ class HomeLogic extends ChangeNotifier { bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; + bool profile = false; bool _disposed = false; void notify() { @@ -45,28 +49,25 @@ class HomeLogic extends ChangeNotifier { notify(); } - void openMenu() { - drawerOpen = true; + void setMenuOpen(bool value) { + if (drawerOpen == value) return; + drawerOpen = value; notify(); - scaffoldKey.currentState?.openDrawer(); } void selectFromMenu(int nextTab, {int? mySection}) { - scaffoldKey.currentState?.closeDrawer(); + drawerOpen = false; select(nextTab, mySection: mySection); } Future openCurrentSeries(BuildContext context) async { detailOpen = true; notify(); - scaffoldKey.currentState?.closeDrawer(); await WidgetsBinding.instance.endOfFrame; if (!context.mounted) return; try { await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DramaDetailPage(drama: currentDrama), - ), + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)), ); } finally { if (context.mounted) { @@ -82,6 +83,32 @@ class HomeLogic extends ChangeNotifier { ); } + Future playDrama(BuildContext context, Drama drama) async { + // 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。 + var playable = drama; + try { + playable = await context.read().requireDetail(drama); + } catch (_) { + // 取详情失败就用卡片自带的那一集起播。 + } + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage(drama: playable, initialIndex: 0), + ), + ); + } + + void openProfile() { + profile = true; + select(2, mySection: 0); + } + + void openLibrary({int section = 1}) { + profile = false; + select(2, mySection: section); + } + void discoverAction(BuildContext context, DiscoverPageAction action) { switch (action) { case DiscoverPageAction.home: @@ -89,7 +116,7 @@ class HomeLogic extends ChangeNotifier { case DiscoverPageAction.my: select(2); case DiscoverPageAction.watchlist: - select(2, watchlist: true); + openLibrary(); case DiscoverPageAction.resume: select(0, resume: true); case DiscoverPageAction.detail: @@ -109,25 +136,14 @@ class HomeLogic extends ChangeNotifier { } void onSystemBack() { - if (drawerOpen) { - scaffoldKey.currentState?.closeDrawer(); - } else if (videoImmersive) { - backRequest++; - notify(); - } else if (tab != 0) { - select(0); - } else { + if (videoImmersive) { backRequest++; notify(); + } else if (tab != 1) { + select(1); } } - void onDrawerChanged(bool opened) { - if (drawerOpen == opened) return; - drawerOpen = opened; - notify(); - } - void onDramaChanged(Drama drama) { currentDrama = drama; notify(); diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 3a81705..2c9377c 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -1,17 +1,30 @@ // Author: fruit.com.cn +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/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/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/server_endpoint_dialog.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_view.dart'; +import '../splash/splash_page.dart'; import '../mine/mine_view.dart'; import '../player/player_view.dart'; import 'home_binding.dart'; import 'home_logic.dart'; -import 'widgets/home_quick_drawer.dart'; +import 'widgets/home_menu_sheet.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -19,9 +32,10 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { final catalog = context.watch(); + if (!catalog.settled) return const SplashPage(); final l10n = AppLocalizations.of(context)!; return Theme( - data: ThemeData.dark(useMaterial3: true), + data: AppTheme.dark(), child: _buildHome(context, catalog, l10n), ); } @@ -31,25 +45,31 @@ class HomePage extends StatelessWidget { CatalogController catalog, AppLocalizations l10n, ) { - if (catalog.loading) { return const Scaffold( - backgroundColor: Colors.black, + backgroundColor: AppColors.ink, body: Center(child: CircularProgressIndicator()), ); } - if (catalog.error != null || catalog.dramas.isEmpty) { + if (catalog.error != null) { return Scaffold( - backgroundColor: Colors.black, - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(catalog.error == null ? l10n.noStories : l10n.catalogError), - const SizedBox(height: 12), - FilledButton(onPressed: catalog.load, child: Text(l10n.retry)), - ], - ), + backgroundColor: AppColors.ink, + body: HomeFailureView( + onRetry: catalog.load, + showDebugTools: kDebugMode, + ), + ); + } + if (catalog.dramas.isEmpty) { + return Scaffold( + backgroundColor: AppColors.ink, + body: EmptyState( + key: const Key('home-empty'), + icon: Icons.movie_outlined, + title: l10n.noStories, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: catalog.load, ), ); } @@ -61,6 +81,57 @@ class HomePage extends StatelessWidget { } } +/// Home catalog failure with optional debug-only endpoint controls. +class HomeFailureView extends StatelessWidget { + const HomeFailureView({ + super.key, + required this.onRetry, + this.showDebugTools = kDebugMode, + }); + + final VoidCallback onRetry; + final bool showDebugTools; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Column( + children: [ + Expanded( + child: EmptyState( + icon: Icons.cloud_off_rounded, + title: l10n.catalogError, + actionLabel: l10n.retry, + onAction: onRetry, + ), + ), + if (showDebugTools) _debugTools(context, l10n), + ], + ); + } + + Widget _debugTools(BuildContext context, AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Column( + children: [ + Text( + AppConfig.apiBaseUrl, + style: const TextStyle(fontSize: 12, color: AppColors.muted), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('home-server-settings'), + onPressed: () => showServerEndpointDialog(context, l10n), + icon: const Icon(Icons.dns_outlined), + label: Text(l10n.serverSettings), + ), + ], + ), + ); + } +} + /// The three primary destinations share a single route and keep their state. class _HomeTabs extends StatefulWidget { const _HomeTabs({super.key, required this.dramas}); @@ -96,25 +167,21 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _scaffold(BuildContext context) { final l10n = AppLocalizations.of(context)!; + // 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关) + final signedIn = context.watch().isSignedIn; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - logic.onSystemBack(); + if (!logic.videoImmersive && logic.tab == 1) { + unawaited(_openMenu(context)); + } else { + logic.onSystemBack(); + } }, child: Scaffold( key: logic.scaffoldKey, - backgroundColor: const Color(0xFF0B0C10), - drawer: HomeQuickDrawer( - drama: logic.currentDrama, - onOpenSeries: () => logic.openCurrentSeries(context), - onDiscover: () => logic.selectFromMenu(1), - onHistory: () => logic.selectFromMenu(2, mySection: 0), - onWatchlist: () => logic.selectFromMenu(2, mySection: 1), - onLiked: () => logic.selectFromMenu(2, mySection: 2), - ), - drawerEnableOpenDragGesture: false, - onDrawerChanged: logic.onDrawerChanged, + backgroundColor: AppColors.ink, body: IndexedStack( index: logic.tab, children: [ @@ -129,19 +196,23 @@ class _HomeTabsState extends State<_HomeTabs> { resumeRequest: logic.resumeRequest, backRequest: logic.backRequest, onDiscover: () => logic.select(1), - onMy: () => logic.select(2), + onMy: logic.openProfile, onSearch: () => logic.select(1, search: true), - onMenu: logic.openMenu, + onMenu: () => _openMenu(context), onImmersiveChanged: logic.onImmersiveChanged, ), DiscoverPage( drama: logic.currentDrama, dramas: widget.dramas, - onDramaSelected: (drama) => logic.openDrama(context, drama), + onDramaSelected: (drama) => logic.playDrama(context, drama), duration: Duration.zero, embedded: true, active: logic.tab == 1, searchRequest: logic.searchRequest, + signedIn: signedIn, + onSignIn: () => openAuthPage(context), + onCoins: logic.openProfile, + onRewards: logic.openProfile, onAction: (action) => logic.discoverAction(context, action), ), MyPage( @@ -150,94 +221,80 @@ class _HomeTabsState extends State<_HomeTabs> { onDramaSelected: (drama) => logic.openDrama(context, drama), duration: Duration.zero, initialSection: logic.mySection, + profile: logic.profile, sectionRequest: logic.sectionRequest, embedded: true, active: logic.tab == 2, onAction: logic.myAction, + signedIn: signedIn, + onSignIn: () => openAuthPage(context), + onSignOut: () => context.read().signOut(), ), ], ), bottomNavigationBar: logic.videoImmersive ? null - : SafeArea( - top: false, - child: Container( - key: const Key('home-tabs-bar'), - height: 63, - decoration: const BoxDecoration( - color: Color(0xFF0D0D11), - border: Border(top: BorderSide(color: Color(0xFF242329))), + : AppBottomNav( + barKey: const Key('home-tabs-bar'), + items: [ + AppBottomNavItem( + tabKey: const Key('tab-home'), + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, + label: l10n.homeTab, + selected: logic.tab == 0, + onTap: () => _selectTab(context, 0), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _TabButton( - key: const Key('tab-home'), - icon: Icons.play_circle_outline_rounded, - label: l10n.homeTab, - selected: logic.tab == 0, - onTap: () => logic.select(0), - ), - _TabButton( - key: const Key('tab-discover'), - icon: Icons.explore_outlined, - label: l10n.discoverTab, - selected: logic.tab == 1, - onTap: () => logic.select(1), - ), - _TabButton( - key: const Key('tab-my'), - icon: Icons.video_library_outlined, - label: l10n.myTab, - selected: logic.tab == 2, - onTap: () => logic.select(2), - ), - ], + AppBottomNavItem( + tabKey: const Key('tab-discover'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: l10n.discoverTab, + selected: logic.tab == 1, + onTap: () => _selectTab(context, 1), ), - ), + AppBottomNavItem( + tabKey: const Key('tab-my'), + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, + label: l10n.myTab, + selected: logic.tab == 2, + onTap: () => _selectTab(context, 2, myProfile: true), + ), + ], ), ), ); } -} -class _TabButton extends StatelessWidget { - const _TabButton({ - super.key, - required this.icon, - required this.label, - required this.selected, - required this.onTap, - }); - final IconData icon; - final String label; - final bool selected; - final VoidCallback onTap; + void _selectTab(BuildContext context, int tab, {bool myProfile = false}) { + final same = logic.tab == tab; + if (!same) { + unawaited(context.read().selectionClick()); + } + if (tab == 2) { + myProfile ? logic.openProfile() : logic.openLibrary(); + } else { + logic.select(tab); + } + } - @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - child: SizedBox( - width: 80, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 21, - color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE), - ), - Text( - label, - style: TextStyle( - fontSize: 10, - color: selected - ? const Color(0xFFFF5C37) - : const Color(0xFFE6E1DE), - ), - ), - ], + Future _openMenu(BuildContext context) async { + logic.setMenuOpen(true); + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => HomeMenuSheet( + drama: logic.currentDrama, + onOpenSeries: () => logic.openCurrentSeries(context), + onDiscover: () => logic.selectFromMenu(1), + onHistory: () => logic.openLibrary(section: 0), + onWatchlist: logic.openLibrary, + onLiked: () => logic.selectFromMenu(2, mySection: 2), ), - ), - ); + ); + logic.setMenuOpen(false); + } } diff --git a/lib/pages/home/widgets/category_swipe_region.dart b/lib/pages/home/widgets/category_swipe_region.dart deleted file mode 100644 index 27f8cc8..0000000 --- a/lib/pages/home/widgets/category_swipe_region.dart +++ /dev/null @@ -1,37 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -/// Converts horizontal drags into a previous/next category request. -/// Placement decides where the gesture is active; the seek bar sits outside it. -class CategorySwipeRegion extends StatefulWidget { - const CategorySwipeRegion({ - super.key, - required this.onShift, - required this.child, - }); - - final ValueChanged onShift; - final Widget child; - - @override - State createState() => _CategorySwipeRegionState(); -} - -class _CategorySwipeRegionState extends State { - double _distance = 0; - - @override - Widget build(BuildContext context) => GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => _distance = 0, - onHorizontalDragUpdate: (details) => _distance += details.delta.dx, - onHorizontalDragEnd: (details) { - final velocity = details.primaryVelocity ?? 0; - if (_distance.abs() < 44 && velocity.abs() < 300) return; - final direction = _distance.abs() >= 44 ? _distance : velocity; - widget.onShift(direction < 0 ? 1 : -1); - }, - child: widget.child, - ); -} diff --git a/lib/pages/home/widgets/home_feed_header.dart b/lib/pages/home/widgets/home_feed_header.dart deleted file mode 100644 index cb93c7f..0000000 --- a/lib/pages/home/widgets/home_feed_header.dart +++ /dev/null @@ -1,172 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../../l10n/app_localizations.dart'; -import '../home_feed_sections.dart'; -import 'category_swipe_region.dart'; - -/// Reused by playable and empty home feeds. -class HomeFeedHeader extends StatelessWidget { - const HomeFeedHeader({ - super.key, - required this.l10n, - required this.category, - required this.onCategorySelected, - required this.onCategoryShift, - required this.onSearch, - required this.onMy, - this.onMenu, - this.onMute, - this.muted = false, - }); - - final AppLocalizations l10n; - final HomeFeedCategory category; - final ValueChanged onCategorySelected; - final ValueChanged onCategoryShift; - final VoidCallback onSearch; - final VoidCallback onMy; - final VoidCallback? onMenu; - final VoidCallback? onMute; - final bool muted; - - String _label(HomeFeedCategory category) => switch (category) { - HomeFeedCategory.following => l10n.followingTab, - HomeFeedCategory.animated => l10n.animatedTab, - HomeFeedCategory.liveAction => l10n.liveActionTab, - HomeFeedCategory.recommended => l10n.recommendedTab, - }; - - @override - Widget build(BuildContext context) => IconTheme( - data: const IconThemeData(color: Colors.white), - child: DefaultTextStyle.merge( - style: const TextStyle(color: Colors.white), - child: _header(context), - ), - ); - - Widget _header(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Row( - children: [ - if (onMenu != null) - IconButton( - key: const Key('home-open-menu'), - tooltip: l10n.openQuickMenu, - onPressed: onMenu, - icon: const Icon(Icons.menu_rounded), - ), - Container( - width: 29, - height: 29, - decoration: BoxDecoration( - color: const Color(0xFFFF5C37), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.play_arrow_rounded, color: Colors.white), - ), - const SizedBox(width: 8), - Text( - l10n.homeTab, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), - ), - const Spacer(), - IconButton( - tooltip: l10n.search, - onPressed: onSearch, - icon: const Icon(Icons.search_rounded), - ), - IconButton( - tooltip: l10n.myTab, - onPressed: onMy, - icon: const CircleAvatar( - radius: 15, - backgroundColor: Color(0xFF3C3A3A), - child: Icon( - Icons.person_rounded, - color: Colors.white, - size: 19, - ), - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), - child: Row( - children: [ - Expanded( - child: CategorySwipeRegion( - key: const Key('home-category-swipe'), - onShift: onCategoryShift, - child: Row( - children: [ - for (final item in HomeFeedCategory.values) - Expanded( - child: InkWell( - key: Key('home-category-${item.name}'), - onTap: () => onCategorySelected(item), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 9), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _label(item), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: category == item - ? const Color(0xFFFFC2B0) - : const Color(0xFFF7F4F2), - shadows: const [ - Shadow(blurRadius: 8, color: Colors.black), - ], - fontSize: 12, - fontWeight: category == item - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - const SizedBox(height: 4), - SizedBox( - width: 18, - height: 2, - child: DecoratedBox( - decoration: BoxDecoration( - color: category == item - ? const Color(0xFFFF5C37) - : Colors.transparent, - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - if (onMute != null) - IconButton( - tooltip: muted ? l10n.unmute : l10n.mute, - onPressed: onMute, - icon: Icon( - muted ? Icons.volume_off_rounded : Icons.volume_up_rounded, - size: 21, - ), - ), - ], - ), - ), - ], - ); -} diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart new file mode 100644 index 0000000..e870bd4 --- /dev/null +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -0,0 +1,246 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/config/locale_controller.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +/// Compact navigation menu opened from the preview home header. +class HomeMenuSheet extends StatelessWidget { + const HomeMenuSheet({ + super.key, + required this.drama, + required this.onOpenSeries, + required this.onDiscover, + required this.onHistory, + required this.onWatchlist, + required this.onLiked, + }); + + final Drama drama; + final VoidCallback onOpenSeries; + final VoidCallback onDiscover; + final VoidCallback onHistory; + final VoidCallback onWatchlist; + final VoidCallback onLiked; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final languageCode = Localizations.localeOf(context).languageCode; + void closeThen(VoidCallback action) { + Navigator.pop(context); + action(); + } + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: RemoteImage(drama.posterUrl, width: 64, height: 86), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: 9, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 34, + child: FilledButton.icon( + key: const Key('drawer-open-series'), + onPressed: () => closeThen(onOpenSeries), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle(fontSize: 11), + ), + icon: const Icon( + Icons.play_arrow_rounded, + size: 15, + ), + label: Text(l10n.exploreSeries), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 14), + Text( + l10n.quickAccess, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _MenuTile( + key: const Key('drawer-discover'), + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: () => closeThen(onDiscover), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _MenuTile( + key: const Key('drawer-history'), + icon: Icons.history_rounded, + label: l10n.history, + onTap: () => closeThen(onHistory), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _MenuTile( + key: const Key('drawer-watchlist'), + icon: Icons.bookmark_border_rounded, + label: l10n.watchlist, + onTap: () => closeThen(onWatchlist), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _MenuTile( + key: const Key('drawer-liked'), + icon: Icons.favorite_border_rounded, + label: l10n.likedTab, + onTap: () => closeThen(onLiked), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + l10n.appLanguage, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final (code, label) in [ + ('en', l10n.english), + ('zh', l10n.simplifiedChinese), + ('id', l10n.bahasaIndonesia), + ]) + ChoiceChip( + label: Text(label), + selected: languageCode == code, + onSelected: (_) => + context.read().select(code), + backgroundColor: AppColors.input, + selectedColor: AppColors.blue, + labelStyle: TextStyle( + color: languageCode == code + ? AppColors.onPrimary + : AppColors.muted, + fontSize: 10, + ), + side: BorderSide.none, + showCheckmark: false, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _MenuTile extends StatelessWidget { + const _MenuTile({ + 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 Material( + color: AppColors.quick, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 58, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: AppColors.secondary, size: 16), + const SizedBox(height: 4), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/home_quick_drawer.dart b/lib/pages/home/widgets/home_quick_drawer.dart deleted file mode 100644 index f895323..0000000 --- a/lib/pages/home/widgets/home_quick_drawer.dart +++ /dev/null @@ -1,293 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../core/config/locale_controller.dart'; -import '../../../core/data/models/drama.dart'; -import '../../../core/formatting/localized_content.dart'; -import '../../../l10n/app_localizations.dart'; - -/// Shortcuts for the existing root tabs; this widget owns no navigation state. -class HomeQuickDrawer extends StatelessWidget { - const HomeQuickDrawer({ - super.key, - required this.drama, - required this.onOpenSeries, - required this.onDiscover, - required this.onHistory, - required this.onWatchlist, - required this.onLiked, - }); - - final Drama drama; - final VoidCallback onOpenSeries; - final VoidCallback onDiscover; - final VoidCallback onHistory; - final VoidCallback onWatchlist; - final VoidCallback onLiked; - - static const _panel = Color(0xFF222225); - static const _card = Color(0xFF303033); - static const _orange = Color(0xFFFF5C37); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final languageCode = Localizations.localeOf(context).languageCode; - return Drawer( - width: (MediaQuery.sizeOf(context).width * 0.83).clamp(0.0, 360.0), - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal(right: Radius.circular(20)), - ), - child: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), - children: [ - Row( - children: [ - const CircleAvatar( - radius: 30, - backgroundColor: Color(0xFF4B4142), - child: Icon( - Icons.person_rounded, - size: 34, - color: Colors.white, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.localViewer, - style: const TextStyle( - color: Colors.white, - fontSize: 19, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - l10n.localDemoProfile, - style: const TextStyle(color: Color(0xFFE7E2E0)), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 26), - _SectionCard( - title: l10n.currentSeries, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.asset( - drama.posterAsset, - width: 74, - height: 100, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w700, - fontSize: 16, - ), - ), - const SizedBox(height: 7), - Text( - l10n.episodeCount(drama.episodes.length), - style: const TextStyle(color: Color(0xFFE7E2E0)), - ), - const SizedBox(height: 5), - Text( - l10n.freeTestMedia, - maxLines: 2, - style: const TextStyle( - color: Color(0xFFFFB99E), - fontSize: 11, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - key: const Key('drawer-open-series'), - onPressed: onOpenSeries, - style: FilledButton.styleFrom( - backgroundColor: _orange, - foregroundColor: Colors.white, - ), - icon: const Icon(Icons.play_arrow_rounded), - label: Text(l10n.exploreSeries), - ), - ), - ], - ), - ), - const SizedBox(height: 14), - _SectionCard( - title: l10n.quickAccess, - child: Column( - children: [ - Row( - children: [ - Expanded( - child: _QuickAction( - key: const Key('drawer-discover'), - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: onDiscover, - ), - ), - const SizedBox(width: 9), - Expanded( - child: _QuickAction( - key: const Key('drawer-history'), - icon: Icons.history_rounded, - label: l10n.history, - onTap: onHistory, - ), - ), - ], - ), - const SizedBox(height: 9), - Row( - children: [ - Expanded( - child: _QuickAction( - key: const Key('drawer-watchlist'), - icon: Icons.bookmark_border_rounded, - label: l10n.watchlist, - onTap: onWatchlist, - ), - ), - const SizedBox(width: 9), - Expanded( - child: _QuickAction( - key: const Key('drawer-liked'), - icon: Icons.favorite_border_rounded, - label: l10n.likedTab, - onTap: onLiked, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 14), - _SectionCard( - title: l10n.appLanguage, - child: Row( - children: [ - for (final (code, label) in [ - ('en', l10n.english), - ('zh', l10n.simplifiedChinese), - ]) ...[ - Expanded( - child: ChoiceChip( - label: Text(label, maxLines: 1), - selected: languageCode == code, - onSelected: (_) => - context.read().select(code), - ), - ), - if (code == 'en') const SizedBox(width: 8), - ], - ], - ), - ), - ], - ), - ), - ); - } -} - -class _SectionCard extends StatelessWidget { - const _SectionCard({required this.title, required this.child}); - - final String title; - final Widget child; - - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: HomeQuickDrawer._card, - borderRadius: BorderRadius.circular(18), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 14), - child, - ], - ), - ); -} - -class _QuickAction extends StatelessWidget { - const _QuickAction({ - 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) => Material( - color: const Color(0xFF3B3B3F), - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7), - child: Column( - children: [ - Icon(icon, color: HomeQuickDrawer._orange, size: 25), - const SizedBox(height: 6), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), - ), - ], - ), - ), - ), - ); -} diff --git a/lib/pages/interaction/widgets/report_dialog.dart b/lib/pages/interaction/widgets/report_dialog.dart new file mode 100644 index 0000000..e6fae25 --- /dev/null +++ b/lib/pages/interaction/widgets/report_dialog.dart @@ -0,0 +1,125 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/interaction.dart'; +import '../../../l10n/app_localizations.dart'; + +const int _maxDescriptionLength = 300; + +/// 举报弹窗收集的结果。 +class ReportDialogResult { + const ReportDialogResult({ + required this.reason, + required this.description, + }); + + final ReportReason reason; + final String description; +} + +/// 打开举报原因单选弹窗;取消时返回 null。 +Future showReportDialog( + BuildContext context, { + required String title, +}) => showDialog( + context: context, + builder: (_) => _ReportDialog(title: title), +); + +class _ReportDialog extends StatefulWidget { + const _ReportDialog({required this.title}); + + final String title; + + @override + State<_ReportDialog> createState() => _ReportDialogState(); +} + +class _ReportDialogState extends State<_ReportDialog> { + final _description = TextEditingController(); + ReportReason _reason = ReportReason.spam; + + @override + void dispose() { + _description.dispose(); + super.dispose(); + } + + void _submit() { + Navigator.of(context).pop( + ReportDialogResult( + reason: _reason, + description: _description.text.trim(), + ), + ); + } + + String _reasonLabel(AppLocalizations l10n, ReportReason reason) => + switch (reason) { + ReportReason.spam => l10n.reportReasonSpam, + ReportReason.abuse => l10n.reportReasonAbuse, + ReportReason.porn => l10n.reportReasonPorn, + ReportReason.ad => l10n.reportReasonAd, + ReportReason.other => l10n.reportReasonOther, + }; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return AlertDialog( + title: Text(widget.title), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.reportReason), + RadioGroup( + groupValue: _reason, + onChanged: (value) { + if (value == null) return; + setState(() => _reason = value); + }, + child: Column( + children: [ + for (final reason in ReportReason.values) + RadioListTile( + key: Key('report-reason-${reason.apiValue}'), + value: reason, + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(_reasonLabel(l10n, reason)), + ), + ], + ), + ), + const SizedBox(height: 8), + TextField( + key: const Key('report-description'), + controller: _description, + maxLength: _maxDescriptionLength, + maxLines: 3, + minLines: 2, + decoration: InputDecoration( + hintText: l10n.reportDescriptionHint, + border: const OutlineInputBorder(), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + key: const Key('report-submit'), + onPressed: _submit, + child: Text(l10n.reportSubmit), + ), + ], + ); + } +} diff --git a/lib/pages/mine/mine_binding.dart b/lib/pages/mine/mine_binding.dart index 390823b..6c684ec 100644 --- a/lib/pages/mine/mine_binding.dart +++ b/lib/pages/mine/mine_binding.dart @@ -8,10 +8,10 @@ class MineBinding { MineLogic create({ required Drama drama, required int initialSection, - WatchHistoryRepository? history, + required WatchHistoryRepository history, }) => MineLogic( drama: drama, initialSection: initialSection, - history: history ?? SharedPreferencesWatchHistoryRepository(), + history: history, ); } diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index 2e567e8..3c56aaf 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -1,11 +1,16 @@ // Author: fruit.com.cn +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/config/locale_controller.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/widgets/server_endpoint_dialog.dart'; import '../../l10n/app_localizations.dart'; enum MyPageAction { home, discover, resume } @@ -22,6 +27,12 @@ class MineLogic extends ChangeNotifier { final WatchHistoryRepository history; Drama drama; late Future position; + List recent = const []; + bool recentLoading = false; + String? recentError; + + /// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始) + bool positionFailed = false; int section; int filter = 0; bool _disposed = false; @@ -31,11 +42,33 @@ class MineLogic extends ChangeNotifier { } void refreshPosition() { - position = history.positionFor( - demoProfileId, - drama.id, - drama.episodes.first.id, - ); + position = history + .resumePointFor(drama.id) + .then((point) { + positionFailed = false; + return point?.position; + }) + .catchError((Object _) { + positionFailed = true; + notify(); + return null; + }); + refreshRecent(); + } + + /// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。 + Future refreshRecent() async { + recentLoading = true; + recentError = null; + notify(); + try { + recent = await history.recent(limit: 20); + } catch (_) { + recentError = 'progressError'; + } finally { + recentLoading = false; + notify(); + } } void selectSection(int index) { @@ -81,35 +114,63 @@ class MineLogic extends ChangeNotifier { } } - Future showLanguage(BuildContext context) async { + Future showLanguage( + BuildContext context, { + bool showServerSetting = kDebugMode, + }) async { final l10n = AppLocalizations.of(context)!; await showModalBottomSheet( context: context, - backgroundColor: const Color(0xFF1C1D22), + backgroundColor: AppColors.ink, showDragHandle: true, + isScrollControlled: true, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile(title: Text(l10n.language)), - for (final (code, label) in [ - ('en', l10n.english), - ('zh', l10n.simplifiedChinese), - ]) - ListTile( - title: Text(label), - onTap: () { - context.read().select(code); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(l10n.language)), + for (final (code, label) in [ + ('en', l10n.english), + ('zh', l10n.simplifiedChinese), + ('id', l10n.bahasaIndonesia), + ]) + ListTile( + title: Text(label), + onTap: () { + context.read().select(code); + Navigator.pop(sheetContext); + }, + ), + if (showServerSetting) ...[ + const Divider(height: 1), + ListTile( + key: const Key('server-endpoint-setting'), + leading: const Icon(Icons.dns_outlined), + title: Text(l10n.serverAddress), + subtitle: Text(AppConfig.apiBaseUrl), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _editServerEndpoint(context, l10n), + ), + ], + const SizedBox(height: 8), + ], + ), + ), ), ), ); } + Future _editServerEndpoint( + BuildContext context, + AppLocalizations l10n, + ) => showServerEndpointDialog(context, l10n); + @override void dispose() { _disposed = true; diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 14c9ee9..bc1e7a5 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -1,13 +1,31 @@ // Author: fruit.com.cn +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/formatting/localized_content.dart'; +import '../../core/config/locale_controller.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; -import '../../core/data/services/engagement_controller.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/interaction_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_content.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_bottom_nav.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; +import '../account/account_entry_card.dart'; +import '../downloads/downloads_page.dart'; +import '../favorites/favorites_page.dart'; +import '../notices/notices_page.dart'; +import '../watch_records/watch_records_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; @@ -17,25 +35,35 @@ class MyPage extends StatefulWidget { required this.drama, required this.duration, this.initialSection = 0, + this.profile = false, this.history, this.embedded = false, this.active = true, this.sectionRequest = 0, this.onAction, + this.signedIn = false, + this.onSignIn, + this.onSignOut, this.dramas, this.onDramaSelected, + this.debugShowSetting = kDebugMode, }); final Drama drama; final Duration duration; final int initialSection; + final bool profile; final WatchHistoryRepository? history; final bool embedded; final bool active; final int sectionRequest; final ValueChanged? onAction; + final bool signedIn; + final VoidCallback? onSignIn; + final VoidCallback? onSignOut; final List? dramas; final ValueChanged? onDramaSelected; + final bool debugShowSetting; @override State createState() => _MyPageState(); @@ -50,7 +78,7 @@ class _MyPageState extends State { logic = MineBinding().create( drama: widget.drama, initialSection: widget.initialSection, - history: widget.history, + history: widget.history ?? context.read(), ); } @@ -90,42 +118,27 @@ class _MyPageState extends State { children: [ _topBar(l10n), Expanded( - child: FutureBuilder( - future: logic.position, - builder: (context, snapshot) { - final position = snapshot.data ?? Duration.zero; - final total = widget.duration > Duration.zero - ? widget.duration - : const Duration(seconds: 12); - final fraction = - (position.inMilliseconds / total.inMilliseconds).clamp( - 0.0, - 1.0, - ); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), - children: [ - _profile(l10n), - const SizedBox(height: 22), - _continueCard(l10n, fraction, position, total), - const SizedBox(height: 26), - _library(l10n, position, total, fraction), - const SizedBox(height: 26), - _preferences(l10n), - const SizedBox(height: 24), - Center( - child: Text( - l10n.brandMark, - style: TextStyle( - color: Color(0xFFD2CDD0), - fontSize: 10, - letterSpacing: 1.3, - ), - ), - ), - ], - ); - }, + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 18), + children: [ + _userRow(l10n), + const SizedBox(height: 12), + _wallet(l10n), + if (_continueDrama != null) ...[ + const SizedBox(height: 12), + _continueCard(l10n, _continueDrama!, _continueRecord!), + ], + const SizedBox(height: 14), + _library(l10n), + const SizedBox(height: 14), + _languageRow(l10n), + if (widget.signedIn) ...[ + const SizedBox(height: 12), + const AccountEntryCard(), + ], + const SizedBox(height: 12), + _menuCard(l10n), + ], ), ), if (!widget.embedded) _bottomNav(l10n), @@ -133,218 +146,314 @@ class _MyPageState extends State { ), ); return Theme( - data: ThemeData.dark(useMaterial3: true), + data: AppTheme.dark(), child: widget.embedded ? content - : Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content), + : Scaffold(backgroundColor: AppColors.ink, body: content), ); } Widget _topBar(AppLocalizations l10n) => SizedBox( - height: 54, + height: 46, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27), - const SizedBox(width: 7), + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, + ), + ), + const SizedBox(width: 8), Text( l10n.myTab, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), ), const Spacer(), - IconButton( + _RoundHeaderButton( + key: const Key('mine-search-button'), tooltip: l10n.search, - onPressed: () => logic.act( + icon: Icons.search_rounded, + onTap: () => logic.act( context, MyPageAction.discover, embedded: widget.embedded, onAction: widget.onAction, ), - icon: const Icon(Icons.search_rounded, size: 23), ), - const SizedBox(width: 6), - const CircleAvatar( - radius: 14, - backgroundColor: Color(0xFF2C2D33), - child: Icon(Icons.person_rounded, color: Colors.white, size: 19), + const SizedBox(width: 8), + Container( + key: const Key('mine-avatar'), + width: 36, + height: 36, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 18, + ), ), ], ), ), ); - Widget _profile(AppLocalizations l10n) => Row( - children: [ - const CircleAvatar( - radius: 30, - backgroundColor: Color(0xFF32343A), - child: Icon(Icons.person_rounded, color: Colors.white, size: 34), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.localViewer, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 4), - Text( - l10n.localDemoProfile, - style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 12), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFF24252A), - borderRadius: BorderRadius.circular(20), - ), - child: Text(l10n.demo, style: const TextStyle(fontSize: 11)), - ), - ], - ); - - Widget _continueCard( - AppLocalizations l10n, - double fraction, - Duration position, - Duration total, - ) => Container( - padding: const EdgeInsets.all(13), - decoration: BoxDecoration( - color: const Color(0xFF201B1E), - borderRadius: BorderRadius.circular(13), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _userRow(AppLocalizations l10n) { + final user = widget.signedIn ? context.watch().user : null; + final name = widget.signedIn + ? ((user?.nickname.isNotEmpty ?? false) + ? user!.nickname + : l10n.localViewer) + : l10n.localViewer; + final subtitle = widget.signedIn + ? ((user?.uid.isNotEmpty ?? false) + ? 'ID: ${user!.uid}' + : l10n.myAccount) + : l10n.logInHint; + return Row( + key: const Key('mine-user-row'), children: [ - Row( - children: [ - const Icon(Icons.history_rounded, color: _orange, size: 17), - const SizedBox(width: 6), - Expanded( - child: Text( - l10n.continueWatching, + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 22, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 14, + color: AppColors.onSurface, + fontSize: 15, fontWeight: FontWeight.w800, ), ), - ), - Text( - l10n.episodeProgress(1, widget.drama.episodes.length), - style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10), - ), - ], + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10), + ), + ], + ), ), - const SizedBox(height: 12), - Row( + if (widget.signedIn) + TextButton( + onPressed: widget.onSignOut, + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + backgroundColor: const Color(0xFF24252A), + minimumSize: const Size(52, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text(l10n.signOut), + ) + else + TextButton( + key: const Key('mine-sign-in'), + onPressed: widget.onSignIn, + style: TextButton.styleFrom( + foregroundColor: AppColors.cream, + backgroundColor: const Color(0xFF24252A), + minimumSize: const Size(64, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text('${l10n.logIn}/${l10n.signUp}'), + ), + ], + ); + } + + Widget _wallet(AppLocalizations l10n) { + return InkWell( + key: const Key('mine-wallet'), + onTap: () => _mockSnack(l10n), + borderRadius: BorderRadius.circular(13), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.goldCard, + borderRadius: BorderRadius.circular(13), + ), + child: Row( children: [ - _poster(width: 75, height: 96), - const SizedBox(width: 11), + const Icon( + Icons.monetization_on_rounded, + color: AppColors.gold, + size: 20, + ), + const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - localizedDramaTitle(l10n, widget.drama), + l10n.coins, style: const TextStyle( - fontSize: 17, + color: AppColors.onSurface, + fontSize: 12, fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( - l10n.dramaTag, + '0 ${l10n.coins} · ${l10n.topUpWallet}', style: const TextStyle( - color: Color(0xFFE6E1E3), - fontSize: 12, - ), - ), - const SizedBox(height: 10), - Text( - l10n.watchedPercent((fraction * 100).round()), - style: const TextStyle( - color: Color(0xFFE7DEDD), - fontSize: 11, - ), - ), - const SizedBox(height: 5), - ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - value: fraction, - minHeight: 4, - backgroundColor: const Color(0xFF444046), - color: _orange, - ), - ), - const SizedBox(height: 6), - Text( - '${_clock(position)} / ${_clock(total)}', - style: const TextStyle( - color: Color(0xFFE6E1E3), - fontSize: 12, + color: AppColors.accent, + fontSize: 9, ), ), ], ), ), + const Icon( + Icons.chevron_right_rounded, + color: Color(0xFF666666), + size: 16, + ), ], ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () => logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ), - style: FilledButton.styleFrom( - backgroundColor: _orange, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(37), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - ), - icon: const Icon(Icons.play_arrow_rounded, size: 18), - label: Text(l10n.resume), - ), - ), - ], - ), - ); + ), + ); + } - Widget _library( + WatchProgress? get _continueRecord { + for (final record in logic.recent) { + if (!record.finished && record.hasPosition) return record; + } + return null; + } + + Drama? get _continueDrama { + final record = _continueRecord; + if (record == null) return null; + for (final drama in [...?widget.dramas, widget.drama]) { + if (drama.id == record.dramaId) return drama; + } + return null; + } + + Widget _continueCard( AppLocalizations l10n, - Duration position, - Duration total, - double fraction, + Drama drama, + WatchProgress record, ) { + return Container( + key: const Key('mine-continue-watching'), + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 50, height: 64, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.continueWatching, + style: const TextStyle( + color: AppColors.accent, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '${_episodeText(drama, record)} · ' + '${_clock(record.position)} / ${_clock(record.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 60, + height: 28, + child: FilledButton( + onPressed: () => _resume(drama), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + child: Text(l10n.resume), + ), + ), + ], + ), + ); + } + + Widget _library(AppLocalizations l10n) { final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; - final filters = [l10n.all, l10n.inProgress, l10n.completed]; - final hasHistory = - logic.filter == 0 || - (logic.filter == 1 && fraction < 0.99) || - (logic.filter == 2 && fraction >= 0.99); - final engagement = context.watch(); - final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) { - return logic.section == 1 - ? engagement.isSaved(drama.id) - : engagement.isLiked(drama.id); - }).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -352,337 +461,529 @@ class _MyPageState extends State { scrollDirection: Axis.horizontal, child: Row( children: [ - for (var index = 0; index < tabs.length; index++) ...[ - InkWell( - onTap: () => logic.selectSection(index), - child: Text( - tabs[index], - style: TextStyle( - color: logic.section == index - ? _orange - : const Color(0xFFE4DBDD), - fontSize: 16, - fontWeight: FontWeight.w800, + for (var index = 0; index < tabs.length; index++) + Padding( + padding: EdgeInsets.only( + right: index == tabs.length - 1 ? 0 : 20, + ), + child: Semantics( + selected: logic.section == index, + button: true, + child: InkWell( + key: Key('mine-library-tab-$index'), + onTap: () => logic.selectSection(index), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tabs[index], + maxLines: 1, + style: TextStyle( + color: logic.section == index + ? AppColors.blue + : const Color(0xFFE4DBDD), + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 18, + height: 2, + decoration: BoxDecoration( + color: logic.section == index + ? AppColors.blue + : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + ), ), ), ), - if (index < tabs.length - 1) const SizedBox(width: 18), - ], ], ), ), - const SizedBox(height: 17), - if (logic.section == 0) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < filters.length; index++) - Padding( - padding: const EdgeInsets.only(right: 7), - child: ChoiceChip( - label: Text(filters[index]), - selected: logic.filter == index, - onSelected: (_) => logic.selectFilter(index), - showCheckmark: false, - visualDensity: VisualDensity.compact, - backgroundColor: const Color(0xFF242328), - selectedColor: _orange, - labelStyle: TextStyle( - color: logic.filter == index - ? Colors.white - : const Color(0xFFD5CED0), - fontSize: 11, - fontWeight: FontWeight.w700, - ), - side: BorderSide.none, - ), - ), - ], + const SizedBox(height: 12), + if (logic.section == 1 && context.watch().loading) + const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), ), - ), - if (logic.section == 0) const SizedBox(height: 11), - if (logic.section == 0 && hasHistory) - InkWell( - onTap: () => logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, + ) + else if (logic.section == 1) + ..._favoriteCards(l10n) + else if (logic.section == 2) + ..._likedCards(l10n) + else if (logic.recentLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), ), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: const Color(0xFF1B1C21), - borderRadius: BorderRadius.circular(12), - ), - child: Row( + ) + else if (_recentDramas.isEmpty) + EmptyState( + key: const Key('mine-recent-empty'), + icon: Icons.history_rounded, + title: l10n.noStories, + subtitle: l10n.emptyHint, + compact: true, + ) + else + for (final drama in _recentDramas) ...[ + _historyCard(l10n, drama), + const SizedBox(height: 8), + ], + ], + ); + } + + List _favoriteCards(AppLocalizations l10n) { + final favorites = context.watch(); + if (favorites.error != null && favorites.cards.isEmpty) { + return [ + EmptyState( + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + onAction: () => unawaited(favorites.load()), + compact: true, + ), + ]; + } + if (favorites.cards.isEmpty) { + return [ + EmptyState( + key: const Key('mine-saved-empty'), + icon: Icons.bookmark_border_rounded, + title: l10n.noSavedShows, + subtitle: l10n.emptyHint, + compact: true, + ), + ]; + } + return [ + for (final drama in favorites.cards) ...[ + _smallDramaCard(l10n, drama), + const SizedBox(height: 8), + ], + ]; + } + + List _likedCards(AppLocalizations l10n) { + final interactions = context.watch(); + final dramas = [ + ...?widget.dramas, + widget.drama, + ].where((drama) => interactions.isLiked(drama.id)).toList(); + if (dramas.isEmpty) { + return [ + EmptyState( + key: const Key('mine-saved-empty'), + icon: Icons.favorite_border_rounded, + title: l10n.noSavedShows, + subtitle: l10n.emptyHint, + compact: true, + ), + ]; + } + return [ + for (final drama in dramas) ...[ + _smallDramaCard(l10n, drama), + const SizedBox(height: 8), + ], + ]; + } + + Widget _historyCard(AppLocalizations l10n, Drama drama) { + final record = _recordFor(drama.id); + return InkWell( + key: Key('mine-history-card-${drama.id}'), + onTap: () => _resume(drama), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 48, height: 60, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _poster(width: 74, height: 103), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, widget.drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 5), - Text( - l10n.dramaTag, - style: const TextStyle( - color: Color(0xFFE6E1E3), - fontSize: 12, - ), - ), - const SizedBox(height: 32), - Text( - '${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFFFFA18D), - fontSize: 10, - ), - ), - ], + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, ), ), - const CircleAvatar( - radius: 15, - backgroundColor: Color(0xFF313137), - child: Icon( - Icons.play_arrow_rounded, - color: _orange, - size: 20, + const SizedBox(height: 3), + Text( + record == null + ? l10n.episodeCount(drama.episodeCount) + : '${_episodeText(drama, record)} · ' + '${_clock(record.position)} / ${_clock(record.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 9, ), ), ], ), ), - ) - else if (logic.section != 0 && markedDramas.isNotEmpty) - for (final drama in markedDramas) ...[ - _markedDramaCard(drama, l10n), - const SizedBox(height: 9), - ] - else - Padding( - padding: const EdgeInsets.symmetric(vertical: 22), - child: Text( - l10n.noSavedShows, - style: const TextStyle(color: Color(0xFFE6E1E3)), + Container( + width: 26, + height: 26, + decoration: const BoxDecoration( + color: Color(0xFF313137), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.blue, + size: 15, + ), ), - ), - ], + ], + ), + ), ); } - Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell( - onTap: () { - if (widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else if (drama.id == widget.drama.id) { - logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: const Color(0xFF1B1C21), - borderRadius: BorderRadius.circular(12), + Widget _smallDramaCard(AppLocalizations l10n, Drama drama) { + return InkWell( + onTap: () { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + _resume(drama); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 48, height: 60, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 9, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.muted, + size: 16, + ), + ], + ), ), - child: Row( + ); + } + + Widget _languageRow(AppLocalizations l10n) { + final locale = context.watch(); + final code = locale.locale?.languageCode ?? 'id'; + final value = switch (code) { + 'zh' => l10n.simplifiedChinese, + 'en' => l10n.english, + _ => l10n.bahasaIndonesia, + }; + return InkWell( + key: const Key('mine-language'), + onTap: () => logic.showLanguage( + context, + showServerSetting: widget.debugShowSetting, + ), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon( + Icons.language_rounded, + color: AppColors.muted, + size: 14, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + l10n.appLanguage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + ), + ), + ), + Text( + value, + style: const TextStyle(color: AppColors.accent, fontSize: 10), + ), + const SizedBox(width: 3), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.accent, + size: 16, + ), + ], + ), + ), + ); + } + + Widget _menuCard(AppLocalizations l10n) { + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: Column( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - drama.posterAsset, - width: 74, - height: 103, - fit: BoxFit.cover, + _menuTile( + icon: Icons.notifications_none_rounded, + title: l10n.notices, + key: const Key('mine-notices'), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NoticesPage()), ), ), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 7), - Text( - l10n.episodeCount(drama.episodes.length), - style: const TextStyle( - color: Color(0xFFE6E1E3), - fontSize: 12, - ), - ), - ], + _menuTile( + icon: Icons.history_rounded, + title: l10n.watchHistory, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const WatchRecordsPage()), + ), + ), + _menuTile( + icon: Icons.favorite_outline_rounded, + title: l10n.myFavorites, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const FavoritesPage()), + ), + ), + _menuTile( + icon: Icons.download_outlined, + title: l10n.downloads, + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DownloadsPage()), + ), + ), + _menuTile( + icon: Icons.settings_rounded, + title: l10n.settings, + onTap: () => logic.showLanguage( + context, + showServerSetting: widget.debugShowSetting, ), ), - const Icon(Icons.chevron_right_rounded, color: Color(0xFFF2EEEF)), ], ), - ), - ); + ); + } - Widget _preferences(AppLocalizations l10n) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.preferencesHeading, - style: const TextStyle( - color: Color(0xFFCEBBB9), - fontSize: 11, - fontWeight: FontWeight.w800, - letterSpacing: 0.5, + Widget _menuTile({ + required IconData icon, + required String title, + required VoidCallback onTap, + Key? key, + }) { + return ListTile( + key: key, + dense: true, + minTileHeight: 44, + leading: Icon(icon, color: AppColors.secondary, size: 17), + title: Text( + title, + style: const TextStyle(color: AppColors.onSurface, fontSize: 11), + ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.muted, + size: 16, + ), + onTap: onTap, + ); + } + + Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( + items: [ + AppBottomNavItem( + tabKey: const Key('mine-nav-home'), + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, + label: l10n.homeTab, + onTap: () => logic.act( + context, + MyPageAction.home, + embedded: widget.embedded, + onAction: widget.onAction, ), ), - const SizedBox(height: 12), - Material( - color: const Color(0xFF1B1C21), - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: ListTile( - leading: const Icon( - Icons.translate_rounded, - color: Color(0xFFFFBC65), - ), - title: Text(l10n.appLanguage), - subtitle: Text(l10n.language), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - Localizations.localeOf(context).languageCode == 'zh' - ? l10n.simplifiedChinese - : l10n.english, - style: const TextStyle(color: Color(0xFFFFB2A1), fontSize: 11), - ), - const SizedBox(width: 4), - const Icon(Icons.chevron_right_rounded, size: 18), - ], - ), - onTap: () => logic.showLanguage(context), + AppBottomNavItem( + tabKey: const Key('mine-nav-discover'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: l10n.discoverTab, + onTap: () => logic.act( + context, + MyPageAction.discover, + embedded: widget.embedded, + onAction: widget.onAction, ), ), + AppBottomNavItem( + tabKey: const Key('mine-nav-current'), + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, + label: l10n.myTab, + selected: true, + onTap: () {}, + ), ], ); - Widget _bottomNav(AppLocalizations l10n) => Container( - height: 63, - decoration: const BoxDecoration( - color: Color(0xFF0D0D11), - border: Border(top: BorderSide(color: Color(0xFF242329))), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _NavItem( - icon: Icons.play_circle_outline_rounded, - label: l10n.homeTab, - onTap: () => logic.act( - context, - MyPageAction.home, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - _NavItem( - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: () => logic.act( - context, - MyPageAction.discover, - embedded: widget.embedded, - onAction: widget.onAction, - ), - ), - _NavItem( - icon: Icons.video_library_outlined, - label: l10n.myTab, - selected: true, - onTap: () {}, - ), - ], - ), + List get _recentDramas { + final pool = [...?widget.dramas, widget.drama]; + final result = []; + for (final item in logic.recent) { + for (final drama in pool) { + if (drama.id == item.dramaId && + !result.any((picked) => picked.id == drama.id)) { + result.add(drama); + } + } + } + return result; + } + + WatchProgress? _recordFor(String dramaId) { + for (final record in logic.recent) { + if (record.dramaId == dramaId) return record; + } + return null; + } + + String _episodeText(Drama drama, WatchProgress record) { + for (final episode in drama.episodes) { + if (episode.id == record.episodeId) { + return AppLocalizations.of( + context, + )!.episodeOfTotal(episode.number, drama.episodeCount); + } + } + return AppLocalizations.of(context)!.episodeCount(drama.episodeCount); + } + + void _resume(Drama drama) { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + logic.act( + context, + MyPageAction.resume, + embedded: widget.embedded, + onAction: widget.onAction, + ); + } + } + + void _mockSnack(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); + } + + Widget _poster( + Drama drama, { + required double width, + required double height, + required double radius, + }) => ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: RemoteImage(drama.posterUrl, width: width, height: height), ); - Widget _poster({required double width, required double height}) => ClipRRect( - borderRadius: BorderRadius.circular(7), - child: Image.asset( - widget.drama.posterAsset, - width: width, - height: height, - fit: BoxFit.cover, - ), - ); + String _clock(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } } -class _NavItem extends StatelessWidget { - const _NavItem({ +class _RoundHeaderButton extends StatelessWidget { + const _RoundHeaderButton({ + super.key, + required this.tooltip, required this.icon, - required this.label, required this.onTap, - this.selected = false, }); + final String tooltip; final IconData icon; - final String label; final VoidCallback onTap; - final bool selected; @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - child: SizedBox( - width: 72, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 21, - color: selected ? _orange : const Color(0xFFCAC4C7), - ), - Text( - label, - style: TextStyle( - color: selected ? _orange : const Color(0xFFCAC4C7), - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - ], + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), ), - ), - ); + ); + } } - -String _clock(Duration duration) { - final seconds = duration.inSeconds; - return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; -} - -const _orange = Color(0xFFFF5B3D); diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart new file mode 100644 index 0000000..0b16bac --- /dev/null +++ b/lib/pages/notices/notices_page.dart @@ -0,0 +1,303 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/notice.dart'; +import '../../core/data/repositories/notice_repository.dart'; +import '../../core/data/services/notices_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_date_time.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; + +/// Anonymous system notice list with pagination and detail dialog. +class NoticesPage extends StatefulWidget { + const NoticesPage({super.key}); + + @override + State createState() => _NoticesPageState(); +} + +class _NoticesPageState extends State { + late final NoticesController _controller; + + @override + void initState() { + super.initState(); + _controller = NoticesController(context.read()); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('notices-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(l10n.notices), + ), + body: SafeArea( + top: false, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => _body(l10n), + ), + ), + ), + ); + } + + Widget _body(AppLocalizations l10n) { + if (_controller.loading && _controller.notices.isEmpty) { + return const Center( + child: CircularProgressIndicator(key: Key('notices-loading')), + ); + } + if (_controller.error != null && _controller.notices.isEmpty) { + return RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.7, + child: EmptyState( + key: const Key('notices-error'), + icon: Icons.cloud_off_rounded, + title: errorText( + l10n, + _controller.error, + l10n.noticesLoadFailed, + ), + actionLabel: l10n.retry, + actionKey: const Key('notices-retry'), + onAction: _controller.load, + ), + ), + ], + ), + ); + } + if (_controller.notices.isEmpty) { + return RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.7, + child: EmptyState( + key: const Key('notices-empty'), + icon: Icons.notifications_none_rounded, + title: l10n.noNotices, + subtitle: l10n.emptyHint, + ), + ), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView.separated( + key: const Key('notices-list'), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + itemCount: _controller.notices.length + 1, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + if (index == _controller.notices.length) return _footer(l10n); + final notice = _controller.notices[index]; + return _NoticeTile( + notice: notice, + onTap: () => _showDetails(notice, l10n), + ); + }, + ), + ); + } + + Widget _footer(AppLocalizations l10n) { + if (_controller.loadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_controller.loadMoreError != null) { + return TextButton.icon( + onPressed: _controller.loadMore, + icon: const Icon(Icons.refresh_rounded), + label: Text(l10n.retry), + ); + } + if (_controller.hasMore) { + return TextButton( + key: const Key('notices-load-more'), + onPressed: _controller.loadMore, + child: Text(l10n.loadMore), + ); + } + return const SizedBox(height: 8); + } + + Future _showDetails(AppNotice notice, AppLocalizations l10n) { + final publishAt = notice.publishAt == null + ? '' + : formatLocalizedDateTime(context, notice.publishAt!); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(notice.title), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (publishAt.isNotEmpty) ...[ + Text( + publishAt, + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + const SizedBox(height: 10), + ], + SelectableText( + notice.content, + key: const Key('notice-detail-content'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + key: const Key('notice-detail-close'), + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + ], + ), + ); + } +} + +class _NoticeTile extends StatelessWidget { + const _NoticeTile({required this.notice, required this.onTap}); + + final AppNotice notice; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final publishAt = notice.publishAt == null + ? '' + : formatLocalizedDateTime(context, notice.publishAt!); + final type = notice.type.isEmpty ? l10n.noticeAnnouncement : notice.type; + return Material( + key: ValueKey('notice-item-${notice.id}'), + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppDimens.innerPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppColors.bluePressed, + borderRadius: BorderRadius.circular( + AppDimens.pillRadius, + ), + ), + child: Text( + type, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: AppDimens.caption, + ), + ), + ), + ), + if (publishAt.isNotEmpty) + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + publishAt, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 9), + Text( + notice.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w700, + ), + ), + if (notice.content.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + notice.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + height: 1.35, + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index b0e10c8..873901d 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -1,12 +1,17 @@ // Author: fruit.com.cn import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import 'player_logic.dart'; class PlayerBinding { - PlayerLogic create() => PlayerLogic( - history: SharedPreferencesWatchHistoryRepository(), + PlayerLogic create({ + required WatchHistoryRepository history, + required PlaybackRepository playback, + }) => PlayerLogic( + history: history, + playback: playback, pictureInPicture: AndroidPictureInPicture(), ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index fc6f867..321a3b6 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -3,52 +3,77 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/play_ticket.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/models/player_port.dart'; +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/platform_share_service.dart'; import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_logic.dart'; import '../discover/discover_view.dart'; import '../drama_detail/drama_detail_view.dart'; -import '../home/home_feed_sections.dart'; import '../mine/mine_logic.dart'; import '../mine/mine_view.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; +import 'widgets/episode_picker_cell.dart'; + +const double _episodeCellAspect = 1.05; class PlayerLogic extends ChangeNotifier { - PlayerLogic({required this.history, required this.pictureInPicture}); + PlayerLogic({ + required this.history, + required this.playback, + required this.pictureInPicture, + }); final WatchHistoryRepository history; + final PlaybackRepository playback; final AndroidPictureInPicture pictureInPicture; late PageController pages; late PlaybackQueue queue; - late HomeFeedSections homeSections; late List visibleIndices; - HomeFeedCategory category = HomeFeedCategory.recommended; - int categoryRequest = 0; late PlaybackSession session; VideoPlayerPort? trackedPlayer; Episode? trackedEpisode; Drama? trackedDrama; DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); + + /// 进度上报节流:契约建议每 10 秒一次 + Duration progressInterval = const Duration(seconds: 10); Future pendingWrite = Future.value(); + + /// 服务端续看点:进入播放页时定位到"那一集的哪一秒" + ResumePoint? resumePoint; + bool resumeLoaded = false; + + /// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放 + bool ticketLoading = false; + String? ticketError; + PlayTicket? blockedTicket; + String? blockedEpisodeId; + List sessionEpisodes = const []; int pageRequest = 0; bool muted = false; + double playbackSpeed = 1.0; bool stoppedAtFeedEnd = false; VideoPlayerPort? scrubbingPlayer; - bool fullscreen = false; bool pip = false; bool pipPreparing = false; + final Map pickedEpisode = {}; bool Function() isMounted = () => false; bool homeMode = false; @@ -67,7 +92,22 @@ class PlayerLogic extends ChangeNotifier { ValueChanged? onImmersiveChanged; bool _disposed = false; - bool get immersive => fullscreen || pip || pipPreparing; + /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, + /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 + bool _started = false; + + bool get immersive => pip || pipPreparing; + + bool get chromeHidden => immersive; + + Episode displayEpisode(Drama drama, Episode fallback) { + final number = pickedEpisode[drama.id]; + if (number == null) return fallback; + for (final episode in drama.episodes) { + if (episode.number == number) return episode; + } + return fallback; + } void notify() { if (!_disposed) notifyListeners(); @@ -95,21 +135,20 @@ class PlayerLogic extends ChangeNotifier { required PlayerFactory factory, required PlaybackCoordinator coordinator, }) { + _started = true; pictureInPicture.setStateListener(onPipChanged); queue = feedDramas == null ? PlaybackQueue.series(pageDrama) : PlaybackQueue.home(feedDramas!); - homeSections = HomeFeedSections([ - for (final entry in queue.entries) entry.drama, - ]); - visibleIndices = homeMode - ? homeSections.indicesFor(category) - : List.generate(queue.length, (index) => index); + visibleIndices = List.generate(queue.length, (index) => index); pages = PageController(initialPage: initialIndex); + // 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它) + sessionEpisodes = [for (final entry in queue.entries) entry.episode]; session = PlaybackSession( - episodes: [for (final entry in queue.entries) entry.episode], + episodes: sessionEpisodes, factory: factory, - canPlay: (episode) => episode.isFree, + // 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定 + canPlay: (episode) => episode.videoUrl.isNotEmpty, prepare: preparePlayer, activate: activatePlayer, coordinator: coordinator, @@ -117,9 +156,88 @@ class PlayerLogic extends ChangeNotifier { if (!active || visibleIndices.isEmpty) { session.setRouteVisible(false); } + unawaited(loadResumePoint()); unawaited(selectEpisode(initialIndex)); } + /// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。 + Future loadResumePoint() async { + final dramaId = queue.entryAt(initialIndex).drama.id; + try { + resumePoint = await history.resumePointFor(dramaId); + } catch (_) { + // 拿不到续看位置不影响播放:从当前集开头看 + resumePoint = null; + } finally { + resumeLoaded = true; + if (isMounted()) notify(); + } + } + + /// 票据门:起播前先拿票(命中未过期缓存就不会重复请求), + /// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。 + Duration? resumePositionFor(String episodeId) { + final point = resumePoint; + if (point == null || point.episodeId != episodeId) return null; + if (point.position <= Duration.zero) return null; + return point.position; + } + + /// 进度上报节流:距上次上报不足 [progressInterval] 就不报。 + /// + /// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。 + bool shouldReportProgress(DateTime now) { + if (now.difference(lastProgressWrite) < progressInterval) return false; + lastProgressWrite = now; + return true; + } + + /// 票据门:起播前先拿票(命中未过期缓存就不会重复请求), + /// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。 + Future ensureTicket(int index) async { + if (index < 0 || index >= sessionEpisodes.length) return false; + final episode = sessionEpisodes[index]; + final request = pageRequest; + ticketLoading = true; + ticketError = null; + notify(); + try { + final ticket = await playback.ticketFor(episode.id); + if (request != pageRequest) return false; + blockedTicket = null; + blockedEpisodeId = null; + if (ticket.accessible && ticket.playUrl.isNotEmpty) { + // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) + sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl); + return true; + } + blockedTicket = ticket; + blockedEpisodeId = episode.id; + return false; + } catch (exception) { + if (request != pageRequest) return false; + // 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了 + if (episode.videoUrl.isNotEmpty) { + ticketError = null; + return true; + } + ticketError = exception is ApiException + ? exception.message + : 'ticketError'; + return false; + } finally { + ticketLoading = false; + if (isMounted()) notify(); + } + } + + /// 重试当前集的票据(失败态的"重试"按钮)。 + Future retryTicket() async { + ticketError = null; + notify(); + unawaited(selectEpisode(session.currentIndex)); + } + void onPageUpdated(PlayerPage previous) { if (previous.active != active) { session.setRouteVisible(active && visibleIndices.isNotEmpty); @@ -135,11 +253,7 @@ class PlayerLogic extends ChangeNotifier { final request = backRequest; WidgetsBinding.instance.addPostFrameCallback((_) { if (isMounted() && backRequest == request) { - if (fullscreen) { - unawaited(setFullscreen(false)); - } else { - shiftCategory(-1); - } + onMenu?.call(); } }); } @@ -147,6 +261,14 @@ class PlayerLogic extends ChangeNotifier { void onLifecycle(AppLifecycleState state) { if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); + if (state == AppLifecycleState.resumed) { + // 签名地址有有效期:回前台时若票据已过期,重新取一张再起播, + // 否则会撞上 403(契约 §1.4) + final episode = trackedEpisode; + if (episode != null && !playback.isFresh(episode.id)) { + unawaited(selectEpisode(session.currentIndex)); + } + } session.setAppResumed( state == AppLifecycleState.resumed || pip || pipPreparing, ); @@ -158,47 +280,17 @@ class PlayerLogic extends ChangeNotifier { if (_disposed) return; _disposed = true; pictureInPicture.setStateListener(null); - if (fullscreen) { - unawaited( - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), - ); - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } unawaited(saveCurrentProgress()); - session.setRouteVisible(false); - cancelScrub(); - unawaited(session.close()); - pages.dispose(); + if (_started) { + session.setRouteVisible(false); + cancelScrub(); + unawaited(session.close()); + pages.dispose(); + } super.dispose(); } - void notifyImmersive() => onImmersiveChanged?.call(immersive); - - Future setFullscreen(bool value) async { - if (fullscreen == value || !isMounted()) return; - fullscreen = value; - notify(); - notifyImmersive(); - try { - await SystemChrome.setPreferredOrientations( - value - ? [ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ] - : [DeviceOrientation.portraitUp], - ); - await SystemChrome.setEnabledSystemUIMode( - value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, - ); - } catch (_) { - if (value && isMounted()) { - fullscreen = false; - notify(); - notifyImmersive(); - } - } - } + void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); Future enterPip(BuildContext context) async { if (pip || pipPreparing) return; @@ -208,7 +300,6 @@ class PlayerLogic extends ChangeNotifier { ); return; } - if (fullscreen) await setFullscreen(false); pipPreparing = true; notify(); notifyImmersive(); @@ -228,7 +319,7 @@ class PlayerLogic extends ChangeNotifier { session.setAppResumed(true); } else { pipPreparing = false; - notify(); + notify(); notifyImmersive(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), @@ -250,23 +341,19 @@ class PlayerLogic extends ChangeNotifier { Future preparePlayer(Episode episode, PlayerPort player) async { if (player is! VideoPlayerPort) return; - final drama = queue.entries - .firstWhere((e) => e.episode.id == episode.id) - .drama; try { + await player.controller.setPlaybackSpeed(playbackSpeed); await pendingWrite; - final saved = await history.positionFor( - demoProfileId, - drama.id, - episode.id, - ); + // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 + if (resumePoint == null) await loadResumePoint(); + final saved = resumePositionFor(episode.id); if (saved != null) { await player.controller.seekTo( clampResumePosition(saved, player.controller.value.duration), ); } } catch (_) { - // A local preference failure does not prevent playback. + // 续看位置取不到不影响播放:从头播。 } } @@ -275,6 +362,7 @@ class PlayerLogic extends ChangeNotifier { final index = session.currentIndex; final drama = queue.entryAt(index).drama; await player.controller.setVolume(muted ? 0 : 1); + await player.controller.setPlaybackSpeed(playbackSpeed); if (session.active != player) return; trackedPlayer = player; trackedEpisode = episode; @@ -289,7 +377,13 @@ class PlayerLogic extends ChangeNotifier { if (player.controller.value.isCompleted && !completionProgressSaved) { completionProgressSaved = true; unawaited( - queueProgress(drama, episode, player.controller.value.duration), + queueProgress( + drama, + episode, + player.controller.value.duration, + duration: player.controller.value.duration, + finished: true, + ), ); } if (!completionHandled && @@ -299,11 +393,7 @@ class PlayerLogic extends ChangeNotifier { completionHandled = true; unawaited(advanceAfterEnd(player, index)); } - final now = DateTime.now(); - if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) { - return; - } - lastProgressWrite = now; + if (!shouldReportProgress(DateTime.now())) return; unawaited( queueProgress(drama, episode, player.controller.value.position), ); @@ -329,17 +419,30 @@ class PlayerLogic extends ChangeNotifier { curve: Curves.easeOutCubic, ); } catch (_) { - // A category change can detach the old page controller mid-animation. + // The page can leave the tree before the animation finishes. } } - Future queueProgress(Drama drama, Episode episode, Duration position) { + /// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。 + Future queueProgress( + Drama drama, + Episode episode, + Duration position, { + Duration? duration, + bool finished = false, + }) { pendingWrite = pendingWrite .then( - (_) => history.save(demoProfileId, drama.id, episode.id, position), + (_) => history.save( + episodeId: episode.id, + position: position, + duration: duration, + finished: finished, + ), ) .catchError((Object _) { - // Playback continues if local history cannot be stored. + // 进度上报失败不影响播放(下次节流点会再报一次)。 + return null; }); return pendingWrite; } @@ -354,7 +457,12 @@ class PlayerLogic extends ChangeNotifier { !player.controller.value.isInitialized) { return pendingWrite; } - return queueProgress(drama, episode, player.controller.value.position); + return queueProgress( + drama, + episode, + player.controller.value.position, + duration: player.controller.value.duration, + ); } Future selectEpisode(int index) async { @@ -363,6 +471,10 @@ class PlayerLogic extends ChangeNotifier { // Capture the old position synchronously, persist without blocking a swipe. unawaited(saveCurrentProgress()); if (request != pageRequest || !isMounted()) return; + // 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试) + final ready = await ensureTicket(index); + if (request != pageRequest || !isMounted()) return; + if (!ready && ticketError != null) return; await session.select(index, preloadIndex: nextVisibleIndex(index)); } @@ -373,47 +485,16 @@ class PlayerLogic extends ChangeNotifier { : null; } - void shiftCategory(int offset) { - if (!homeMode) return; - final next = category.index + offset; - if (next < 0) { - onMenu?.call(); - return; - } - if (next >= HomeFeedCategory.values.length) return; - unawaited(selectCategory(HomeFeedCategory.values[next])); - } + bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; - Future selectCategory(HomeFeedCategory next) async { - if (!homeMode || next == category) return; - final request = ++categoryRequest; - ++pageRequest; - session.setRouteVisible(false); - cancelScrub(); - await saveCurrentProgress(); - if (!isMounted() || request != categoryRequest) return; - - final oldPages = pages; - final indices = homeSections.indicesFor(next); - category = next; - visibleIndices = indices; - pages = PageController(); - notify(); - WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose()); - - if (indices.isEmpty) return; - onDramaChanged?.call(queue.entryAt(indices.first).drama); - await session.select( - indices.first, - preloadIndex: nextVisibleIndex(indices.first), + Future playNextEpisode() async { + final next = nextVisibleIndex(session.currentIndex); + if (next == null || !pages.hasClients) return; + await pages.animateToPage( + visibleIndices.indexOf(next), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, ); - if (isMounted() && request == categoryRequest) { - if (stoppedAtFeedEnd) { - stoppedAtFeedEnd = false; - session.setUserWantsPlay(true); - } - session.setRouteVisible(active); - } } void beginScrub(VideoPlayerPort player) { @@ -428,7 +509,11 @@ class PlayerLogic extends ChangeNotifier { session.setOverlayOpen(false); } - Future seekTo(BuildContext context, VideoPlayerPort player, Duration position) async { + Future seekTo( + BuildContext context, + VideoPlayerPort player, + Duration position, + ) async { if (scrubbingPlayer != player || session.active != player) return; try { await session.playbackSettled; @@ -466,12 +551,31 @@ class PlayerLogic extends ChangeNotifier { if (isMounted()) session.setRouteVisible(active); } + Future openSeriesAt( + BuildContext context, + Drama drama, + int index, + ) async { + session.setRouteVisible(false); + await session.playbackSettled; + await saveCurrentProgress(); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage(drama: drama, initialIndex: index), + ), + ); + if (isMounted()) session.setRouteVisible(active); + } + Future showEpisodes(BuildContext context) async { - if (feedDramas != null) { - await openDetail(context); - return; - } final l10n = AppLocalizations.of(context)!; + final entry = queue.entryAt(session.currentIndex); + final drama = await _withFullEpisodes(context, entry.drama); + if (!context.mounted) return; + final homeFeed = feedDramas != null; + final episodes = drama.episodes; + final currentNumber = entry.episode.number; session.setOverlayOpen(true); await session.playbackSettled; if (!context.mounted) { @@ -481,49 +585,57 @@ class PlayerLogic extends ChangeNotifier { try { await showModalBottomSheet( context: context, - backgroundColor: const Color(0xFF202024), + backgroundColor: AppColors.ink, showDragHandle: true, builder: (sheetContext) => SafeArea( child: SizedBox( - height: MediaQuery.sizeOf(context).height * 0.6, + height: MediaQuery.sizeOf(sheetContext).height * 0.46, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + padding: const EdgeInsets.fromLTRB(20, 0, 20, 4), child: Text( l10n.episodes, style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFB7B3B8), + fontSize: 14, + fontWeight: FontWeight.w400, ), ), ), Expanded( - child: ListView.builder( - itemCount: queue.length, - itemBuilder: (context, index) { - final episode = queue.entryAt(index).episode; - return ListTile( - leading: CircleAvatar( - backgroundColor: const Color(0xFF3A3030), - child: Text('${episode.number}'), + child: GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: _episodeCellAspect, ), - title: Text(l10n.episodeLabel(episode.number)), - subtitle: Text(l10n.freeTestMedia), - trailing: const Icon(Icons.play_arrow_rounded), - onTap: () { - Navigator.of(sheetContext).pop(); - unawaited( - pages.animateToPage( - index, - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutCubic, - ), - ); - }, - ); - }, + itemCount: episodes.length, + itemBuilder: (_, index) => _episodeCell( + context, + sheetContext, + drama, + episodes[index], + index, + homeFeed, + currentNumber, + ), ), ), ], @@ -536,6 +648,57 @@ class PlayerLogic extends ChangeNotifier { } } + /// 列表卡片只带一集可播预览,需要完整剧集时补一次详情; + /// 取不到(或页面外没有目录来源)就退回卡片本身。 + Future _withFullEpisodes(BuildContext context, Drama drama) async { + if (drama.detailLoaded) return drama; + final CatalogController catalog; + try { + catalog = context.read(); + } catch (_) { + return drama; + } + try { + return await catalog.requireDetail(drama); + } catch (_) { + return drama; + } + } + + Widget _episodeCell( + BuildContext pageContext, + BuildContext sheetContext, + Drama drama, + Episode episode, + int index, + bool homeFeed, + int currentNumber, + ) { + final selected = episode.number == currentNumber; + final l10n = AppLocalizations.of(pageContext)!; + return EpisodePickerCell( + episode: episode, + selected: selected, + lockLabel: l10n.locked, + onTap: () { + Navigator.of(sheetContext).pop(); + if (homeFeed) { + if (episode.number == currentNumber) return; + unawaited(openSeriesAt(pageContext, drama, index)); + return; + } + if (index == session.currentIndex) return; + unawaited( + pages.animateToPage( + index, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ), + ); + }, + ); + } + Future showMy(BuildContext context, {int initialSection = 0}) async { if (embedded) { onMy?.call(); @@ -576,7 +739,10 @@ class PlayerLogic extends ChangeNotifier { await showDiscover(context, focusSearch: true); } - Future showDiscover(BuildContext context, {bool focusSearch = false}) async { + Future showDiscover( + BuildContext context, { + bool focusSearch = false, + }) async { if (embedded) { onDiscover?.call(); return; @@ -603,7 +769,8 @@ class PlayerLogic extends ChangeNotifier { await openDetail(context); } else if (action == DiscoverPageAction.my || action == DiscoverPageAction.watchlist) { - await showMy(context, + await showMy( + context, initialSection: action == DiscoverPageAction.watchlist ? 1 : 0, ); } else { @@ -623,6 +790,15 @@ class PlayerLogic extends ChangeNotifier { notify(); } + Future setPlaybackSpeed(double value) async { + playbackSpeed = value; + final active = session.active; + if (active is VideoPlayerPort && active.controller.value.isInitialized) { + await active.controller.setPlaybackSpeed(value); + } + notify(); + } + Future showComments(BuildContext context, Drama drama) async { session.setOverlayOpen(true); await session.playbackSettled; @@ -634,7 +810,7 @@ class PlayerLogic extends ChangeNotifier { await showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: const Color(0xFF232328), + backgroundColor: AppColors.ink, showDragHandle: true, builder: (_) => DramaCommentsSheet(drama: drama), ); @@ -663,5 +839,4 @@ class PlayerLogic extends ChangeNotifier { session.setOverlayOpen(false); } } - } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 3edbfd2..20ac336 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -8,17 +8,28 @@ import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/services/engagement_controller.dart'; +import '../../core/data/models/play_ticket.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/platform/haptics.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; -import '../home/home_feed_sections.dart'; -import '../home/widgets/category_swipe_region.dart'; -import '../home/widgets/home_feed_header.dart'; import 'player_binding.dart'; import 'player_logic.dart'; +import 'widgets/home_feed_overlay.dart'; +import 'widgets/exit_clear_screen_button.dart'; +import 'widgets/player_center_toggle.dart'; +import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/repositories/playback_repository.dart'; class PlayerPage extends StatefulWidget { const PlayerPage({ @@ -60,11 +71,16 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; + bool _chromeVisible = true; + double _playbackSpeed = 1.0; @override void initState() { super.initState(); - logic = PlayerBinding().create(); + logic = PlayerBinding().create( + history: context.read(), + playback: context.read(), + ); logic.attach(widget, isMounted: () => mounted); WidgetsBinding.instance.addObserver(this); logic.start( @@ -101,85 +117,32 @@ class _PlayerPageState extends State with WidgetsBindingObserver { } Widget _buildPlayer(BuildContext context) { - final engagement = context.watch(); - final category = logic.category; + final interactions = context.watch(); final indices = logic.visibleIndices; final pages = logic.pages; final content = AnimatedBuilder( animation: logic.session, - builder: (context, _) => indices.isEmpty && widget.homeMode - ? _buildEmptyCategory(context) - : PageView.builder( - key: ValueKey(category), - controller: pages, - scrollDirection: Axis.vertical, - allowImplicitScrolling: true, - itemCount: indices.length, - onPageChanged: (position) { - if (category != logic.category) return; - final index = indices[position]; - widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); - unawaited(logic.selectEpisode(index)); - }, - itemBuilder: (context, position) => - _buildEpisode(context, indices[position], engagement), - ), + builder: (context, _) => PageView.builder( + controller: pages, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + itemCount: indices.length, + onPageChanged: (position) { + final index = indices[position]; + widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); + unawaited(logic.selectEpisode(index)); + }, + itemBuilder: (context, position) => + _buildEpisode(context, indices[position], interactions), + ), ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Theme( - data: ThemeData.dark(useMaterial3: true), + data: AppTheme.dark(), child: widget.embedded ? content - : Scaffold(backgroundColor: Colors.black, body: content), - ), - ); - } - - Widget _buildEmptyCategory(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return CategorySwipeRegion( - key: const Key('home-empty-swipe'), - onShift: logic.shiftCategory, - child: ColoredBox( - color: const Color(0xFF111214), - child: SafeArea( - child: Column( - children: [ - HomeFeedHeader( - l10n: l10n, - category: logic.category, - onCategorySelected: (value) => - unawaited(logic.selectCategory(value)), - onCategoryShift: logic.shiftCategory, - onSearch: () => logic.showSearch(context), - onMy: () => logic.showMy(context), - onMenu: widget.onMenu, - ), - const Spacer(), - const Icon( - Icons.video_library_outlined, - size: 48, - color: Color(0xFFE6E1E3), - ), - const SizedBox(height: 12), - Text( - l10n.noCategoryDramas, - style: const TextStyle(fontSize: 16, color: Colors.white), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - l10n.demoCatalogNotice, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13, color: Color(0xFFF3EEEA)), - ), - ), - const Spacer(), - ], - ), - ), + : Scaffold(backgroundColor: AppColors.ink, body: content), ), ); } @@ -187,25 +150,30 @@ class _PlayerPageState extends State with WidgetsBindingObserver { Widget _buildEpisode( BuildContext context, int index, - EngagementController engagement, + InteractionController interactions, ) { final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); final drama = entry.drama; - final episode = entry.episode; + final episode = widget.homeMode + ? logic.displayEpisode(drama, entry.episode) + : entry.episode; final selected = index == logic.session.currentIndex; // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. final port = - (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); + (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; final ready = videoPort?.controller.value.isInitialized ?? false; + final theater = logic.pip || logic.pipPreparing; Widget? video; if (ready) { video = GestureDetector( - onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), child: FittedBox( - fit: logic.immersive ? BoxFit.contain : BoxFit.cover, + fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( width: videoPort!.controller.value.size.width, height: videoPort.controller.value.size.height, @@ -216,389 +184,456 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ); - if (widget.homeMode) { - video = CategorySwipeRegion( - key: selected ? const Key('home-video-swipe') : null, - onShift: logic.shiftCategory, - child: video, - ); - } } return Stack( fit: StackFit.expand, children: [ - if (widget.homeMode && !ready && !logic.immersive) - CategorySwipeRegion( - key: selected ? const Key('home-video-swipe') : null, - onShift: logic.shiftCategory, - child: const ColoredBox(color: Colors.black), - ) - else - const ColoredBox(color: Colors.black), + const ColoredBox(color: AppColors.ink), ?video, - if (!logic.immersive) - const IgnorePointer( - child: DecoratedBox( + if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible)) + IgnorePointer( + key: widget.homeMode ? const Key('home-feed-scrim') : null, + child: const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Color(0xD6000000), Color(0x66000000), + Color(0x00000000), Color(0x33000000), - Color(0xF0000000), + Color(0xE6000000), ], - stops: [0, 0.22, 0.5, 1], + stops: [0, 0.16, 0.55, 1], ), ), ), ), + if (widget.homeMode && _chromeVisible && !logic.chromeHidden) + Positioned.fill( + child: GestureDetector( + key: const Key('home-feed-video-toggle'), + behavior: HitTestBehavior.opaque, + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + ), + ), + // 票据三态之一:起播前正在向后端要签名地址 + if (selected && logic.ticketLoading) + const Center( + child: CircularProgressIndicator(color: AppColors.blueSoft), + ), + // 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载 + if (selected && !logic.ticketLoading && logic.ticketError != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.wifi_off_rounded, + color: Colors.white70, + size: 36, + ), + const SizedBox(height: 12), + Text( + errorText(l10n, logic.ticketError, l10n.playbackError), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () => unawaited(logic.retryTicket()), + child: Text(l10n.retry), + ), + ], + ), + ), + ), if (selected && logic.session.loading) const Center( - child: CircularProgressIndicator(color: Color(0xFFFF6B45)), + child: CircularProgressIndicator(color: AppColors.blueSoft), ), - if (selected && ready && !logic.session.userWantsPlay && !logic.pip) + if (selected && !ready && logic.session.error != null) Center( - child: IconButton.filled( - iconSize: 48, - onPressed: () => logic.session.setUserWantsPlay(true), - icon: const Icon(Icons.play_arrow_rounded), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline_rounded, + color: Colors.white70, + size: 36, + ), + const SizedBox(height: 12), + Text( + l10n.playbackError, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ], + ), ), ), - if (widget.homeMode && (logic.pip || logic.pipPreparing)) - const SizedBox.expand() - else if (widget.homeMode && logic.fullscreen) - _FullscreenControls( + // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 + if (selected && !ready && logic.blockedEpisodeId == episode.id) + _BlockedNotice( l10n: l10n, - controller: ready ? videoPort!.controller : null, - onClose: () => unawaited(logic.setFullscreen(false)), - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, - ) + episode: episode, + accessType: logic.blockedTicket?.accessType, + unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, + ), + // 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程) + if (selected && + !ready && + logic.blockedEpisodeId != episode.id && + logic.session.error == null && + logic.session.isLocked) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.movie_filter_outlined, + color: Colors.white70, + size: 36, + ), + const SizedBox(height: 12), + Text( + episode.locked + ? l10n.lockedTitle(episode.number) + : l10n.noMediaNotice, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ), + ), + if (logic.pip || logic.pipPreparing) + const SizedBox.expand() else if (widget.homeMode) - _StitchHomeOverlay( + _chromeVisible + ? HomeFeedOverlay( + l10n: l10n, + drama: drama, + episode: episode, + liked: interactions.isLiked(drama.id), + saved: context.watch().isSaved(drama.id), + onLike: () async { + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike( + drama.id, + ); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? + AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + }, + onSave: () async { + final favorites = context.read(); + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.favoriteToggleFailed)), + ); + } + }, + onComments: () => logic.showComments(context, drama), + onShare: () => logic.shareDrama(context, drama), + onEpisodes: () => logic.showEpisodes(context), + onDownload: () => requestEpisodeDownload(context, episode.id), + onClear: () => setState(() => _chromeVisible = false), + onSearch: + widget.onSearch ?? () => logic.showDiscover(context), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + ) + : const SizedBox.shrink() + else + _PlaybackOverlay( l10n: l10n, drama: drama, episode: episode, controller: ready ? videoPort!.controller : null, - liked: engagement.isLiked(drama.id), - saved: engagement.isSaved(drama.id), - muted: logic.muted, - category: logic.category, - onCategorySelected: (value) => unawaited(logic.selectCategory(value)), - onCategoryShift: logic.shiftCategory, - onDetail: () => logic.openDetail(context), - onDiscover: () => logic.showDiscover(context), + liked: interactions.isLiked(drama.id), + // 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载) + saved: context.watch().isSaved(drama.id), + onBack: widget.embedded + ? null + : () => unawaited(logic.leave(context)), onEpisodes: () => logic.showEpisodes(context), - onMy: () => logic.showMy(context), - onSearch: () => logic.showSearch(context), - onMenu: widget.onMenu, - onMute: logic.toggleMute, - onLike: () => engagement.toggleLiked(drama.id), - onSave: () => engagement.toggleSaved(drama.id), + onNext: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + playing: logic.session.userWantsPlay, + playbackSpeed: _playbackSpeed, + onSpeedTap: () => unawaited(_openSpeedSheet(context)), + controlsVisible: _chromeVisible, + onClear: () => setState(() => _chromeVisible = false), + onExitClear: () => setState(() => _chromeVisible = true), + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) + onLike: () async { + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? + AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + }, + onSave: () async { + final favorites = context.read(); + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.favoriteToggleFailed)), + ); + } + }, + onDownload: () => requestEpisodeDownload(context, episode.id), onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), - onFullscreen: () => unawaited(logic.setFullscreen(true)), onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, - showBottomNav: !widget.embedded, - ) - else - SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (!widget.homeMode) - IconButton( - onPressed: () => unawaited(logic.leave(context)), - tooltip: l10n.backToSeries, - icon: const Icon(Icons.arrow_back_rounded), - ) - else - const Icon( - Icons.play_circle_fill_rounded, - color: Color(0xFFFF6B45), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - widget.homeMode - ? l10n.appName - : localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - IconButton( - tooltip: l10n.language, - onPressed: () => logic.showMy(context), - icon: const Icon(Icons.language_rounded), - ), - ], - ), - if (widget.homeMode) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - l10n.homeTab, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - decoration: TextDecoration.underline, - ), - ), - const SizedBox(width: 22), - TextButton( - onPressed: () => logic.openDetail(context), - child: Text( - l10n.discoverTab, - style: const TextStyle( - color: Color(0xFFF3EEEA), - fontSize: 15, - ), - ), - ), - ], - ), - const Spacer(), - if (selected && logic.session.error != null) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: FilledButton( - onPressed: () => unawaited(logic.selectEpisode(index)), - child: Text('${l10n.playbackError} ${l10n.retry}'), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.demoTestMedia, - style: const TextStyle( - color: Color(0xFFFFB199), - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - localizedDramaTitle(l10n, drama), - style: const TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w800, - shadows: [ - Shadow(blurRadius: 12, color: Colors.black), - ], - ), - ), - const SizedBox(height: 6), - Text( - localizedDramaSynopsis(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFFF7F4F2)), - ), - ], - ), - ), - const SizedBox(width: 8), - Column( - children: [ - IconButton.filledTonal( - tooltip: l10n.exploreSeries, - onPressed: () => logic.openDetail(context), - style: IconButton.styleFrom( - backgroundColor: Colors.black45, - foregroundColor: Colors.white, - ), - icon: const Icon(Icons.info_outline_rounded), - ), - const SizedBox(height: 8), - IconButton.filledTonal( - tooltip: l10n.episodes, - onPressed: () => logic.showEpisodes(context), - style: IconButton.styleFrom( - backgroundColor: Colors.black45, - foregroundColor: Colors.white, - ), - icon: const Icon(Icons.grid_view_rounded), - ), - const SizedBox(height: 8), - IconButton.filledTonal( - tooltip: logic.session.userWantsPlay - ? l10n.pause - : l10n.play, - style: IconButton.styleFrom( - backgroundColor: Colors.black45, - foregroundColor: Colors.white, - ), - onPressed: ready - ? () => logic.session.setUserWantsPlay( - !logic.session.userWantsPlay, - ) - : null, - icon: Icon( - logic.session.userWantsPlay - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - ), - ], - ), - ], - ), - const SizedBox(height: 18), - TextButton.icon( - onPressed: () => logic.showEpisodes(context), - icon: const Icon(Icons.play_arrow_rounded), - label: Text( - '${l10n.episodeProgress(episode.number, drama.episodes.length)} · ${l10n.episodes}', - ), - style: TextButton.styleFrom(foregroundColor: Colors.white), - ), - SeekProgressBar( - valueListenable: ready ? videoPort!.controller : null, - semanticsLabel: l10n.seekVideo, - showTimes: false, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready - ? (position) => logic.seekTo(context, videoPort!, position) - : null, - ), - if (widget.homeMode) ...[ - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _BottomAction( - icon: Icons.home_rounded, - label: l10n.homeTab, - selected: true, - onTap: () {}, - ), - _BottomAction( - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: () => logic.showDiscover(context), - ), - _BottomAction( - icon: Icons.person_outline_rounded, - label: l10n.myTab, - onTap: () => logic.showMy(context), - ), - ], - ), - ], - ], + onSeek: ready + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + ), + if (widget.homeMode && !_chromeVisible) + Positioned.fill( + child: GestureDetector( + key: const Key('home-feed-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + ), + ), + if (widget.homeMode && !_chromeVisible) + Positioned( + top: 0, + right: 0, + child: SafeArea( + left: false, + bottom: false, + minimum: const EdgeInsets.only(top: 8, right: 12), + child: ExitClearScreenButton( + key: const Key('home-feed-exit-clear-screen'), + tooltip: l10n.exitClearScreen, + onPressed: () => setState(() => _chromeVisible = true), ), ), ), + if (widget.homeMode && + !logic.chromeHidden && + selected && + !logic.session.userWantsPlay) + Center( + child: PlayerCenterToggle( + key: const Key('home-feed-center-play'), + playing: false, + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + ), + ), ], ); } + + Future _openSpeedSheet(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed), + ); + if (selected == null || !mounted) return; + setState(() => _playbackSpeed = selected); + await logic.setPlaybackSpeed(selected); + } } -class _FullscreenControls extends StatelessWidget { - const _FullscreenControls({ +/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 +class _BlockedNotice extends StatelessWidget { + const _BlockedNotice({ required this.l10n, - required this.controller, - required this.onClose, - required this.onPip, - required this.onScrubStart, - required this.onSeek, + required this.episode, + required this.accessType, + required this.unlockCoin, }); final AppLocalizations l10n; - final VideoPlayerController? controller; - final VoidCallback onClose; - final VoidCallback? onPip; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; + final Episode episode; + final String? accessType; + final int unlockCoin; @override - Widget build(BuildContext context) => SafeArea( - child: Column( - children: [ - Row( - children: [ - IconButton( - tooltip: l10n.exitFullscreen, - onPressed: onClose, - icon: const Icon(Icons.close_fullscreen_rounded), - ), - const Spacer(), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - icon: const Icon(Icons.picture_in_picture_alt_outlined), - ), - ], + Widget build(BuildContext context) { + final needsUnlock = + accessType == PlayTicket.coinRequired || + accessType == PlayTicket.vipRequired; + final title = needsUnlock + ? l10n.lockedTitle(episode.number) + : accessType == PlayTicket.noMedia + ? l10n.noMediaNotice + : episode.locked + ? l10n.lockedTitle(episode.number) + : l10n.noMediaNotice; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: _UnlockCard( + locked: needsUnlock, + title: title, + coinLabel: needsUnlock && unlockCoin > 0 + ? '$unlockCoin ${l10n.coins}' + : null, + footnote: needsUnlock ? l10n.unlockComingSoon : null, ), - const Spacer(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - compactIdle: true, - onScrubStart: onScrubStart, - onSeek: onSeek, - ), - ), - ], - ), - ); + ), + ); + } } -class _StitchHomeOverlay extends StatelessWidget { - const _StitchHomeOverlay({ +/// Coin lock presented as a reward card: gold chip, chili only on the icon. +class _UnlockCard extends StatelessWidget { + const _UnlockCard({ + required this.locked, + required this.title, + required this.coinLabel, + required this.footnote, + }); + + final bool locked; + final String title; + final String? coinLabel; + final String? footnote; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.gold.withValues(alpha: 0.55)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + locked ? Icons.lock_outline_rounded : Icons.movie_filter_outlined, + color: locked ? AppColors.gold : AppColors.cream, + size: 32, + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.cream, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + if (coinLabel != null) ...[ + const SizedBox(height: 12), + DecoratedBox( + decoration: BoxDecoration( + color: AppColors.gold, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Text( + coinLabel!, + style: const TextStyle( + color: AppColors.onGold, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + if (footnote != null) ...[ + const SizedBox(height: 8), + Text( + footnote!, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ], + ], + ), + ), + ); + } +} + +class _PlaybackOverlay extends StatefulWidget { + const _PlaybackOverlay({ required this.l10n, required this.drama, required this.episode, required this.controller, required this.liked, required this.saved, - required this.muted, - required this.category, - required this.onCategorySelected, - required this.onCategoryShift, - required this.onDetail, - required this.onDiscover, + required this.playing, + required this.playbackSpeed, + required this.onSpeedTap, + required this.controlsVisible, + required this.onTogglePlay, + required this.onClear, + required this.onExitClear, + required this.onBack, required this.onEpisodes, - required this.onMy, - required this.onSearch, - required this.onMenu, - required this.onMute, + required this.onComments, required this.onLike, required this.onSave, - required this.onComments, + required this.onDownload, required this.onShare, - required this.onFullscreen, required this.onPip, required this.onScrubStart, required this.onSeek, - required this.showBottomNav, + this.onNext, }); final AppLocalizations l10n; @@ -607,353 +642,446 @@ class _StitchHomeOverlay extends StatelessWidget { final VideoPlayerController? controller; final bool liked; final bool saved; - final bool muted; - final HomeFeedCategory category; - final ValueChanged onCategorySelected; - final ValueChanged onCategoryShift; - final VoidCallback onDetail; - final VoidCallback onDiscover; + final bool playing; + final double playbackSpeed; + final VoidCallback onSpeedTap; + final bool controlsVisible; + final VoidCallback onTogglePlay; + final VoidCallback onClear; + final VoidCallback onExitClear; + final VoidCallback? onBack; final VoidCallback onEpisodes; - final VoidCallback onMy; - final VoidCallback onSearch; - final VoidCallback? onMenu; - final VoidCallback onMute; + final VoidCallback onComments; + final VoidCallback? onNext; final VoidCallback onLike; final VoidCallback onSave; - final VoidCallback onComments; + final VoidCallback onDownload; final VoidCallback onShare; - final VoidCallback onFullscreen; final VoidCallback? onPip; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; - final bool showBottomNav; @override - Widget build(BuildContext context) => SafeArea( - bottom: showBottomNav, - child: Column( - children: [ - HomeFeedHeader( - l10n: l10n, - category: category, - onCategorySelected: onCategorySelected, - onCategoryShift: onCategoryShift, - onSearch: onSearch, - onMy: onMy, - onMenu: onMenu, - onMute: onMute, - muted: muted, - ), - const Spacer(), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 12, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _MetaChip(text: l10n.freeTestMedia, accent: true), - const SizedBox(height: 5), - Text( - localizedDramaTitle(l10n, drama), - style: const TextStyle( - color: Colors.white, - fontSize: 19, - fontWeight: FontWeight.w800, - shadows: [Shadow(blurRadius: 12, color: Colors.black)], - ), - ), - const SizedBox(height: 3), - Text( - localizedDramaSynopsis(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11, - height: 1.2, - color: Colors.white, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - Tooltip( - message: l10n.exploreSeries, - child: TextButton( - onPressed: onDetail, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size(0, 28), - foregroundColor: const Color(0xFFFFA18B), - ), - child: Text( - l10n.exploreSeries, - style: const TextStyle(fontSize: 12), - ), - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - _RailAction( - key: const Key('rail-save'), - icon: saved - ? Icons.bookmark_rounded - : Icons.bookmark_border_rounded, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - key: const Key('rail-comments'), - icon: Icons.mode_comment_outlined, - label: l10n.comments, - onTap: onComments, - ), - _RailAction( - key: const Key('rail-like'), - icon: liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - label: l10n.like, - selected: liked, - onTap: onLike, - ), - _RailAction( - key: const Key('rail-share'), - icon: Icons.share_rounded, - label: l10n.share, - onTap: onShare, - ), - ], - ), - ], - ), - ), - const SizedBox(height: 2), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Tooltip( - message: l10n.watchFullscreen, - child: TextButton.icon( - onPressed: onFullscreen, - icon: const Icon(Icons.screen_rotation_outlined, size: 15), - label: Text(l10n.watchFullscreen), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - textStyle: const TextStyle(fontSize: 11), - visualDensity: VisualDensity.compact, - ), - ), + State<_PlaybackOverlay> createState() => _PlaybackOverlayState(); +} + +class _PlaybackOverlayState extends State<_PlaybackOverlay> { + @override + Widget build(BuildContext context) { + return SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + key: const Key('player-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: widget.onTogglePlay, ), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - visualDensity: VisualDensity.compact, - iconSize: 17, - icon: const Icon(Icons.picture_in_picture_alt_outlined), - ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Tooltip( - message: l10n.episodes, - child: InkWell( - onTap: onEpisodes, - borderRadius: BorderRadius.circular(8), - child: Container( - height: 42, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: const Color(0xE6212125), - borderRadius: BorderRadius.circular(8), - ), - child: IconTheme( - data: const IconThemeData(color: Colors.white), - child: DefaultTextStyle.merge( - style: const TextStyle(color: Colors.white), - child: Row( - children: [ - const Icon(Icons.play_circle_outline_rounded, size: 19), - const SizedBox(width: 7), - Expanded( - child: Text( - '${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, + ), + if (widget.controlsVisible) + Column( + key: const Key('player-controls-visible'), + children: [ + _topBar(context), + const Spacer(), + Padding( + key: const Key('player-controls-bottom'), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 12), + child: Column( + children: [ + SeekProgressBar( + key: const Key('player-progress'), + valueListenable: widget.controller, + semanticsLabel: widget.l10n.seekVideo, + showTimes: true, + onScrubStart: widget.onScrubStart, + onSeek: widget.onSeek, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _PlayerAction( + key: const Key('player-episodes'), + icon: Icons.view_list_rounded, + label: widget.l10n.episodes, + onTap: widget.onEpisodes, ), ), - ), - Text( - '${l10n.episodes} (${drama.episodes.length})', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, + const SizedBox(width: 8), + Expanded( + child: _PlayerAction( + key: const Key('player-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: widget.l10n.comments, + onTap: widget.onComments, + ), ), - ), - const Icon(Icons.keyboard_arrow_up_rounded, size: 18), - ], - ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 32, + child: FilledButton( + key: const Key('player-next'), + onPressed: widget.onNext, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.subtle, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + widget.l10n.nextEpisode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.skip_next_rounded, + size: 14, + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + PlayerFeaturedComment( + drama: widget.drama, + onViewAll: widget.onComments, + ), + ], ), ), - ), - ), - ), - ), - const SizedBox(height: 2), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - onScrubStart: onScrubStart, - onSeek: onSeek, - compactIdle: true, - ), - ), - const SizedBox(height: 2), - if (showBottomNav) - Container( - height: 62, - color: const Color(0xF20C0C0F), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _BottomAction( - icon: Icons.play_circle_outline_rounded, - label: l10n.homeTab, - selected: true, - onTap: () {}, - ), - _BottomAction( - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: onDiscover, - ), - _BottomAction( - icon: Icons.video_library_outlined, - label: l10n.myTab, - onTap: onMy, - ), ], ), - ), - ], - ), - ); + if (!widget.controlsVisible) + Positioned( + top: 8, + right: 12, + child: ExitClearScreenButton( + key: const Key('player-exit-clear-screen'), + tooltip: widget.l10n.exitClearScreen, + onPressed: widget.onExitClear, + ), + ), + if (!widget.playing) + Center( + child: PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: false, + onTap: widget.onTogglePlay, + ), + ), + ], + ), + ); + } + + Widget _topBar(BuildContext context) { + return SizedBox( + key: const Key('player-controls-top'), + height: 46, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + if (widget.onBack != null) + _TopAction( + key: const Key('player-back'), + tooltip: widget.l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: widget.onBack!, + ) + else + const SizedBox(width: 36), + const SizedBox(width: 8), + Expanded( + child: Text( + '${localizedDramaTitle(widget.l10n, widget.drama)} · ' + '${widget.l10n.episodeLabel(widget.episode.number)}', + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + _TopAction( + key: const Key('player-clear-screen'), + tooltip: widget.l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: widget.onClear, + ), + const SizedBox(width: 8), + _TopAction( + key: const Key('player-more'), + tooltip: widget.l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ), + ); + } + + Future _openMore(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('player-more-speed'), + leading: const Icon( + Icons.speed_rounded, + color: AppColors.secondary, + ), + title: Text(widget.l10n.playbackSpeed), + trailing: Text( + _speedLabel(widget.playbackSpeed), + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onSpeedTap(); + }); + }, + ), + ListTile( + key: const Key('player-more-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, + ), + title: Text(widget.l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + widget.onDownload(); + }, + ), + if (widget.onPip != null) + ListTile( + key: const Key('player-more-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(widget.l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + widget.onPip!(); + }, + ), + ListTile( + key: const Key('player-more-like'), + leading: Icon( + widget.liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: widget.liked ? AppColors.blue : AppColors.muted, + ), + title: Text(widget.l10n.like), + onTap: () { + Navigator.pop(sheetContext); + widget.onLike(); + }, + ), + ListTile( + key: const Key('player-more-favorite'), + leading: Icon( + widget.saved ? Icons.star_rounded : Icons.star_border_rounded, + color: widget.saved ? AppColors.blue : AppColors.muted, + ), + title: Text(widget.l10n.favorite), + onTap: () { + Navigator.pop(sheetContext); + widget.onSave(); + }, + ), + ListTile( + key: const Key('player-more-share'), + leading: const Icon(Icons.share_outlined, color: AppColors.muted), + title: Text(widget.l10n.share), + onTap: () { + Navigator.pop(sheetContext); + widget.onShare(); + }, + ), + ], + ), + ), + ); + } } -class _MetaChip extends StatelessWidget { - const _MetaChip({required this.text, this.accent = false}); - final String text; - final bool accent; +const List _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + +String _speedLabel(double value) => '${value.toString()}x'; + +class _PlaybackSpeedSheet extends StatelessWidget { + const _PlaybackSpeedSheet({required this.selected}); + + final double selected; @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xB51F1F22), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - text, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: accent ? const Color(0xFFFFC2B0) : const Color(0xFFF7F4F2), + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SafeArea( + child: Column( + key: const Key('player-speed-sheet'), + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackSpeed, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + for (final value in _speedOptions) + ListTile( + key: Key('player-speed-${value.toString()}'), + dense: true, + tileColor: value == selected + ? AppColors.blue.withValues(alpha: 0.12) + : null, + title: Text( + _speedLabel(value), + style: TextStyle( + color: value == selected ? AppColors.blue : AppColors.cream, + fontSize: 13, + fontWeight: value == selected + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + trailing: value == selected + ? const Icon( + Icons.check_rounded, + color: AppColors.blue, + size: 18, + ) + : null, + onTap: () => Navigator.pop(context, value), + ), + ], ), - ), - ); + ); + } } -class _RailAction extends StatelessWidget { - const _RailAction({ +class _TopAction extends StatelessWidget { + const _TopAction({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _PlayerAction extends StatelessWidget { + const _PlayerAction({ super.key, required this.icon, required this.label, required this.onTap, - this.selected = false, - }); - final IconData icon; - final String label; - final VoidCallback? onTap; - final bool selected; - - @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton.filledTonal( - tooltip: label, - onPressed: onTap, - style: IconButton.styleFrom( - backgroundColor: const Color(0xB719191B), - foregroundColor: selected ? const Color(0xFFFF6B45) : Colors.white, - ), - icon: Icon(icon, size: 22), - ), - Text( - label, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - ], - ); -} - -class _BottomAction extends StatelessWidget { - const _BottomAction({ - required this.icon, - required this.label, - required this.onTap, - this.selected = false, }); final IconData icon; final String label; final VoidCallback onTap; - final bool selected; @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA), + Widget build(BuildContext context) { + return Material( + color: const Color(0xFF2A2A30), + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: AppColors.secondary), + const SizedBox(width: 3), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], ), - const SizedBox(height: 2), - Text( - label, - style: TextStyle( - fontSize: 11, - color: selected ? const Color(0xFFFF6B45) : const Color(0xFFF3EEEA), - ), - ), - ], + ), ), - ), - ); + ); + } } diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart new file mode 100644 index 0000000..56c1c19 --- /dev/null +++ b/lib/pages/player/widgets/comment_tile.dart @@ -0,0 +1,151 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +const String _deleteAction = 'delete'; +const String _reportAction = 'report'; + +/// 评论条目的交互回调集合。 +class CommentInteractionHandlers { + const CommentInteractionHandlers({ + required this.onReply, + required this.onLike, + required this.onDelete, + required this.onReport, + }); + + final ValueChanged onReply; + final Future Function(DramaComment) onLike; + final Future Function(DramaComment) onDelete; + final Future Function(DramaComment) onReport; +} + +/// 单条评论或回复的展示与操作行。 +class CommentTile extends StatelessWidget { + const CommentTile({ + super.key, + required this.comment, + required this.controller, + required this.handlers, + this.isReply = false, + }); + + final DramaComment comment; + final CommentController controller; + final CommentInteractionHandlers handlers; + final bool isReply; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final name = comment.nickname.isEmpty + ? l10n.anonymousUser + : comment.nickname; + return Padding( + key: Key('comment-item-${comment.id}'), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: isReply ? 12 : 16, + backgroundColor: AppColors.raised, + child: Icon( + Icons.person_rounded, + size: isReply ? 14 : 18, + color: AppColors.cream, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + name, + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + ), + PopupMenuButton( + key: Key('comment-menu-${comment.id}'), + tooltip: l10n.more, + onSelected: (value) { + if (value == _deleteAction) { + handlers.onDelete(comment); + } else if (value == _reportAction) { + handlers.onReport(comment); + } + }, + itemBuilder: (_) => [ + if (comment.mine) + PopupMenuItem( + value: _deleteAction, + child: Text(l10n.deleteComment), + ), + PopupMenuItem( + value: _reportAction, + child: Text(l10n.reportComment), + ), + ], + ), + ], + ), + Text( + comment.content, + style: const TextStyle(color: AppColors.cream, fontSize: 14), + ), + if (comment.isPending) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + l10n.underReview, + key: Key('comment-pending-${comment.id}'), + style: const TextStyle( + color: AppColors.blueSoft, + fontSize: AppDimens.caption, + ), + ), + ), + Row( + children: [ + TextButton.icon( + key: Key('comment-like-${comment.id}'), + onPressed: controller.likingComment(comment.id) + ? null + : () => handlers.onLike(comment), + icon: Icon( + comment.liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + size: 16, + ), + label: Text('${comment.likeCount}'), + ), + if (!isReply) + TextButton( + key: Key('comment-reply-${comment.id}'), + onPressed: () => handlers.onReply(comment), + child: Text(l10n.reply), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index f819f5c..e95d820 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -1,12 +1,30 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../../core/data/models/comment.dart'; import '../../../core/data/models/drama.dart'; -import '../../../core/data/services/engagement_controller.dart'; +import '../../../core/data/repositories/comment_repository.dart'; +import '../../../core/data/repositories/interaction_repository.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/formatting/error_text.dart'; +import '../../../core/platform/haptics.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/auth_gate.dart'; +import '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; +import '../../interaction/widgets/report_dialog.dart'; +import 'comment_tile.dart'; +const double _minSheetHeight = 280; +const double _maxSheetHeight = 520; +const int _maxCommentLength = 300; +const double _composerRadius = 22; + +/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 class DramaCommentsSheet extends StatefulWidget { const DramaCommentsSheet({super.key, required this.drama}); @@ -18,26 +36,124 @@ class DramaCommentsSheet extends StatefulWidget { class _DramaCommentsSheetState extends State { final _input = TextEditingController(); + final _inputFocus = FocusNode(); + late final CommentController _controller; + DramaComment? _replyTo; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + dramaId: widget.drama.id, + ); + unawaited(_controller.load()); + } @override void dispose() { + _controller.dispose(); _input.dispose(); + _inputFocus.dispose(); super.dispose(); } - void _post() { - final text = _input.text.trim(); - if (text.isEmpty) return; - context.read().addComment(widget.drama.id, text); - _input.clear(); + Future _post() async { + if (_controller.submitting) return; + if (_input.text.trim().isEmpty) return; + if (!await ensureSignedIn(context) || !mounted) return; + unawaited(context.read().lightImpact()); + final result = await _controller.publish(_input.text, replyTo: _replyTo); + if (!mounted) return; + if (result.isSuccess) { + _input.clear(); + setState(() => _replyTo = null); + return; + } + _showMessage( + result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed, + ); + } + + void _beginReply(DramaComment comment) { + setState(() => _replyTo = comment); + _inputFocus.requestFocus(); + } + + void _cancelReply() { + if (_replyTo == null) return; + setState(() => _replyTo = null); + } + + Future _like(DramaComment comment) async { + if (!await ensureSignedIn(context) || !mounted) return; + unawaited(context.read().lightImpact()); + final result = await _controller.toggleLike(comment); + if (!result.isSuccess && mounted) { + _showMessage( + result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed, + ); + } + } + + Future _confirmDelete(DramaComment comment) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.deleteCommentTitle), + content: Text(l10n.deleteCommentMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + key: const Key('comment-delete-confirm'), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.delete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + final result = await _controller.delete(comment); + if (!result.isSuccess && mounted) { + _showMessage( + result.errorMessage ?? + AppLocalizations.of(context)!.commentDeleteFailed, + ); + } + } + + Future _report(DramaComment comment) async { + if (!await ensureSignedIn(context) || !mounted) return; + final l10n = AppLocalizations.of(context)!; + final draft = await showReportDialog(context, title: l10n.reportComment); + if (draft == null || !mounted) return; + unawaited(context.read().lightImpact()); + final result = await _controller.report( + comment, + reason: draft.reason, + description: draft.description, + ); + if (!mounted) return; + _showMessage( + result.isSuccess + ? l10n.reportSubmitted + : result.errorMessage ?? l10n.reportFailed, + ); + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final comments = context.watch().commentsFor( - widget.drama.id, - ); final available = MediaQuery.sizeOf(context).height - MediaQuery.viewInsetsOf(context).bottom - @@ -45,83 +161,249 @@ class _DramaCommentsSheetState extends State { return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: SizedBox( - height: available.clamp(240.0, 460.0), + height: available.clamp(_minSheetHeight, _maxSheetHeight), child: SafeArea( top: false, child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.comments, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - l10n.localOnlyComments, - style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 13), - ), - const SizedBox(height: 14), - Expanded( - child: comments.isEmpty - ? Center( - child: Text( - l10n.noComments, - style: const TextStyle(color: Color(0xFFE6E1E3)), - ), - ) - : ListView.separated( - itemCount: comments.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) => ListTile( - leading: const CircleAvatar( - radius: 16, - child: Icon(Icons.person_rounded, size: 18), - ), - title: Text(comments[index]), - contentPadding: EdgeInsets.zero, - ), - ), - ), - Row( - children: [ - Expanded( - child: TextField( - key: const Key('comment-input'), - controller: _input, - maxLength: 300, - maxLines: 1, - decoration: InputDecoration( - hintText: l10n.writeComment, - counterText: '', - filled: true, - fillColor: const Color(0xFF35353A), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, - ), - ), - onSubmitted: (_) => _post(), - ), - ), - const SizedBox(width: 8), - IconButton.filled( - key: const Key('comment-post'), - tooltip: l10n.postComment, - onPressed: _post, - icon: const Icon(Icons.send_rounded), - ), - ], - ), - ], + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => _content(context), ), ), ), ), ); } + + Widget _content(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.comments, + style: const TextStyle( + color: AppColors.cream, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Expanded(child: _list(l10n)), + if (_replyTo != null) _replyBanner(l10n, _replyTo!), + _composer(l10n), + ], + ); + } + + Widget _list(AppLocalizations l10n) { + if (_controller.loading && _controller.comments.isEmpty) { + return const Center( + child: CircularProgressIndicator(key: Key('comment-list-loading')), + ); + } + if (_controller.error != null && _controller.comments.isEmpty) { + return EmptyState( + key: const Key('comment-list-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, _controller.error, l10n.commentsLoadFailed), + actionLabel: l10n.retry, + actionKey: const Key('comment-list-retry'), + onAction: _controller.load, + compact: true, + ); + } + if (_controller.comments.isEmpty) { + return EmptyState( + key: const Key('comment-list-empty'), + icon: Icons.chat_bubble_outline_rounded, + title: l10n.noComments, + compact: true, + ); + } + return ListView.builder( + key: const Key('comment-list'), + itemCount: _controller.comments.length + 1, + itemBuilder: (context, index) { + if (index == _controller.comments.length) return _footer(l10n); + return _CommentThread( + comment: _controller.comments[index], + controller: _controller, + handlers: CommentInteractionHandlers( + onReply: _beginReply, + onLike: _like, + onDelete: _confirmDelete, + onReport: _report, + ), + ); + }, + ); + } + + Widget _footer(AppLocalizations l10n) { + if (_controller.loadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_controller.hasMore) { + return TextButton( + key: const Key('comment-list-load-more'), + onPressed: _controller.loadMore, + child: Text(l10n.loadMore), + ); + } + return const SizedBox(height: 8); + } + + Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Expanded( + child: Text( + l10n.replyingTo( + replyTo.nickname.isEmpty ? l10n.anonymousUser : replyTo.nickname, + ), + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ), + IconButton( + key: const Key('comment-cancel-reply'), + tooltip: l10n.cancelReply, + onPressed: _cancelReply, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close_rounded, size: 18), + ), + ], + ), + ); + + InputDecoration _composerDecoration(String hint) { + const radius = BorderRadius.all(Radius.circular(_composerRadius)); + const clear = OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ); + return InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), + counterText: '', + filled: true, + fillColor: AppColors.raised, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: clear, + enabledBorder: clear, + focusedBorder: clear, + ); + } + + Widget _composer(AppLocalizations l10n) => Row( + children: [ + Expanded( + child: TextField( + key: const Key('comment-input'), + controller: _input, + focusNode: _inputFocus, + maxLength: _maxCommentLength, + maxLines: 1, + style: const TextStyle(color: AppColors.cream, fontSize: 14), + cursorColor: AppColors.gold, + decoration: _composerDecoration(l10n.writeComment), + onSubmitted: (_) => _post(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + key: const Key('comment-post'), + tooltip: l10n.postComment, + onPressed: _controller.submitting ? null : _post, + icon: _controller.submitting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send_rounded), + ), + ], + ); +} + +class _CommentThread extends StatelessWidget { + const _CommentThread({ + required this.comment, + required this.controller, + required this.handlers, + }); + + final DramaComment comment; + final CommentController controller; + final CommentInteractionHandlers handlers; + + @override + Widget build(BuildContext context) { + final replies = controller.repliesFor(comment.id); + final expanded = controller.repliesExpanded(comment.id); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommentTile( + comment: comment, + controller: controller, + handlers: handlers, + ), + if (comment.replyCount > 0 || replies.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 44, bottom: 8), + child: TextButton( + key: Key('comment-replies-${comment.id}'), + onPressed: () => _toggleReplies(context), + child: Text( + expanded + ? AppLocalizations.of(context)!.hideReplies + : AppLocalizations.of( + context, + )!.viewReplies(comment.replyCount), + ), + ), + ), + if (expanded) + Padding( + padding: const EdgeInsets.only(left: 28), + child: Column( + children: [ + if (controller.repliesLoading(comment.id)) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: CircularProgressIndicator(), + ), + for (final reply in replies) + CommentTile( + comment: reply, + controller: controller, + handlers: handlers, + isReply: true, + ), + if (controller.repliesHasMore(comment.id)) + TextButton( + onPressed: () => controller.loadMoreReplies(comment.id), + child: Text(AppLocalizations.of(context)!.loadMore), + ), + ], + ), + ), + const Divider(height: 1, color: AppColors.outlineVariantDark), + ], + ); + } + + Future _toggleReplies(BuildContext context) async { + if (controller.repliesExpanded(comment.id)) { + controller.hideReplies(comment.id); + return; + } + await controller.loadReplies(comment.id); + } } diff --git a/lib/pages/player/widgets/episode_picker_cell.dart b/lib/pages/player/widgets/episode_picker_cell.dart new file mode 100644 index 0000000..b5069b7 --- /dev/null +++ b/lib/pages/player/widgets/episode_picker_cell.dart @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/theme/app_colors.dart'; + +const double _cellRadius = 8; +const double _numberSize = 15; +const double _lockSize = 16; +const double _lockGap = 4; +const double _lockWeight = 300; +const double _coverAlpha = 0.45; + +/// 选集格子。需要解锁时铺一层透明底,锁在上,集数在锁下面。 +class EpisodePickerCell extends StatelessWidget { + const EpisodePickerCell({ + required this.episode, + required this.selected, + required this.lockLabel, + required this.onTap, + super.key, + }); + + final Episode episode; + final bool selected; + final String lockLabel; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + clipBehavior: Clip.antiAlias, + color: selected ? AppColors.bluePressed : AppColors.raised, + borderRadius: BorderRadius.circular(_cellRadius), + child: InkWell( + onTap: onTap, + child: Stack( + fit: StackFit.expand, + children: [ + if (!episode.locked) Center(child: _EpisodeNumber(episode.number)), + if (episode.locked) + _LockCover( + episodeId: episode.id, + number: episode.number, + label: lockLabel, + ), + ], + ), + ), + ); + } +} + +class _EpisodeNumber extends StatelessWidget { + const _EpisodeNumber(this.number); + + final int number; + + @override + Widget build(BuildContext context) { + return Text( + '$number', + style: const TextStyle( + color: AppColors.cream, + fontSize: _numberSize, + fontWeight: FontWeight.w600, + height: 1, + ), + ); + } +} + +/// 透明底铺满格子,锁在上,集数在锁下面。 +class _LockCover extends StatelessWidget { + const _LockCover({ + required this.episodeId, + required this.number, + required this.label, + }); + + final String episodeId; + final int number; + final String label; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + key: ValueKey('episode-lock-cover-$episodeId'), + color: AppColors.ink.withValues(alpha: _coverAlpha), + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline, + key: ValueKey('episode-lock-$episodeId'), + size: _lockSize, + weight: _lockWeight, + fill: 0, + color: AppColors.gold, + semanticLabel: label, + ), + const SizedBox(height: _lockGap), + _EpisodeNumber(number), + ], + ), + ), + ], + ); + } +} diff --git a/lib/pages/player/widgets/exit_clear_screen_button.dart b/lib/pages/player/widgets/exit_clear_screen_button.dart new file mode 100644 index 0000000..a2b1c51 --- /dev/null +++ b/lib/pages/player/widgets/exit_clear_screen_button.dart @@ -0,0 +1,34 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +/// Shared floating action used to leave clear-screen mode. +class ExitClearScreenButton extends StatelessWidget { + const ExitClearScreenButton({ + super.key, + required this.tooltip, + required this.onPressed, + }); + + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0x80000000), + foregroundColor: Colors.white, + shape: const CircleBorder(), + ), + icon: const Icon(Icons.fullscreen_exit_rounded, size: 18), + ), + ); + } +} diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart new file mode 100644 index 0000000..3fc5c00 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -0,0 +1,422 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/glass_panel.dart'; +import '../../../l10n/app_localizations.dart'; + +/// Home feed layer from `docs/移动端设计预览.html` screen 01. +class HomeFeedOverlay extends StatelessWidget { + const HomeFeedOverlay({ + super.key, + required this.l10n, + required this.drama, + required this.episode, + required this.liked, + required this.saved, + required this.onLike, + required this.onSave, + required this.onComments, + required this.onShare, + required this.onEpisodes, + required this.onDownload, + required this.onClear, + required this.onSearch, + this.onPip, + }); + + final AppLocalizations l10n; + final Drama drama; + final Episode episode; + final bool liked; + final bool saved; + final VoidCallback onLike; + final VoidCallback onSave; + final VoidCallback onComments; + final VoidCallback onShare; + final VoidCallback onEpisodes; + final VoidCallback onDownload; + final VoidCallback onClear; + final VoidCallback onSearch; + final VoidCallback? onPip; + + @override + Widget build(BuildContext context) { + return SafeArea( + bottom: false, + child: Column( + children: [ + _header(context), + Expanded( + child: Align( + alignment: const Alignment(1.0, 0.45), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 12, 16), + child: _rail(), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + child: _infoCard(), + ), + ], + ), + ); + } + + Widget _header(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 6, 10, 4), + child: Row( + children: [ + Expanded( + child: Text( + localizedDramaTitle(l10n, drama), + key: const Key('home-feed-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), + ), + ), + _RoundIcon( + key: const Key('home-feed-search'), + tooltip: l10n.search, + icon: Icons.search_rounded, + onPressed: onSearch, + ), + const SizedBox(width: 8), + _RoundIcon( + key: const Key('home-feed-clear-screen'), + tooltip: l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: onClear, + ), + const SizedBox(width: 8), + _RoundIcon( + key: const Key('home-feed-more'), + tooltip: l10n.more, + icon: Icons.menu_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ); + } + + Widget _rail() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _RailItem( + key: const Key('rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + label: l10n.like, + selected: liked, + onTap: onLike, + ), + _RailItem( + key: const Key('rail-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + ), + _RailItem( + key: const Key('rail-share'), + icon: Icons.ios_share_rounded, + label: l10n.share, + onTap: onShare, + ), + _RailItem( + key: const Key('rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: l10n.favorite, + selected: saved, + onTap: onSave, + ), + ], + ); + } + + Widget _infoCard() { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; + return GlassPanel( + key: const Key('home-feed-info-card'), + radius: 14, + blur: 6, + padding: const EdgeInsets.all(12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xBF0F0F14), Color(0xA60F0F14)], + ), + borderColor: Colors.white.withValues(alpha: 0.06), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (genre.isNotEmpty) + _tag( + genre, + background: AppColors.blue, + foreground: AppColors.onPrimary, + ), + if (drama.category.isNotEmpty && drama.category != genre) + _tag( + drama.category, + background: const Color(0xFF333333), + foreground: const Color(0xFFDDDDDD), + ), + Text( + l10n.episodeOfTotal(episode.number, drama.episodeCount), + key: const Key('home-feed-episode'), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ), + const SizedBox(height: 8), + Text( + episode.title.trim().isEmpty + ? localizedDramaTitle(l10n, drama) + : episode.title, + key: const Key('home-feed-episode-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: SizedBox( + height: 34, + child: FilledButton.icon( + key: const Key('home-feed-follow-series'), + onPressed: onSave, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 16), + label: Text( + saved ? l10n.followingTab : l10n.followSeries, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 34, + child: FilledButton( + key: const Key('home-feed-episodes'), + onPressed: onEpisodes, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2A2A30), + foregroundColor: AppColors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: Text( + l10n.episodes, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _tag( + String label, { + required Color background, + required Color foreground, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: foreground, + fontSize: 8, + fontWeight: FontWeight.w800, + ), + ), + ); + } + + Future _openMore(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('home-feed-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + onDownload(); + }, + ), + if (onPip != null) + ListTile( + key: const Key('home-feed-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + onPip!(); + }, + ), + ], + ), + ), + ); + } +} + +class _RoundIcon extends StatelessWidget { + const _RoundIcon({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xCC2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _RailItem extends StatelessWidget { + const _RailItem({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.selected = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool selected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Semantics( + button: true, + label: label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: SizedBox( + width: 44, + child: Column( + children: [ + Icon( + icon, + size: 22, + color: selected ? AppColors.blue : AppColors.onSurface, + ), + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 9, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart new file mode 100644 index 0000000..b15e355 --- /dev/null +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -0,0 +1,41 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +/// Shared play/pause control used by the home feed and full-screen player. +class PlayerCenterToggle extends StatelessWidget { + const PlayerCenterToggle({ + super.key, + required this.playing, + required this.onTap, + }); + + final bool playing; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: playing ? 'Pause' : 'Play', + child: GestureDetector( + onTap: onTap, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: Icon( + playing ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 26, + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart new file mode 100644 index 0000000..e888ba8 --- /dev/null +++ b/lib/pages/player/widgets/player_featured_comment.dart @@ -0,0 +1,158 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/repositories/comment_repository.dart'; +import '../../../core/data/repositories/interaction_repository.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +class PlayerFeaturedComment extends StatefulWidget { + const PlayerFeaturedComment({ + super.key, + required this.drama, + required this.onViewAll, + }); + + final Drama drama; + final VoidCallback onViewAll; + + @override + State createState() => _PlayerFeaturedCommentState(); +} + +class _PlayerFeaturedCommentState extends State { + late final CommentController _controller; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + dramaId: widget.drama.id, + ); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + if (_controller.comments.isEmpty) return const SizedBox.shrink(); + return _card(context, _controller.comments.first); + }, + ); + } + + Widget _card(BuildContext context, DramaComment comment) { + final l10n = AppLocalizations.of(context)!; + final time = _time(l10n, comment.createdAt); + return InkWell( + key: const Key('player-featured-comment'), + onTap: widget.onViewAll, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.secondary.withValues(alpha: 0.2), + ), + child: Text( + _initial(comment.nickname), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + comment.nickname.isEmpty + ? l10n.localViewer + : comment.nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + if (time != null) ...[ + const SizedBox(width: 4), + Text( + '· $time', + style: const TextStyle( + color: Color(0xFF777777), + fontSize: 9, + ), + ), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 5), + Text( + comment.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFDDDDDD), + fontSize: 10, + height: 1.5, + ), + ), + ], + ), + ), + ); + } + + String? _time(AppLocalizations l10n, DateTime? createdAt) { + if (createdAt == null) return null; + final diff = DateTime.now().difference(createdAt); + if (diff.inMinutes < 1) return null; + return l10n.minutesAgo(diff.inMinutes); + } + + String _initial(String nickname) { + final trimmed = nickname.trim(); + return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + } +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 9dfc2cd..6680fa1 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; +import '../../../core/theme/app_colors.dart'; + /// Keeps seeks within the playable range so dragging to the edge does not /// trigger the player's completion callback before playback resumes. Duration seekPositionForFraction(double fraction, Duration duration) { @@ -24,7 +26,7 @@ class SeekProgressBar extends StatefulWidget { this.onScrubStart, this.onSeek, this.showTimes = true, - this.compactIdle = false, + this.compact = false, }); final ValueListenable? valueListenable; @@ -32,7 +34,7 @@ class SeekProgressBar extends StatefulWidget { final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; final bool showTimes; - final bool compactIdle; + final bool compact; @override State createState() => _SeekProgressBarState(); @@ -101,19 +103,41 @@ class _SeekProgressBarState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ + if (widget.showTimes && scrubbing) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '${_format(previewPosition)} / ${_format(video.duration)}', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + ), SizedBox( - height: widget.compactIdle && !scrubbing ? 20 : 36, + height: widget.compact + ? (scrubbing ? 32 : 20) + : (scrubbing ? 48 : 44), child: SliderTheme( data: SliderTheme.of(context).copyWith( - trackHeight: widget.compactIdle && !scrubbing ? 2 : 3, - activeTrackColor: const Color(0xFFFF5C37), - inactiveTrackColor: Colors.white30, - thumbColor: Colors.white, + trackHeight: widget.compact + ? (scrubbing ? 3 : 1.5) + : (scrubbing ? 4 : 3), + activeTrackColor: AppColors.blue, + inactiveTrackColor: widget.compact + ? Colors.white.withValues(alpha: 0.18) + : const Color(0xFF474248), + thumbColor: widget.compact ? AppColors.blue : Colors.white, thumbShape: RoundSliderThumbShape( - enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6, + enabledThumbRadius: widget.compact + ? (scrubbing ? 6 : 4) + : (scrubbing ? 8 : 6), ), overlayShape: RoundSliderOverlayShape( - overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11, + overlayRadius: widget.compact ? 12 : 18, ), ), child: Semantics( @@ -136,20 +160,6 @@ class _SeekProgressBarState extends State { ), ), ), - if (widget.showTimes && (!widget.compactIdle || scrubbing)) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _format(previewPosition), - style: const TextStyle(fontSize: 12, color: Colors.white), - ), - Text( - _format(video.duration), - style: const TextStyle(fontSize: 12, color: Colors.white), - ), - ], - ), ], ); } diff --git a/lib/pages/splash/splash_page.dart b/lib/pages/splash/splash_page.dart new file mode 100644 index 0000000..2eac25c --- /dev/null +++ b/lib/pages/splash/splash_page.dart @@ -0,0 +1,53 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; + +const double _markSize = 84; +const double _markRadius = 18; +const double _loaderSize = 22; +const double _loaderStroke = 2.5; +const String _markAsset = 'assets/icon/splash_mark.png'; + +/// 系统开屏已经放好同一块底和图标。这一页接上加载,直到首页数据回来。 +class SplashPage extends StatelessWidget { + const SplashPage({super.key}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + key: Key('splash-screen'), + color: AppColors.ink, + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(_markRadius), + child: const Image( + image: AssetImage(_markAsset), + width: _markSize, + height: _markSize, + filterQuality: FilterQuality.medium, + gaplessPlayback: true, + ), + ), + ), + Align( + alignment: Alignment(0, 0.24), + child: SizedBox( + width: _loaderSize, + height: _loaderSize, + child: CircularProgressIndicator( + key: Key('splash-loading'), + strokeWidth: _loaderStroke, + color: AppColors.gold, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/watch_records/watch_records_binding.dart b/lib/pages/watch_records/watch_records_binding.dart new file mode 100644 index 0000000..80ab1ef --- /dev/null +++ b/lib/pages/watch_records/watch_records_binding.dart @@ -0,0 +1,14 @@ +// Author: fruit.com.cn + +import '../../core/data/repositories/catalog_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/watch_records_controller.dart'; + +/// 观看记录页依赖装配。 +class WatchRecordsBinding { + /// 创建独立页面控制器。 + WatchRecordsController create({ + required WatchHistoryRepository history, + required CatalogRepository catalog, + }) => WatchRecordsController(history: history, catalog: catalog); +} diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart new file mode 100644 index 0000000..d5250c5 --- /dev/null +++ b/lib/pages/watch_records/watch_records_page.dart @@ -0,0 +1,389 @@ +// 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/watch_records_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_date_time.dart'; +import '../../core/platform/haptics.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/remote_image.dart'; +import '../../l10n/app_localizations.dart'; +import '../drama_detail/drama_detail_view.dart'; +import 'watch_records_binding.dart'; + +/// 独立观看记录页。 +class WatchRecordsPage extends StatelessWidget { + const WatchRecordsPage({super.key}); + + @override + Widget build(BuildContext context) { + final signedIn = context.watch().isSignedIn; + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('watch-records-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: Colors.white, + title: Text(l10n.watchHistory), + ), + body: SafeArea( + top: false, + child: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(), + ), + ), + ); + } +} + +class _WatchRecordsBody extends StatefulWidget { + const _WatchRecordsBody(); + + @override + State<_WatchRecordsBody> createState() => _WatchRecordsBodyState(); +} + +class _WatchRecordsBodyState extends State<_WatchRecordsBody> { + late final WatchRecordsController controller; + + @override + void initState() { + super.initState(); + controller = WatchRecordsBinding().create( + history: context.read(), + catalog: context.read(), + ); + unawaited(controller.load()); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) => _content(context), + ); + } + + Widget _content(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (controller.loading && controller.records.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.error != null && controller.records.isEmpty) { + return _FailureState( + message: errorText(l10n, controller.error, l10n.progressError), + onRetry: controller.load, + ); + } + if (controller.records.isEmpty) { + return EmptyState( + key: const Key('watch-records-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + ); + } + return Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + key: const Key('watch-records-clear'), + onPressed: controller.clearing ? null : _confirmClear, + icon: const Icon(Icons.delete_sweep_outlined), + label: Text(l10n.clearAll), + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: controller.load, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 28), + itemCount: controller.records.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final record = controller.records[index]; + return _RecordTile( + record: record, + onRetry: () => controller.retryDetail(record.dramaId), + onRemove: () => _remove(record.dramaId), + onOpen: record.drama == null + ? null + : () => _open(record.drama!), + ); + }, + ), + ), + ), + ], + ); + } + + Future _open(Drama drama) async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), + ); + } + + Future _remove(String dramaId) async { + unawaited(context.read().mediumImpact()); + final ok = await controller.remove(dramaId); + if (!ok && mounted) _showFailure('recordDeleteFailed'); + } + + Future _confirmClear() async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.clearWatchHistoryTitle), + content: Text(l10n.clearWatchHistoryMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.confirmClear), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + unawaited(context.read().mediumImpact()); + final ok = await controller.clear(); + if (!ok && mounted) _showFailure('recordClearFailed'); + } + + void _showFailure(String key) { + final l10n = AppLocalizations.of(context)!; + final message = key == 'recordDeleteFailed' + ? l10n.recordDeleteFailed + : l10n.recordClearFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _RecordTile extends StatelessWidget { + const _RecordTile({ + required this.record, + required this.onRetry, + required this.onRemove, + this.onOpen, + }); + + final WatchRecord record; + final VoidCallback onRetry; + final VoidCallback onRemove; + final VoidCallback? onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (record.detailFailed) { + return _FailedRecordTile(record: record, onRetry: onRetry); + } + final drama = record.drama; + if (drama == null || record.detailLoading) { + return Container( + key: ValueKey('watch-record-${record.dramaId}'), + height: AppDimens.watchPlaceholderHeight, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + ), + child: const Padding( + padding: EdgeInsets.all(AppDimens.innerPadding), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + ); + } + final episodeLabel = record.episodeNumber > 0 + ? l10n.episodeLabel(record.episodeNumber) + : l10n.episodeUnavailable; + return Material( + key: ValueKey('watch-record-${record.dramaId}'), + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.all(AppDimens.innerPadding), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RemoteImage(drama.posterUrl, width: 72, height: 96), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 5), + Text( + episodeLabel, + key: ValueKey('watch-record-episode-${record.dramaId}'), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.progressRadius, + ), + child: LinearProgressIndicator( + minHeight: AppDimens.progressHeight, + value: record.progress.fraction, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation( + AppColors.blueSoft, + ), + ), + ), + const SizedBox(height: 6), + Text( + l10n.resumeProgress( + _clock(record.progress.position), + _clock(record.progress.duration), + ), + style: const TextStyle( + color: AppColors.blueSoft, + fontSize: AppDimens.caption, + ), + ), + if (record.progress.updatedAt != null) + Text( + l10n.updatedAtLabel( + formatLocalizedDateTime( + context, + record.progress.updatedAt!, + ), + ), + key: ValueKey('watch-record-updated-${record.dramaId}'), + style: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.caption, + ), + ), + ], + ), + ), + IconButton( + key: ValueKey('watch-record-remove-${record.dramaId}'), + tooltip: l10n.remove, + onPressed: onRemove, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + ), + ); + } +} + +class _FailedRecordTile extends StatelessWidget { + const _FailedRecordTile({required this.record, required this.onRetry}); + + final WatchRecord record; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: ValueKey('watch-record-failed-${record.dramaId}'), + padding: const EdgeInsets.all(AppDimens.innerPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.innerRadius), + ), + child: Row( + children: [ + const Icon(Icons.broken_image_outlined, color: Colors.white38), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.detailLoadFailed, + style: const TextStyle(color: Colors.white70), + ), + ), + TextButton( + key: ValueKey('watch-record-retry-${record.dramaId}'), + onPressed: onRetry, + child: Text(l10n.retryDetail), + ), + ], + ), + ); + } +} + +class _FailureState extends StatelessWidget { + const _FailureState({required this.message, required this.onRetry}); + + final String message; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return EmptyState( + icon: Icons.cloud_off_rounded, + title: message, + actionLabel: l10n.retry, + actionKey: const Key('watch-records-retry'), + onAction: () => unawaited(onRetry()), + ); + } +} + +String _clock(Duration value) { + final minutes = value.inMinutes; + final seconds = value.inSeconds.remainder(60); + return '$minutes:${seconds.toString().padLeft(2, '0')}'; +} diff --git a/pubspec.lock b/pubspec.lock index ee2e934..2db1ec3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" fake_async: dependency: transitive description: @@ -118,6 +134,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -149,6 +170,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" html: dependency: transitive description: @@ -157,6 +183,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.15.7" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" image: dependency: transitive description: @@ -165,6 +199,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.12.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: transitive description: @@ -173,6 +212,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" json_annotation: dependency: transitive description: @@ -237,6 +292,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" nested: dependency: transitive description: @@ -245,6 +308,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -301,6 +372,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.6" provider: dependency: "direct main" description: @@ -321,18 +400,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + sha256: b90caa27669d5f3ef9918d55343b44000f088ee9d4685ed822310848004c793e url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.28" + version: "2.5.0" shared_preferences_foundation: - dependency: transitive + dependency: "direct overridden" description: name: shared_preferences_foundation - sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.7" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -402,6 +481,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -418,6 +505,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -470,10 +565,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" url: "https://pub.flutter-io.cn" source: hosted - version: "15.3.0" + version: "15.4.0" web: dependency: transitive description: @@ -482,6 +577,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fb5bbc0..d184d02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ # Author: fruit.com.cn -name: nontonki -description: "Nontonki short-drama playback prototype." +name: fruitshorts +description: "FruitShorts short-drama playback app (Indonesia first)." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -36,6 +36,8 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + # 网络层用 dio(统一拦截器/超时/错误映射);纯 Dart 无原生插件,仍然不引代码生成与图片缓存库 + dio: ^5.7.0 provider: ^6.1.5+1 video_player: ^2.14.0 shared_preferences: ^2.5.5 @@ -46,6 +48,11 @@ dev_dependencies: flutter_test: sdk: flutter + # 设备侧集成测试:真机/模拟器进程里真实发请求,验证"设备能不能连到开发机后端" + # (红线第 10 条:设备验证必须设备侧取证,不能用开发机 curl 充数) + integration_test: + sdk: flutter + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your @@ -55,24 +62,45 @@ dev_dependencies: shared_preferences_platform_interface: ^2.4.2 flutter_launcher_icons: ^0.14.4 +# Xcode 16.2 / Swift 6.0.3 cannot parse the trailing comma in foundation 2.6.0. +dependency_overrides: + shared_preferences_foundation: 2.5.6 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter packages. flutter: generate: true - assets: - - assets/media/demo_clip.mp4 - - assets/images/demo_poster.jpg - - assets/images/stitch_signal_poster_clean.png - - assets/images/stitch_city_poster_clean.png - - assets/images/stitch_vengeance_poster_clean.png - - assets/images/stitch_rain_scene.jpg + # 封面与视频全部由后端下发(/files/**),本地不再打包演示素材: + # 原先 assets/media 下的演示视频有 316MB,会把安装包撑到无法接受的大小。 # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/icon/splash_mark.png + + fonts: + - family: Outfit + fonts: + - asset: assets/fonts/Outfit-Regular.ttf + weight: 400 + - asset: assets/fonts/Outfit-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Outfit-Bold.ttf + weight: 700 + - family: PlusJakartaSans + fonts: + - asset: assets/fonts/PlusJakartaSans-Regular.ttf + weight: 400 + - asset: assets/fonts/PlusJakartaSans-Medium.ttf + weight: 500 + - asset: assets/fonts/PlusJakartaSans-SemiBold.ttf + weight: 600 + - asset: assets/fonts/PlusJakartaSans-Bold.ttf + weight: 700 # To add assets to your application, add an assets section, like this: # assets: @@ -108,7 +136,7 @@ flutter: flutter_launcher_icons: android: true ios: true - image_path: assets/icon/nontonki.png - adaptive_icon_background: "#D74A7D" - adaptive_icon_foreground: assets/icon/nontonki.png + image_path: assets/icon/fruitshorts.png + adaptive_icon_background: "#C4322A" + adaptive_icon_foreground: assets/icon/fruitshorts.png remove_alpha_ios: true diff --git a/test/account/account_api_test.dart b/test/account/account_api_test.dart new file mode 100644 index 0000000..60eaadf --- /dev/null +++ b/test/account/account_api_test.dart @@ -0,0 +1,174 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/account_profile.dart'; +import 'package:fruitshorts/core/data/remote/account_api.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + String envelope(Object? data, {int code = 0, String message = 'ok'}) => + jsonEncode({'code': code, 'message': message, 'data': data}); + + AccountApi apiWith(FakeHttpAdapter adapter) => + AccountApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)); + + test('should_get_profile_and_parse_numeric_vip_level', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope({ + 'uid': 'U-K19', + 'nickname': 'Nadia', + 'avatarUrl': 'https://cdn/avatar.png', + 'gender': 'female', + 'countryCode': 'ID', + 'regionCode': 'JK', + 'language': 'id', + 'timezone': 'Asia/Jakarta', + 'currency': 'IDR', + 'status': 'normal', + 'maskedTarget': 'n***@example.com', + 'createdAt': '2026-10-19T08:00:00', + 'lastLoginAt': '2026-10-19T09:00:00', + 'vipLevel': 2, + 'vipExpireAt': null, + }), + ), + ); + + final profile = await apiWith(adapter).profile(); + + expect(adapter.requests.single.method, 'GET'); + expect(adapter.requests.single.uri.path, '/app/me'); + expect(profile.uid, 'U-K19'); + expect(profile.nickname, 'Nadia'); + expect(profile.vipLevel, 2); + expect(profile.createdAt, DateTime(2026, 10, 19, 8)); + }); + + test('should_send_only_changed_profile_fields_when_updating', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope({'uid': 'U-K19', 'nickname': 'Nadia Baru'}), + ), + ); + + final profile = await apiWith(adapter).updateProfile( + const AccountProfileUpdate( + nickname: 'Nadia Baru', + gender: 'female', + birthday: '1999-01-02', + ), + ); + + expect(adapter.requests.single.method, 'PUT'); + expect(adapter.requests.single.uri.path, '/app/me'); + expect(adapter.requests.single.data, { + 'nickname': 'Nadia Baru', + 'gender': 'female', + 'birthday': '1999-01-02', + }); + expect(profile.nickname, 'Nadia Baru'); + }); + + test('should_parse_device_ids_from_strings_and_numbers', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope([ + { + 'id': '2111012753262120962', + 'userId': '2111012753262120000', + 'deviceId': 'd-device-1234', + 'platform': 'IOS', + 'deviceModel': 'iPhone 15', + 'firstSeenAt': '2026-10-18T08:00:00', + 'lastSeenAt': '2026-10-19T09:00:00', + 'status': 'active', + 'deleted': 0, + }, + {'id': 7, 'deviceId': 'd-android-5678', 'platform': 'ANDROID'}, + ]), + ), + ); + + final devices = await apiWith(adapter).devices(); + + expect(adapter.requests.single.uri.path, '/app/me/devices'); + expect(devices.first.id, '2111012753262120962'); + expect(devices.first.lastSeenAt, DateTime(2026, 10, 19, 9)); + expect(devices.last.id, '7'); + expect(devices.last.deviceModel, ''); + }); + + test('should_parse_export_counts_and_keep_complete_json', () async { + final data = { + 'profile': {'uid': 'U-K19', 'nickname': 'Nadia'}, + 'credentials': [ + {'type': 'EMAIL', 'masked': 'n***@example.com', 'verified': true}, + ], + 'consents': [ + {'scene': 'privacy', 'version': '2026-10-01'}, + ], + 'devices': [ + {'id': '9', 'deviceId': 'd-device-1234', 'status': 'active'}, + ], + }; + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope(data)), + ); + + final export = await apiWith(adapter).export(); + + expect(adapter.requests.single.uri.path, '/app/me/export'); + expect(export.profile.nickname, 'Nadia'); + expect(export.credentials.single.masked, 'n***@example.com'); + expect(export.credentials.single.verified, isTrue); + expect(export.consentCount, 1); + expect(export.deviceCount, 1); + expect(export.rawJson, contains('"credentials"')); + }); + + test('should_request_deactivation_with_reason_and_parse_schedule', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope('2026-11-03T09:30:00')), + ); + + final scheduledAt = await apiWith(adapter).deactivate(reason: '不再使用'); + + expect(adapter.requests.single.method, 'POST'); + expect(adapter.requests.single.uri.path, '/app/me/deactivate'); + expect(adapter.requests.single.data, {'reason': '不再使用'}); + expect(scheduledAt, DateTime(2026, 11, 3, 9, 30)); + }); + + test('should_cancel_deactivation_with_post_and_no_body', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope(null)), + ); + + await apiWith(adapter).cancelDeactivation(); + + expect(adapter.requests.single.method, 'POST'); + expect(adapter.requests.single.uri.path, '/app/me/deactivate/cancel'); + expect(adapter.requests.single.data, isNull); + }); + + test('should_keep_backend_message_when_cancel_fails', () async { + final adapter = FakeHttpAdapter( + (_) async => + FakeHttpResponse(envelope(null, code: 100009, message: '没有待处理的注销申请')), + ); + + await expectLater( + apiWith(adapter).cancelDeactivation(), + throwsA( + isA() + .having((error) => error.code, 'code', 100009) + .having((error) => error.message, 'message', '没有待处理的注销申请'), + ), + ); + }); +} diff --git a/test/account/account_logic_test.dart b/test/account/account_logic_test.dart new file mode 100644 index 0000000..49d1db9 --- /dev/null +++ b/test/account/account_logic_test.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/account_device.dart'; +import 'package:fruitshorts/core/data/models/account_profile.dart'; +import 'package:fruitshorts/pages/account/account_deactivate_logic.dart'; +import 'package:fruitshorts/pages/account/account_devices_logic.dart'; +import 'package:fruitshorts/pages/account/account_export_logic.dart'; +import 'package:fruitshorts/pages/account/account_profile_logic.dart'; + +import '../support/fake_account_repository.dart'; + +void main() { + test('should_update_profile_and_notify_auth_when_saved', () async { + final repository = FakeAccountRepository(); + AccountProfile? applied; + final logic = AccountProfileLogic( + repository, + onProfileUpdated: (profile) async => applied = profile, + ); + await logic.load(); + + final ok = await logic.save( + nickname: ' Nadia Baru ', + gender: 'unknown', + birthday: '1999-01-02', + ); + + expect(ok, isTrue); + expect(repository.lastUpdate?.nickname, 'Nadia Baru'); + expect(repository.lastUpdate?.gender, 'unknown'); + expect(repository.lastUpdate?.birthday, '1999-01-02'); + expect(applied?.nickname, 'Nadia Baru'); + }); + + test('should_load_devices_and_keep_the_page_read_only', () async { + final repository = FakeAccountRepository( + devices: const [ + AccountDevice( + id: '1', + deviceId: 'd-device-1234', + platform: 'IOS', + deviceModel: 'iPhone 15', + status: 'active', + ), + ], + ); + final logic = AccountDevicesLogic(repository); + + await logic.load(); + + expect(logic.devices.single.deviceModel, 'iPhone 15'); + expect(logic.loading, isFalse); + expect(logic.error, isNull); + }); + + test('should_copy_complete_export_json_when_requested', () async { + final repository = FakeAccountRepository(); + String copied = ''; + final logic = AccountExportLogic( + repository, + copyText: (value) async => copied = value, + ); + await logic.load(); + + final ok = await logic.copy(); + + expect(ok, isTrue); + expect(copied, contains('"credentials"')); + expect(copied, contains('n***@example.com')); + }); + + test( + 'should_clear_session_and_keep_schedule_when_deactivation_succeeds', + () async { + final repository = FakeAccountRepository(); + var cleared = false; + final logic = AccountDeactivateLogic( + repository, + clearSession: () async => cleared = true, + ); + + final ok = await logic.request('不再使用'); + + expect(ok, isTrue); + expect(repository.lastDeactivateReason, '不再使用'); + expect(logic.scheduledAt, DateTime(2026, 11, 3, 9, 30)); + expect(cleared, isTrue); + }, + ); + + test('should_show_backend_message_when_cancel_deactivation_fails', () async { + final repository = FakeAccountRepository()..failCancel = true; + final logic = AccountDeactivateLogic(repository, clearSession: () async {}); + + final ok = await logic.cancel(); + + expect(ok, isFalse); + expect(logic.actionMessage, '没有待处理的注销申请'); + }); +} diff --git a/test/account/account_pages_test.dart b/test/account/account_pages_test.dart new file mode 100644 index 0000000..90cc7d0 --- /dev/null +++ b/test/account/account_pages_test.dart @@ -0,0 +1,225 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/repositories/account_repository.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/account/account_deactivate_page.dart'; +import 'package:fruitshorts/pages/account/account_devices_page.dart'; +import 'package:fruitshorts/pages/account/account_export_page.dart'; +import 'package:fruitshorts/pages/account/account_profile_page.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_account_repository.dart'; + +void main() { + testWidgets('should_fill_profile_and_save_nickname', (tester) async { + final repository = FakeAccountRepository(); + final auth = await _signedInAuth(); + await _pumpPage( + tester, + AccountProfilePage(), + repository: repository, + auth: auth, + ); + + final nickname = find.byKey(const Key('account-nickname')); + expect(tester.widget(nickname).controller?.text, 'Nadia'); + await tester.enterText(nickname, 'Nadia Baru'); + await tester.tap(find.byKey(const Key('account-profile-save'))); + await tester.pumpAndSettle(); + + expect(repository.lastUpdate?.nickname, 'Nadia Baru'); + }); + + testWidgets('should_show_device_empty_state_when_list_is_empty', ( + tester, + ) async { + final repository = FakeAccountRepository(); + final auth = await _signedInAuth(); + await _pumpPage( + tester, + AccountDevicesPage(), + repository: repository, + auth: auth, + ); + + expect(find.byKey(const Key('account-devices-empty')), findsOneWidget); + }); + + testWidgets('should_confirm_before_deactivation_and_show_schedule', ( + tester, + ) async { + final repository = FakeAccountRepository(); + final auth = await _signedInAuth(); + await _pumpPage( + tester, + AccountDeactivatePage(), + repository: repository, + auth: auth, + ); + + await tester.enterText( + find.byKey(const Key('account-deactivate-reason')), + '不再使用', + ); + await tester.tap(find.byKey(const Key('account-deactivate-submit'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('account-deactivate-confirm')), findsOneWidget); + await tester.tap(find.byKey(const Key('account-deactivate-confirm'))); + await tester.pumpAndSettle(); + + expect(repository.lastDeactivateReason, '不再使用'); + expect( + find.byKey(const Key('account-deactivate-scheduled')), + findsOneWidget, + ); + expect(auth.isSignedIn, isFalse); + }); + + testWidgets('should_retry_export_after_failure', (tester) async { + final repository = FakeAccountRepository()..failExport = true; + final auth = await _signedInAuth(); + await _pumpPage( + tester, + AccountExportPage(), + repository: repository, + auth: auth, + ); + + expect(find.byKey(const Key('account-export-error')), findsOneWidget); + repository.failExport = false; + await tester.tap(find.byKey(const Key('account-export-retry'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('account-export-content')), findsOneWidget); + }); + + testWidgets('should_show_localized_network_copy_when_profile_load_fails', ( + tester, + ) async { + final repository = FakeAccountRepository()..throwUnexpected = true; + final auth = await _signedInAuth(); + await _pumpPage( + tester, + AccountProfilePage(), + repository: repository, + auth: auth, + locale: const Locale('zh'), + ); + final l10n = await AppLocalizations.delegate.load(const Locale('zh')); + + expect(find.text(l10n.networkError), findsOneWidget); + expect(find.text('accountProfileLoadFailed'), findsNothing); + }); + + testWidgets('should_show_localized_network_copy_when_profile_save_fails', ( + tester, + ) async { + final repository = FakeAccountRepository(); + final auth = await _signedInAuth(); + await _pumpPage( + tester, + AccountProfilePage(), + repository: repository, + auth: auth, + ); + repository.throwUnexpected = true; + await tester.enterText( + find.byKey(const Key('account-nickname')), + 'Nadia Baru', + ); + await tester.tap(find.byKey(const Key('account-profile-save'))); + await tester.pumpAndSettle(); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + expect(find.text(l10n.networkError), findsOneWidget); + expect(find.text('accountProfileSaveFailed'), findsNothing); + }); + + testWidgets('should_show_localized_network_copy_when_devices_fail', ( + tester, + ) async { + final repository = FakeAccountRepository()..throwUnexpected = true; + final auth = await _signedInAuth(); + await _pumpPage( + tester, + AccountDevicesPage(), + repository: repository, + auth: auth, + locale: const Locale('id'), + ); + final l10n = await AppLocalizations.delegate.load(const Locale('id')); + + expect(find.text(l10n.networkError), findsOneWidget); + expect(find.text('accountDevicesLoadFailed'), findsNothing); + }); + + testWidgets('should_show_localized_network_copy_when_export_fails', ( + tester, + ) async { + final repository = FakeAccountRepository()..throwUnexpected = true; + final auth = await _signedInAuth(); + await _pumpPage( + tester, + AccountExportPage(), + repository: repository, + auth: auth, + ); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + expect(find.text(l10n.networkError), findsOneWidget); + expect(find.text('accountExportLoadFailed'), findsNothing); + }); +} + +Future _signedInAuth() async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + await AuthSessionStore().save( + const AuthSession( + accessToken: 'access', + refreshToken: 'refresh', + user: AuthUser(uid: 'U-K19', nickname: 'Nadia'), + ), + ); + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081'); + final auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.load(); + return auth; +} + +Future _pumpPage( + WidgetTester tester, + Widget page, { + required FakeAccountRepository repository, + required AuthController auth, + Locale locale = const Locale('en'), +}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: repository), + ChangeNotifierProvider.value(value: auth), + ], + child: MaterialApp( + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Theme(data: ThemeData.dark(), child: page), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/account/auth_profile_sync_test.dart b/test/account/auth_profile_sync_test.dart new file mode 100644 index 0000000..85519cb --- /dev/null +++ b/test/account/auth_profile_sync_test.dart @@ -0,0 +1,61 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_update_profile_summary_and_keep_tokens_when_applied', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'accessToken': 'access-1', + 'refreshToken': 'refresh-1', + 'expiresIn': 7200, + 'user': {'uid': 'U-OLD', 'nickname': 'Old', 'avatarUrl': 'old.png'}, + }, + }), + ), + ); + late AuthController auth; + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + accessTokenReader: () => auth.accessToken, + tokenRefresher: () => auth.refresh(), + ); + auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.register(email: 'a@b.com', password: 'abcd1234'); + + await auth.applyProfile( + const AuthUser( + uid: 'U-K19', + nickname: 'Nadia', + avatarUrl: 'avatar-k19.png', + ), + ); + + expect(auth.accessToken, 'access-1'); + expect(auth.user?.uid, 'U-K19'); + expect(auth.user?.nickname, 'Nadia'); + expect(auth.user?.avatarUrl, 'avatar-k19.png'); + final saved = await AuthSessionStore().read(); + expect(saved?.refreshToken, 'refresh-1'); + expect(saved?.user.nickname, 'Nadia'); + }); +} diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart new file mode 100644 index 0000000..d0612ca --- /dev/null +++ b/test/account/mine_account_entry_test.dart @@ -0,0 +1,81 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_account_repository.dart'; +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_hide_account_entries_when_signed_out', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mine-account-card')), findsNothing); + }); + + testWidgets('should_show_four_account_entries_when_signed_in', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + await AuthSessionStore().save( + const AuthSession( + accessToken: 'access', + refreshToken: 'refresh', + user: AuthUser(uid: 'U-K19', nickname: 'Nadia'), + ), + ); + await _pumpApp(tester); + + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mine-account-card')), findsOneWidget); + expect(find.byKey(const Key('mine-account-profile')), findsOneWidget); + expect(find.byKey(const Key('mine-account-devices')), findsOneWidget); + expect(find.byKey(const Key('mine-account-export')), findsOneWidget); + expect(find.byKey(const Key('mine-account-deactivate')), findsOneWidget); + }); +} + +Future _pumpApp(WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + accountRepository: FakeAccountRepository(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/auth/auth_controller_test.dart b/test/auth/auth_controller_test.dart new file mode 100644 index 0000000..cbefd61 --- /dev/null +++ b/test/auth/auth_controller_test.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; + +import '../support/fake_http_adapter.dart'; + +/// 用假的 dio adapter 覆盖四条关键路径:注册成功、401 → 刷新 → 重试、刷新失败 → 清会话、登出。 +/// +/// 网络层从 http 换成 dio 后,这里的**断言口径一条没变**,只是注入方式从 +/// `http.testing.MockClient` 换成 `FakeHttpAdapter`。 +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + String tokenBody({ + required String accessToken, + required String refreshToken, + String uid = 'U1', + }) { + return jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'expiresIn': 7200, + 'user': {'uid': uid, 'nickname': 'Tester', 'avatarUrl': ''}, + }, + }); + } + + ApiClient clientWith( + AuthController Function() auth, { + required FakeHttpAdapter adapter, + }) { + final store = AuthSessionStore(); + return ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + accessTokenReader: () => auth().accessToken, + tokenRefresher: () => auth().refresh(), + onUnauthorized: () => auth().clear(), + deviceIdReader: store.deviceId, + ); + } + + test('should_store_session_and_expose_signed_in_state_when_register_succeeds', () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + expect(options.uri.path, '/app/auth/register'); + expect(options.headers['X-Device-Id'], isNotEmpty); + expect(options.headers.containsKey('Authorization'), isFalse); + return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, + ); + + expect(auth.isSignedIn, isFalse); + await auth.register(email: 'a@b.com', password: 'abcd1234'); + expect(auth.isSignedIn, isTrue); + expect(auth.accessToken, 'a1'); + expect(auth.user?.nickname, 'Tester'); + // 落盘:新建一个 store 也能读回来 + expect((await AuthSessionStore().read())?.refreshToken, 'r1'); + }); + + test('should_refresh_once_and_retry_with_new_token_when_response_code_is_401', () async { + var calls = 0; + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + calls++; + if (options.uri.path == '/app/auth/refresh') { + return FakeHttpResponse(tokenBody(accessToken: 'a2', refreshToken: 'r2')); + } + if (options.uri.path != '/app/me') { + // 注册等其它请求:直接发第一版令牌 + return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); + } + final token = options.headers['Authorization']; + if (token == 'Bearer a1') { + return FakeHttpResponse( + jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), + ); + } + expect(token, 'Bearer a2'); + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'hit': true}, + }), + ); + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, + ); + await auth.register(email: 'a@b.com', password: 'abcd1234'); + calls = 0; + + final api = clientWith(() => auth, adapter: adapter); + final data = await api.get('/app/me'); + expect((data as Map)['hit'], isTrue); + expect(calls, 3); // 原请求(401) + 刷新 + 重试 + expect(auth.accessToken, 'a2'); + }); + + test('should_clear_session_when_refresh_itself_fails', () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/refresh') { + return FakeHttpResponse( + jsonEncode({'code': 401, 'message': '登录已过期', 'data': null}), + ); + } + if (options.uri.path == '/app/auth/register') { + return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); + } + return FakeHttpResponse( + jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), + ); + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, + ); + await auth.register(email: 'a@b.com', password: 'abcd1234'); + + final api = clientWith(() => auth, adapter: adapter); + await expectLater(api.get('/app/me'), throwsA(isA())); + expect(auth.isSignedIn, isFalse); + expect(await AuthSessionStore().read(), isNull); + }); + + test('should_clear_local_session_when_sign_out_hits_server_error', () async { + late AuthController auth; + final adapter = FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/logout') { + return FakeHttpResponse('{}', 500); + } + return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1')); + }); + final store = AuthSessionStore(); + auth = AuthController( + AuthApi(clientWith(() => auth, adapter: adapter)), + store, + ); + await auth.register(email: 'a@b.com', password: 'abcd1234'); + + await auth.signOut(); + expect(auth.isSignedIn, isFalse); + expect(await AuthSessionStore().read(), isNull); + }); +} diff --git a/test/auth/auth_k21_test.dart b/test/auth/auth_k21_test.dart new file mode 100644 index 0000000..6312819 --- /dev/null +++ b/test/auth/auth_k21_test.dart @@ -0,0 +1,88 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; + +import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_show_sign_up_subtitle_and_password_rule_when_switched', ( + tester, + ) async { + await _pumpAuth(tester); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + expect(find.byKey(const Key('auth-password-rule')), findsNothing); + await tester.tap(find.byKey(const Key('auth-mode-sign-up'))); + await tester.pumpAndSettle(); + + expect(find.text(l10n.authSignUpSubtitle), findsOneWidget); + final rule = find.byKey(const Key('auth-password-rule')); + expect(rule, findsOneWidget); + expect(tester.widget(rule).data, l10n.authPasswordHint); + }); + + testWidgets('should_apply_semantic_error_border_when_email_is_invalid', ( + tester, + ) async { + await _pumpAuth(tester); + await tester.enterText(find.byKey(const Key('auth-email')), 'invalid'); + await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + final emailFinder = find.byKey(const Key('auth-email')); + final email = tester.widget(emailFinder); + final errorBorder = email.decoration!.errorBorder! as OutlineInputBorder; + final scheme = Theme.of(tester.element(emailFinder)).colorScheme; + expect(errorBorder.borderSide.color, scheme.error); + expect(errorBorder.borderSide.width, 1.5); + }); +} + +Future _pumpAuth(WidgetTester tester) async { + final auth = AuthController( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ), + AuthSessionStore(), + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AuthPage(), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); +} diff --git a/test/auth/auth_landing_test.dart b/test/auth/auth_landing_test.dart new file mode 100644 index 0000000..af8b6d7 --- /dev/null +++ b/test/auth/auth_landing_test.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; + +import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + testWidgets('should_show_three_login_entries_and_open_email_form', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final auth = AuthController( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ), + AuthSessionStore(), + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AuthPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-legal')), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-entry-google'))); + await tester.pumpAndSettle(); + expect(find.text('Coming soon'), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('auth-email')), findsOneWidget); + expect(find.byKey(const Key('auth-password')), findsOneWidget); + }); +} diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart new file mode 100644 index 0000000..3a5851c --- /dev/null +++ b/test/auth/auth_page_test.dart @@ -0,0 +1,294 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; + +import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; + +/// 登录/注册页的可读证据:校验提示 + 登录/注册切换 + 未登录时不会去发请求。 +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + Future pumpAuthPage( + WidgetTester tester, { + bool signUp = false, + AuthApi? api, + FakeHaptics? haptics, + }) async { + final auth = AuthController( + api ?? + AuthApi( + // 这个测试不联网:请求一律 500,正好用于验证"本地校验没过就不发请求" + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{}', 500), + ), + ), + ), + AuthSessionStore(), + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: haptics ?? FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AuthPage(signUpMode: signUp), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + } + + testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', ( + tester, + ) async { + await pumpAuthPage(tester); + + expect(find.text('Welcome back'), findsOneWidget); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + + expect(find.text('Enter your email'), findsOneWidget); + expect(find.text('Welcome back'), findsOneWidget); + }); + + testWidgets( + 'should_reject_weak_password_locally_when_only_letters_are_typed', + (tester) async { + await pumpAuthPage(tester); + + await tester.enterText(find.byType(TextField).first, 'a@b.com'); + await tester.enterText(find.byType(TextField).last, 'abcdefgh'); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + + expect( + find.text('Password needs both letters and numbers'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'should_flip_between_sign_in_and_sign_up_when_switch_link_is_tapped', + (tester) async { + await pumpAuthPage(tester); + expect(find.text('Welcome back'), findsOneWidget); + + await tester.tap(find.text('New here? Create an account')); + await tester.pumpAndSettle(); + + expect(find.text('Create your account'), findsOneWidget); + expect(find.text('Already have an account? Sign in'), findsOneWidget); + }, + ); + + testWidgets('should_show_brand_and_form_accessibility_when_page_opens', ( + tester, + ) async { + await pumpAuthPage(tester); + + expect(find.text('FruitShorts'), findsOneWidget); + expect(find.text('Short dramas made for Indonesia'), findsOneWidget); + final email = tester.widget(find.byKey(const Key('auth-email'))); + final password = tester.widget( + find.byKey(const Key('auth-password')), + ); + expect(email.autofillHints, contains(AutofillHints.email)); + expect(password.autofillHints, contains(AutofillHints.password)); + expect( + tester.getSize(find.byKey(const Key('auth-submit'))).height, + greaterThanOrEqualTo(44), + ); + expect( + tester.getSize(find.byKey(const Key('auth-password-visibility'))).height, + greaterThanOrEqualTo(44), + ); + expect( + tester.getSize(find.byKey(const Key('auth-switch-mode'))).height, + greaterThanOrEqualTo(44), + ); + }); + + testWidgets('should_toggle_password_visibility_when_eye_button_is_tapped', ( + tester, + ) async { + await pumpAuthPage(tester); + expect( + tester + .widget(find.byKey(const Key('auth-password'))) + .obscureText, + isTrue, + ); + + await tester.tap(find.byKey(const Key('auth-password-visibility'))); + await tester.pump(); + + expect( + tester + .widget(find.byKey(const Key('auth-password'))) + .obscureText, + isFalse, + ); + expect(find.byTooltip('Hide password'), findsOneWidget); + }); + + testWidgets('should_move_focus_from_email_to_password_when_next_is_pressed', ( + tester, + ) async { + await pumpAuthPage(tester); + await tester.tap(find.byKey(const Key('auth-email'))); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'auth-password-focus', + ); + }); + + testWidgets('should_disable_form_and_submit_while_done_action_is_pending', ( + tester, + ) async { + final api = _PendingAuthApi(); + final haptics = FakeHaptics(); + await pumpAuthPage(tester, api: api, haptics: haptics); + await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); + await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); + await tester.tap(find.byKey(const Key('auth-password'))); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(api.loginCalls, 1); + expect(haptics.lightImpacts, 1); + expect( + tester.widget(find.byKey(const Key('auth-email'))).enabled, + isFalse, + ); + expect( + tester.widget(find.byKey(const Key('auth-password'))).enabled, + isFalse, + ); + expect( + tester + .widget(find.byKey(const Key('auth-password-visibility'))) + .onPressed, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('auth-submit'))) + .onPressed, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('auth-switch-mode'))) + .onPressed, + isNull, + ); + + api.release(); + await tester.pumpAndSettle(); + }); + + testWidgets('should_render_validation_error_with_theme_semantic_color', ( + tester, + ) async { + await pumpAuthPage(tester); + await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); + await tester.enterText(find.byKey(const Key('auth-password')), 'abcdefgh'); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + final errorFinder = find.byKey(const Key('auth-error')); + final error = tester.widget(errorFinder); + final expected = Theme.of(tester.element(errorFinder)).colorScheme.error; + expect(error.style?.color, expected); + }); + + testWidgets( + 'should_keep_submit_reachable_when_keyboard_opens_on_small_screen', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 260); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + + await pumpAuthPage(tester); + await tester.scrollUntilVisible( + find.byKey(const Key('auth-submit')), + 120, + scrollable: find + .descendant( + of: find.byKey(const Key('auth-scroll')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('auth-submit')).hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); +} + +class _PendingAuthApi extends AuthApi { + _PendingAuthApi() + : super( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ); + + final _gate = Completer(); + int loginCalls = 0; + + void release() => _gate.complete(); + + @override + Future login({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + loginCalls++; + await _gate.future; + throw const ApiException(500, 'pending failure'); + } +} diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart new file mode 100644 index 0000000..10c94f4 --- /dev/null +++ b/test/auth/auth_visual_test.dart @@ -0,0 +1,327 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; + +import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + Future pumpAuthPage( + WidgetTester tester, { + AuthApi? api, + FakeHaptics? haptics, + bool openEmail = true, + }) async { + final auth = AuthController( + api ?? + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{}', 500), + ), + ), + ), + AuthSessionStore(), + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: haptics ?? FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AuthPage(), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 220)); + if (openEmail) { + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + } + } + + testWidgets( + 'should_render_branded_dark_layout_with_approved_tokens_when_page_opens', + (tester) async { + await pumpAuthPage(tester, openEmail: false); + + expect(find.byKey(const Key('auth-landing')), findsOneWidget); + expect(find.text('FruitShorts'), findsOneWidget); + expect( + find.text('Sign in to sync progress, favorites and coins'), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const Key('auth-brand-logo'))), + const Size(64, 64), + ); + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-legal')), findsOneWidget); + + final glow = tester.widget( + find.byKey(const Key('auth-background-glow')), + ); + final glowDecoration = glow.decoration! as BoxDecoration; + final gradient = glowDecoration.gradient! as LinearGradient; + for (final color in gradient.colors) { + expect(color.a, lessThanOrEqualTo(0.18)); + } + + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + + final card = tester.widget(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.border! as Border).top.color, + AppColors.outlineVariantDark, + ); + expect((cardDecoration.border! as Border).top.width, 1); + + final email = tester.widget( + find.byKey(const Key('auth-email')), + ); + expect(email.decoration?.fillColor, AppColors.bar); + expect(tester.getSize(find.byKey(const Key('auth-email'))).height, 52); + final emailBorder = email.decoration!.border! as OutlineInputBorder; + expect(emailBorder.borderRadius, BorderRadius.circular(14)); + + final submit = tester.widget( + find.byKey(const Key('auth-submit')), + ); + expect(submit.style!.backgroundColor!.resolve(const {}), AppColors.blue); + expect( + submit.style!.backgroundColor!.resolve(const {WidgetState.pressed}), + AppColors.bluePressed, + ); + expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 52); + expect( + (submit.style!.shape!.resolve(const {})! as RoundedRectangleBorder) + .borderRadius, + BorderRadius.circular(26), + ); + + expect(find.text('Sign In'), findsWidgets); + expect(find.text('Sign Up'), findsOneWidget); + expect( + tester.widget(find.text('Sign In').first).style?.fontSize, + isNot(0), + ); + }, + ); + + testWidgets('should_fade_and_slide_content_in_within_220ms', (tester) async { + await pumpAuthPage(tester); + + final animation = tester.widget>( + find.byKey(const Key('auth-enter-animation')), + ); + expect( + animation.duration, + lessThanOrEqualTo(const Duration(milliseconds: 220)), + ); + expect(animation.tween.begin, 0); + expect(animation.tween.end, 1); + }); + + testWidgets( + 'should_switch_between_sign_in_and_sign_up_when_segment_is_tapped', + (tester) async { + await pumpAuthPage(tester); + expect(find.text('Welcome back'), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-mode-sign-up'))); + await tester.pumpAndSettle(); + + expect(find.text('Create your account'), findsOneWidget); + expect(find.text('Welcome back'), findsNothing); + expect(_segmentColor(tester, 'auth-mode-sign-up'), AppColors.blue); + expect(_segmentColor(tester, 'auth-mode-sign-in'), Colors.transparent); + + await tester.tap(find.byKey(const Key('auth-mode-sign-in'))); + await tester.pumpAndSettle(); + + expect(find.text('Welcome back'), findsOneWidget); + expect(find.text('Create your account'), findsNothing); + expect(_segmentColor(tester, 'auth-mode-sign-in'), AppColors.blue); + expect(_segmentColor(tester, 'auth-mode-sign-up'), Colors.transparent); + }, + ); + + testWidgets( + 'should_move_focus_and_toggle_password_when_auth_controls_are_used', + (tester) async { + await pumpAuthPage(tester); + await tester.tap(find.byKey(const Key('auth-email'))); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'auth-password-focus', + ); + + await tester.tap(find.byKey(const Key('auth-email'))); + await tester.pump(); + final email = tester.widget( + find.byKey(const Key('auth-email')), + ); + final focusedBorder = + email.decoration!.focusedBorder! as OutlineInputBorder; + expect(focusedBorder.borderSide.color, AppColors.blue); + expect(focusedBorder.borderSide.width, 1.5); + expect( + tester.widget(find.byIcon(Icons.mail_outline_rounded)).color, + AppColors.blueSoft, + ); + + await tester.tap(find.byKey(const Key('auth-password-visibility'))); + await tester.pump(); + expect( + tester + .widget(find.byKey(const Key('auth-password'))) + .obscureText, + isFalse, + ); + expect(find.byTooltip('Hide password'), findsOneWidget); + }, + ); + + testWidgets( + 'should_disable_form_mode_switch_and_submit_when_sign_in_is_pending', + (tester) async { + final api = _PendingAuthApi(); + final haptics = FakeHaptics(); + await pumpAuthPage(tester, api: api, haptics: haptics); + await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); + await tester.enterText( + find.byKey(const Key('auth-password')), + 'abcd1234', + ); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pump(); + + expect(api.loginCalls, 1); + expect(haptics.lightImpacts, 1); + expect( + tester.widget(find.byKey(const Key('auth-email'))).enabled, + isFalse, + ); + expect( + tester + .widget(find.byKey(const Key('auth-password'))) + .enabled, + isFalse, + ); + expect( + tester + .widget(find.byKey(const Key('auth-switch-mode'))) + .onPressed, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('auth-submit'))) + .onPressed, + isNull, + ); + + api.release(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('should_show_validation_message_inside_semantic_error_capsule', ( + tester, + ) async { + await pumpAuthPage(tester); + await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); + await tester.enterText(find.byKey(const Key('auth-password')), 'abcdefgh'); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + final capsuleFinder = find.byKey(const Key('auth-error-capsule')); + expect(capsuleFinder, findsOneWidget); + final capsule = tester.widget(capsuleFinder); + final decoration = capsule.decoration! as BoxDecoration; + expect(decoration.borderRadius, BorderRadius.circular(12)); + expect(decoration.color?.a, closeTo(0.1, 0.001)); + + final errorFinder = find.byKey(const Key('auth-error')); + final error = tester.widget(errorFinder); + final scheme = Theme.of(tester.element(errorFinder)).colorScheme; + expect(error.style?.color, scheme.error); + expect( + find.descendant( + of: capsuleFinder, + matching: find.byIcon(Icons.error_outline_rounded), + ), + findsOneWidget, + ); + }); +} + +Color? _segmentColor(WidgetTester tester, String key) { + final segment = find.byKey(Key(key)); + final animated = tester.widget( + find.descendant(of: segment, matching: find.byType(AnimatedContainer)), + ); + return (animated.decoration! as BoxDecoration).color; +} + +class _PendingAuthApi extends AuthApi { + _PendingAuthApi() + : super( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ); + + final _gate = Completer(); + int loginCalls = 0; + + void release() => _gate.complete(); + + @override + Future login({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + loginCalls++; + await _gate.future; + throw const ApiException(500, 'pending failure'); + } +} diff --git a/test/catalog/catalog_controller_test.dart b/test/catalog/catalog_controller_test.dart index ccd4ffd..d5fbf83 100644 --- a/test/catalog/catalog_controller_test.dart +++ b/test/catalog/catalog_controller_test.dart @@ -1,41 +1,100 @@ // Author: fruit.com.cn import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/core/data/repositories/catalog_repository.dart'; -import 'package:nontonki/core/data/models/drama.dart'; -import 'package:nontonki/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; void main() { - test( - 'empty catalog is distinct from a failed load, and retry recovers', - () async { - final repo = ScriptedCatalogRepository(); - final controller = CatalogController(repo); - await controller.load(); - expect(controller.dramas, isEmpty); - expect(controller.error, isNull); + test('should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds', () async { + final repository = ScriptedCatalogRepository(); + final controller = CatalogController(repository); - repo.fail = true; - await controller.load(); - expect(controller.error, isNotNull); + expect(controller.settled, isFalse); + await controller.load(); + expect(controller.settled, isTrue); + expect(controller.dramas, isEmpty); + expect(controller.error, isNull); + + repository.fail = true; + await controller.load(); + expect(controller.error, isNotNull); + + repository.fail = false; + repository.feed = HomeFeed( + sections: [ + FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]), + ], + ); + await controller.load(); + expect(controller.error, isNull); + expect(controller.dramas, hasLength(1)); + expect(controller.sections.single.title, '热门推荐'); + }); + + test('should_request_drama_detail_once_when_it_is_loaded_on_demand', () async { + final repository = ScriptedCatalogRepository(); + final controller = CatalogController(repository); + repository.feed = HomeFeed( + sections: [ + FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]), + ], + ); + await controller.load(); + + final card = controller.dramas.single; + expect(card.episodes, isEmpty); + + final detail = await controller.requireDetail(card); + expect(detail.episodes, hasLength(2)); + expect(repository.detailCalls, 1); + + // 第二次已经是详情,直接复用,不再请求 + await controller.requireDetail(detail); + expect(repository.detailCalls, 1); + expect(controller.dramas.single.episodes, hasLength(2)); + }); - repo.fail = false; - repo.items = await DemoCatalogRepository().fetchDramas(); - await controller.load(); - expect(controller.error, isNull); - expect(controller.dramas, hasLength(3)); - expect(controller.dramas.first.episodes.length, 3); - }, - ); } +Drama _card(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: '简介 $id', + posterUrl: '/files/$id.jpg', +); + +Drama _detail(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: '简介 $id', + posterUrl: '/files/$id.jpg', + detailLoaded: true, + episodes: const [ + Episode(id: '1', number: 1, title: '第一集', isFree: true), + Episode(id: '2', number: 2, title: '第二集', isFree: false), + ], +); + class ScriptedCatalogRepository implements CatalogRepository { bool fail = false; - List items = const []; + HomeFeed feed = const HomeFeed(); + int detailCalls = 0; @override - Future> fetchDramas() async { + Future fetchHomeFeed() async { if (fail) throw StateError('offline'); - return items; + return feed; } + + @override + Future fetchDiscoverFeed() async => feed; + + @override + Future fetchDrama(String dramaId) async { + detailCalls++; + return _detail(dramaId); + } + } diff --git a/test/catalog/demo_media_test.dart b/test/catalog/demo_media_test.dart deleted file mode 100644 index 0e13e70..0000000 --- a/test/catalog/demo_media_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/core/data/repositories/catalog_repository.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('all mock entries reuse one bundled local demo video', () async { - final dramas = await DemoCatalogRepository().fetchDramas(); - expect(dramas, hasLength(3)); - expect(dramas.every((drama) => drama.episodes.length == 3), isTrue); - final paths = { - for (final drama in dramas) - for (final episode in drama.episodes) episode.assetPath, - }; - expect(paths, {'assets/media/demo_clip.mp4'}); - expect( - dramas.every( - (drama) => drama.episodes.every((episode) => episode.isFree), - ), - isTrue, - ); - final bytes = await rootBundle.load(paths.single); - expect(bytes.lengthInBytes, greaterThan(1000000)); - }); -} diff --git a/test/catalog/drama_model_test.dart b/test/catalog/drama_model_test.dart new file mode 100644 index 0000000..7b715cb --- /dev/null +++ b/test/catalog/drama_model_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; + +void main() { + test('should_carry_one_preview_and_total_count_when_list_card_is_parsed', () { + final drama = Drama.fromCardJson(const { + 'id': 5, + 'title': '闪婚后', + 'synopsis': 'synopsis', + 'cover': '/files/cover.jpg', + 'category': '都市情感', + 'genre': '都市情感', + 'totalEpisodes': 77, + 'isHot': true, + 'viewCount': 3100000, + 'preview': { + 'id': 9, + 'number': 1, + 'title': '第 1 集', + 'isFree': true, + 'playUrl': '/files/episode/9.mp4', + }, + }); + + expect(drama.detailLoaded, isFalse); + expect(drama.episodes, hasLength(1)); + expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4'); + expect(drama.episodeCount, 77); + expect(drama.isHot, isTrue); + }); + + test('should_have_no_episodes_when_list_card_has_no_playable_preview', () { + final drama = Drama.fromCardJson(const { + 'id': 6, + 'title': '替嫁新娘', + 'totalEpisodes': 12, + }); + + expect(drama.episodes, isEmpty); + expect(drama.episodeCount, 12); + }); + + test('should_count_shipped_episodes_when_detail_response_is_parsed', () { + final drama = Drama.fromDetailJson(const { + 'id': 5, + 'title': '闪婚后', + 'synopsis': 'synopsis', + 'cover': '/files/cover.jpg', + 'totalEpisodes': 77, + 'episodes': [ + {'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true}, + {'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false}, + ], + }); + + expect(drama.detailLoaded, isTrue); + expect(drama.episodeCount, 77); + expect(drama.episodes.first.id, '9'); + }); +} diff --git a/test/catalog/home_feed_category_test.dart b/test/catalog/home_feed_category_test.dart deleted file mode 100644 index cae578a..0000000 --- a/test/catalog/home_feed_category_test.dart +++ /dev/null @@ -1,24 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/pages/home/home_feed_sections.dart'; -import 'package:nontonki/core/data/repositories/catalog_repository.dart'; - -void main() { - test( - 'home categories filter demo dramas without changing their media', - () async { - final dramas = await DemoCatalogRepository().fetchDramas(); - final sections = HomeFeedSections(dramas); - - expect(sections.indicesFor(HomeFeedCategory.recommended), [0, 1, 2]); - expect(sections.indicesFor(HomeFeedCategory.animated), [0]); - expect(sections.indicesFor(HomeFeedCategory.liveAction), [1, 2]); - expect(sections.indicesFor(HomeFeedCategory.following), isEmpty); - expect( - sections.dramaAt(HomeFeedCategory.liveAction, 0).title, - 'City Lights & Velvet Nights', - ); - }, - ); -} diff --git a/test/category/category_controller_test.dart b/test/category/category_controller_test.dart new file mode 100644 index 0000000..6ade3eb --- /dev/null +++ b/test/category/category_controller_test.dart @@ -0,0 +1,179 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/category_api.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; +import 'package:fruitshorts/core/data/services/category_controller.dart'; + +import '../support/fake_http_adapter.dart'; + +Drama _drama(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'synopsis', + posterUrl: '/files/$id.png', +); + +void main() { + test( + 'should_keep_snowflake_category_id_as_string_when_api_returns_list', + () async { + const id = '2111012753262120962'; + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + {'id': id, 'name': 'Romance'}, + ], + }), + ), + ); + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + ); + + final categories = await CategoryApi(client).list(); + + expect(categories.single.id, id); + expect(categories.single.name, 'Romance'); + expect(adapter.requests.single.uri.path, '/app/categories'); + }, + ); + + test( + 'should_map_category_id_to_discover_section_when_category_is_selected', + () async { + final repository = ScriptedCategoryRepository( + categories: const [ + ContentCategory(id: '7', name: 'Romance'), + ContentCategory(id: '8', name: 'Action'), + ], + feed: HomeFeed( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]), + FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]), + FeedSection( + code: 'category-7', + title: 'Romance', + dramas: [_drama('romance-1')], + ), + ], + ), + ); + final controller = CategoryController(repository); + + await controller.load(); + controller.selectCategory('7'); + + expect(controller.selectedCode, 'category-7'); + expect(controller.visibleDramas.map((drama) => drama.id), ['romance-1']); + expect(controller.selectedSectionMissing, isFalse); + + controller.selectCategory('8'); + expect(controller.selectedCode, 'category-8'); + expect(controller.visibleDramas, isEmpty); + expect(controller.selectedSectionMissing, isTrue); + }, + ); + + test( + 'should_keep_hot_and_new_entries_when_discover_feed_provides_them', + () async { + final repository = ScriptedCategoryRepository( + feed: HomeFeed( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('hot-1')]), + FeedSection(code: 'new', title: 'New', dramas: [_drama('new-1')]), + FeedSection( + code: 'category-7', + title: 'Romance', + dramas: [_drama('romance-1')], + ), + ], + ), + ); + final controller = CategoryController(repository); + + await controller.load(); + controller.selectCode(CategoryController.hotCode); + expect(controller.visibleDramas.map((drama) => drama.id), ['hot-1']); + + controller.selectCode(CategoryController.newCode); + expect(controller.visibleDramas.map((drama) => drama.id), ['new-1']); + }, + ); + + test('should_dedupe_dramas_when_all_categories_are_selected', () async { + final repository = ScriptedCategoryRepository( + feed: HomeFeed( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: [_drama('same')]), + FeedSection( + code: 'category-7', + title: 'Romance', + dramas: [_drama('same'), _drama('other')], + ), + ], + ), + ); + final controller = CategoryController(repository); + + await controller.load(); + + expect(controller.selectedCode, CategoryController.allCode); + expect(controller.visibleDramas.map((drama) => drama.id), [ + 'same', + 'other', + ]); + }); + + test('should_clear_error_and_retry_when_initial_load_fails', () async { + final repository = ScriptedCategoryRepository(fail: true); + final controller = CategoryController(repository); + + await controller.load(); + expect(controller.error, 'categoriesError'); + expect(controller.loading, isFalse); + + repository.fail = false; + await controller.load(); + + expect(controller.error, isNull); + expect(repository.loads, 2); + }); +} + +class ScriptedCategoryRepository implements CategoryRepository { + ScriptedCategoryRepository({ + this.categories = const [], + this.feed = const HomeFeed(), + this.fail = false, + }); + + List categories; + HomeFeed feed; + bool fail; + int loads = 0; + + @override + Future> fetchCategories() async { + loads++; + if (fail) throw StateError('offline'); + return categories; + } + + @override + Future fetchDiscoverFeed() async { + if (fail) throw StateError('offline'); + return feed; + } +} diff --git a/test/comment/comment_api_test.dart b/test/comment/comment_api_test.dart new file mode 100644 index 0000000..a83dfce --- /dev/null +++ b/test/comment/comment_api_test.dart @@ -0,0 +1,129 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/comment_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + String envelope(Object? data) => + jsonEncode({'code': 0, 'message': 'ok', 'data': data}); + + Map commentJson({ + String id = '2111012753262120962', + String content = 'Great episode', + String status = 'published', + bool mine = false, + bool liked = true, + String parentId = '0', + String rootId = '0', + }) => { + 'id': id, + 'dramaId': '5', + 'episodeId': '7', + 'parentId': parentId, + 'rootId': rootId, + 'userId': '2111012753262120001', + 'uid': 'viewer-1', + 'nickname': 'Viewer', + 'avatarUrl': '', + 'content': content, + 'status': status, + 'auditReason': '', + 'likeCount': '12', + 'replyCount': 2, + 'reportCount': '0', + 'createdAt': '2026-10-17T12:30:00', + 'mine': mine, + 'liked': liked, + }; + + test('should_parse_cursor_page_and_string_ids_when_comment_list_is_loaded', () async { + final adapter = FakeHttpAdapter((options) async { + return FakeHttpResponse( + envelope({ + 'list': [commentJson()], + 'nextCursor': '2111012753262120962', + 'hasMore': true, + }), + ); + }); + final api = CommentApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.page(dramaId: '5', cursor: '99', size: 20); + + expect(adapter.requests.single.method, 'GET'); + expect(adapter.requests.single.uri.path, '/app/comments'); + expect(adapter.requests.single.queryParameters, { + 'dramaId': '5', + 'cursor': '99', + 'size': '20', + }); + expect(page.comments.single.id, '2111012753262120962'); + expect(page.comments.single.dramaId, '5'); + expect(page.comments.single.episodeId, '7'); + expect(page.comments.single.likeCount, 12); + expect(page.nextCursor, '2111012753262120962'); + expect(page.hasMore, isTrue); + }); + + test('should_send_parent_id_only_for_reply_and_parse_replaced_content_when_published', () async { + final adapter = FakeHttpAdapter((options) async { + return FakeHttpResponse( + envelope( + commentJson( + id: '2111012753262120999', + content: 'great ***', + status: 'pending', + mine: true, + parentId: '2111012753262120962', + rootId: '2111012753262120962', + ), + ), + ); + }); + final api = CommentApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final reply = await api.publish( + dramaId: '5', + content: 'great badword', + parentId: '2111012753262120962', + ); + + expect(adapter.requests.single.method, 'POST'); + expect(adapter.requests.single.uri.path, '/app/comments'); + expect(adapter.requests.single.data, { + 'dramaId': '5', + 'content': 'great badword', + 'parentId': '2111012753262120962', + }); + expect(reply.content, 'great ***'); + expect(reply.status, 'pending'); + expect(reply.parentId, '2111012753262120962'); + expect(reply.rootId, '2111012753262120962'); + }); + + test('should_delete_comment_by_string_id_when_requested', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope(null)), + ); + final api = CommentApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + await api.delete('2111012753262120962'); + + expect(adapter.requests.single.method, 'DELETE'); + expect( + adapter.requests.single.uri.path, + '/app/comments/2111012753262120962', + ); + }); +} diff --git a/test/comment/comment_controller_test.dart b/test/comment/comment_controller_test.dart new file mode 100644 index 0000000..b47f97f --- /dev/null +++ b/test/comment/comment_controller_test.dart @@ -0,0 +1,192 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/comment.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/services/comment_controller.dart'; + +import '../support/fake_interaction_repositories.dart'; + +void main() { + CommentController controller( + FakeCommentRepository comments, { + FakeInteractionRepository? interactions, + }) => CommentController( + comments: comments, + interactions: interactions ?? FakeInteractionRepository(), + dramaId: '5', + ); + + test('should_append_next_page_with_cursor_when_has_more_is_true', () async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '2')], + nextCursor: '2', + hasMore: true, + ), + CommentPage(comments: [commentFixture(id: '1')]), + ], + ); + final model = controller(repository); + + await model.load(); + await model.loadMore(); + + expect(model.comments.map((item) => item.id), ['2', '1']); + expect(model.hasMore, isFalse); + expect(repository.pageCalls[0].cursor, isNull); + expect(repository.pageCalls[1].cursor, '2'); + expect(repository.pageCalls.every((call) => call.size == 20), isTrue); + model.dispose(); + }); + + test('should_use_server_content_and_pending_status_when_review_is_returned', () async { + final repository = FakeCommentRepository( + publishResult: commentFixture( + id: '3', + content: 'replaced ***', + status: 'pending', + mine: true, + ), + ); + final model = controller(repository); + + final result = await model.publish(' replaced badword '); + + expect(result.isSuccess, isTrue); + expect(repository.publishCalls.single.content, 'replaced badword'); + expect(model.comments.single.content, 'replaced ***'); + expect(model.comments.single.isPending, isTrue); + model.dispose(); + }); + + test('should_expose_backend_message_and_keep_existing_list_when_publish_is_blocked', () async { + final repository = FakeCommentRepository( + publishError: const ApiException(400, '评论包含敏感词'), + ); + final model = controller(repository); + await model.load(); + + final result = await model.publish('blocked'); + + expect(result.isSuccess, isFalse); + expect(result.errorMessage, '评论包含敏感词'); + expect(model.comments, isEmpty); + model.dispose(); + }); + + test('should_insert_reply_under_root_and_request_replies_with_root_id', () async { + final root = commentFixture(id: '10', replyCount: 1); + final reply = commentFixture( + id: '11', + parentId: '10', + rootId: '10', + content: 'Reply', + mine: true, + ); + final repository = FakeCommentRepository( + pages: [ + CommentPage(comments: [root]), + CommentPage(comments: [reply]), + ], + publishResult: reply, + ); + final model = controller(repository); + await model.load(); + + await model.loadReplies('10'); + final result = await model.publish('Reply', replyTo: root); + + expect(result.isSuccess, isTrue); + expect(repository.publishCalls.single.parentId, '10'); + expect(repository.pageCalls.last.rootId, '10'); + expect(model.repliesFor('10').map((item) => item.id), ['11']); + model.dispose(); + }); + + test('should_restore_comment_at_original_position_when_delete_fails', () async { + final first = commentFixture(id: '2', mine: true); + final second = commentFixture(id: '1'); + final repository = FakeCommentRepository( + pages: [ + CommentPage(comments: [first, second]), + ], + deleteError: const ApiException(500, '删除失败'), + ); + final model = controller(repository); + await model.load(); + + final result = await model.delete(first); + + expect(result.isSuccess, isFalse); + expect(result.errorMessage, '删除失败'); + expect(model.comments.map((item) => item.id), ['2', '1']); + expect(repository.deleteCalls, ['2']); + model.dispose(); + }); + + test('should_remove_own_comment_when_delete_succeeds', () async { + final own = commentFixture(id: '2', mine: true); + final repository = FakeCommentRepository( + pages: [ + CommentPage(comments: [own]), + ], + ); + final model = controller(repository); + await model.load(); + + final result = await model.delete(own); + + expect(result.isSuccess, isTrue); + expect(model.comments, isEmpty); + model.dispose(); + }); + + test('should_optimistically_update_comment_like_and_apply_server_count', () async { + final comment = commentFixture(id: '2', likeCount: 3, liked: false); + final interactions = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 8), + ); + final repository = FakeCommentRepository( + pages: [ + CommentPage(comments: [comment]), + ], + ); + final model = controller(repository, interactions: interactions); + await model.load(); + + final pending = model.toggleLike(comment); + expect(model.comments.single.liked, isTrue); + expect(model.comments.single.likeCount, 4); + final result = await pending; + + expect(result.isSuccess, isTrue); + expect(model.comments.single.liked, isTrue); + expect(model.comments.single.likeCount, 8); + expect(interactions.likeTargets, ['COMMENT:2']); + model.dispose(); + }); + + test('should_rollback_comment_like_when_request_fails', () async { + final comment = commentFixture(id: '2', likeCount: 3, liked: true); + final interactions = FakeInteractionRepository() + ..likeError = const ApiException(500, '点赞失败'); + final repository = FakeCommentRepository( + pages: [ + CommentPage(comments: [comment]), + ], + ); + final model = controller(repository, interactions: interactions); + await model.load(); + + final result = await model.toggleLike(comment); + + expect(result.isSuccess, isFalse); + expect(result.errorMessage, '点赞失败'); + expect(model.comments.single.liked, isTrue); + expect(model.comments.single.likeCount, 3); + model.dispose(); + }); +} diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart new file mode 100644 index 0000000..4c5d436 --- /dev/null +++ b/test/comment/drama_comments_sheet_test.dart @@ -0,0 +1,375 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/comment.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; +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/widgets/empty_state.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/drama_comments_sheet.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; + +const _drama = Drama( + id: '5', + title: 'The Signal', + synopsis: 'synopsis', + posterUrl: '/files/5.jpg', +); + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', + }); + }); + + testWidgets('should_show_server_comments_and_append_next_cursor_page', ( + tester, + ) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [ + commentFixture(id: '10', content: 'Great episode', replyCount: 1), + ], + nextCursor: '10', + hasMore: true, + ), + CommentPage( + comments: [commentFixture(id: '9', content: 'Second page')], + ), + ], + ); + + await _pumpSheet(tester, repository: repository); + expect(find.text('Great episode'), findsOneWidget); + expect(find.byKey(const Key('comment-list-load-more')), findsOneWidget); + + await tester.tap(find.byKey(const Key('comment-list-load-more'))); + await tester.pumpAndSettle(); + + expect(find.text('Second page'), findsOneWidget); + expect(repository.pageCalls.last.cursor, '10'); + }); + + testWidgets('should_show_compact_empty_state_when_no_comments_exist', ( + tester, + ) async { + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + + expect(find.byKey(const Key('comment-list-empty')), findsOneWidget); + expect(find.byType(EmptyState), findsOneWidget); + expect(tester.widget(find.byType(EmptyState)).compact, isTrue); + expect(find.text('No comments yet.'), findsOneWidget); + }); + + testWidgets( + 'should_keep_input_and_show_backend_message_when_comment_is_blocked', + (tester) async { + final repository = FakeCommentRepository( + pages: [const CommentPage()], + publishError: const ApiException(400, '评论包含敏感词'), + ); + + await _pumpSheet(tester, repository: repository); + await tester.enterText( + find.byKey(const Key('comment-input')), + 'blocked text', + ); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + + expect(find.text('评论包含敏感词'), findsOneWidget); + final field = tester.widget( + find.byKey(const Key('comment-input')), + ); + expect(field.controller?.text, 'blocked text'); + }, + ); + + testWidgets('should_show_review_badge_and_server_replaced_content', ( + tester, + ) async { + final repository = FakeCommentRepository( + pages: [const CommentPage()], + publishResult: commentFixture( + id: '11', + content: 'hello ***', + status: 'pending', + mine: true, + ), + ); + final haptics = FakeHaptics(); + + await _pumpSheet(tester, repository: repository, haptics: haptics); + await tester.enterText( + find.byKey(const Key('comment-input')), + 'hello badword', + ); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + + expect(find.text('hello ***'), findsOneWidget); + expect(find.text('Under review'), findsOneWidget); + expect(haptics.lightImpacts, 1); + final field = tester.widget( + find.byKey(const Key('comment-input')), + ); + expect(field.controller?.text, isEmpty); + }); + + testWidgets('should_send_parent_id_when_replying_to_top_level_comment', ( + tester, + ) async { + final root = commentFixture(id: '10', content: 'Root', replyCount: 0); + final repository = FakeCommentRepository( + pages: [ + CommentPage(comments: [root]), + ], + publishResult: commentFixture( + id: '11', + parentId: '10', + rootId: '10', + content: 'My reply', + mine: true, + ), + ); + + await _pumpSheet(tester, repository: repository); + await tester.tap(find.byKey(const Key('comment-reply-10'))); + await tester.pump(); + expect(find.textContaining('Replying to'), findsOneWidget); + await tester.enterText(find.byKey(const Key('comment-input')), 'My reply'); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + + expect(repository.publishCalls.single.parentId, '10'); + expect(find.text('My reply'), findsOneWidget); + }); + + testWidgets('should_show_error_and_retry_when_comment_page_fails', ( + tester, + ) async { + final repository = FakeCommentRepository(pageError: StateError('offline')); + + await _pumpSheet(tester, repository: repository); + + expect(find.byKey(const Key('comment-list-error')), findsOneWidget); + expect(find.byKey(const Key('comment-list-retry')), findsOneWidget); + }); + + testWidgets('should_update_comment_like_count_when_server_returns_count', ( + tester, + ) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '10', content: 'Like me')], + ), + ], + ); + final interactions = FakeInteractionRepository() + ..nextLikeResult = const LikeToggleResult(liked: true, count: 7); + final haptics = FakeHaptics(); + + await _pumpSheet( + tester, + repository: repository, + interactions: interactions, + haptics: haptics, + ); + await tester.tap(find.byKey(const Key('comment-like-10'))); + await tester.pumpAndSettle(); + + expect(interactions.likeTargets, ['COMMENT:10']); + expect(haptics.lightImpacts, 1); + expect(find.text('7'), findsOneWidget); + }); + + testWidgets('should_report_comment_and_show_success_when_submitted', ( + tester, + ) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '10', content: 'Report me')], + ), + ], + ); + final interactions = FakeInteractionRepository(); + final haptics = FakeHaptics(); + + await _pumpSheet( + tester, + repository: repository, + interactions: interactions, + haptics: haptics, + ); + await tester.tap(find.byKey(const Key('comment-menu-10'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Report comment')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('report-submit'))); + await tester.pumpAndSettle(); + + expect( + interactions.reports.single.targetType, + InteractionTargetType.comment, + ); + expect(interactions.reports.single.targetId, '10'); + expect(interactions.reports.single.reasonCode, 'SPAM'); + expect(haptics.lightImpacts, 1); + expect(find.text('Report submitted. Thank you.'), findsOneWidget); + }); + + testWidgets('should_show_backend_message_when_comment_report_is_duplicated', ( + tester, + ) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '10', content: 'Report me')], + ), + ], + ); + final interactions = FakeInteractionRepository() + ..reportError = const ApiException(200003, '你已举报过该内容,我们正在处理'); + + await _pumpSheet( + tester, + repository: repository, + interactions: interactions, + ); + await tester.tap(find.byKey(const Key('comment-menu-10'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Report comment')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('report-submit'))); + await tester.pumpAndSettle(); + + expect(find.text('你已举报过该内容,我们正在处理'), findsOneWidget); + }); + + testWidgets('should_open_auth_gate_without_reporting_when_signed_out', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [commentFixture(id: '10', content: 'Report me')], + ), + ], + ); + final interactions = FakeInteractionRepository(); + + await _pumpSheet( + tester, + repository: repository, + interactions: interactions, + ); + await tester.tap(find.byKey(const Key('comment-menu-10'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Report comment')); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to continue'), findsOneWidget); + expect(interactions.reports, isEmpty); + }); + + testWidgets('should_show_backend_message_when_reporting_own_comment', ( + tester, + ) async { + final repository = FakeCommentRepository( + pages: [ + CommentPage( + comments: [ + commentFixture(id: '10', content: 'My comment', mine: true), + ], + ), + ], + ); + final interactions = FakeInteractionRepository() + ..reportError = const ApiException(400, '不能举报自己发布的内容'); + + await _pumpSheet( + tester, + repository: repository, + interactions: interactions, + ); + await tester.tap(find.byKey(const Key('comment-menu-10'))); + await tester.pumpAndSettle(); + expect(find.text('Delete comment'), findsOneWidget); + expect(find.text('Report comment'), findsOneWidget); + await tester.tap(find.text('Report comment')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('report-submit'))); + await tester.pumpAndSettle(); + + expect(find.text('不能举报自己发布的内容'), findsOneWidget); + }); +} + +Future _pumpSheet( + WidgetTester tester, { + required FakeCommentRepository repository, + FakeInteractionRepository? interactions, + FakeHaptics? haptics, +}) async { + final auth = await _auth(); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: repository), + Provider.value( + value: interactions ?? FakeInteractionRepository(), + ), + ChangeNotifierProvider.value(value: auth), + Provider.value(value: haptics ?? FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + backgroundColor: AppColors.ink, + body: DramaCommentsSheet(drama: _drama), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _auth() async { + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ); + final auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.load(); + return auth; +} diff --git a/test/config/l10n_id_test.dart b/test/config/l10n_id_test.dart new file mode 100644 index 0000000..4e09c28 --- /dev/null +++ b/test/config/l10n_id_test.dart @@ -0,0 +1,57 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fruitshorts/l10n/app_localizations.dart'; + +/// id-ID 文案接入的可读证据:设备语言是 `id` 时真的拿到印尼语、`id` 在支持列表里、 +/// 并且品牌文案里不再有 demo 标记。 +void main() { + test('should_return_indonesian_copy_when_locale_is_id', () async { + final l10n = await AppLocalizations.delegate.load(const Locale('id')); + + expect(l10n.homeTab, 'Beranda'); + expect(l10n.watchNow, 'Tonton gratis'); + expect(l10n.watchlist, 'Daftar Tonton'); + expect(l10n.coins, 'Koin'); + expect(l10n.signIn, 'Masuk'); + }); + + test('should_include_id_when_supported_locales_are_read', () { + final codes = AppLocalizations.supportedLocales + .map((locale) => locale.languageCode) + .toList(); + + expect(codes, containsAll(['en', 'zh', 'id'])); + }); + + test('should_drop_demo_marker_when_brand_copy_is_loaded', () async { + for (final locale in const [Locale('en'), Locale('zh'), Locale('id')]) { + final l10n = await AppLocalizations.delegate.load(locale); + + expect(l10n.appName, 'FruitShorts', reason: '$locale appName'); + expect(l10n.brandMark, 'FRUITSHORTS', reason: '$locale brandMark'); + expect(l10n.shareFooter, 'FruitShorts', reason: '$locale shareFooter'); + expect(l10n.brandMark.toLowerCase(), isNot(contains('demo'))); + expect(l10n.shareFooter.toLowerCase(), isNot(contains('demo'))); + } + }); + + test('should_keep_placeholders_when_indonesian_copy_renders', () async { + final l10n = await AppLocalizations.delegate.load(const Locale('id')); + + expect(l10n.episodeLabel(3), contains('3')); + expect(l10n.episodeProgress(2, 10), contains('2')); + expect(l10n.episodeProgress(2, 10), contains('10')); + }); + + test('should_keep_auth_ui_copy_in_all_supported_locales', () async { + for (final locale in const [Locale('en'), Locale('zh'), Locale('id')]) { + final l10n = await AppLocalizations.delegate.load(locale); + expect(l10n.authBrandTagline, isNotEmpty); + expect(l10n.showPassword, isNotEmpty); + expect(l10n.hidePassword, isNotEmpty); + } + }); +} diff --git a/test/config/locale_controller_test.dart b/test/config/locale_controller_test.dart new file mode 100644 index 0000000..077b54c --- /dev/null +++ b/test/config/locale_controller_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/config/locale_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + test('should_keep_device_locale_when_no_choice_is_stored', () async { + final controller = LocaleController(); + var notifications = 0; + controller.addListener(() => notifications++); + + await controller.load(); + + expect(controller.locale, isNull); + expect(notifications, 1); + }); + + test('should_restore_chosen_language_when_app_restarts', () async { + final first = LocaleController(); + await first.select('zh'); + expect(first.locale, const Locale('zh')); + + final reopened = LocaleController(); + await reopened.load(); + expect(reopened.locale, const Locale('zh')); + }); + + test('should_restore_indonesian_when_selected_and_app_restarts', () async { + final first = LocaleController(); + await first.select('id'); + expect(first.locale, const Locale('id')); + + final reopened = LocaleController(); + await reopened.load(); + expect(reopened.locale, const Locale('id')); + }); + + test('should_fallback_to_indonesian_when_system_language_is_unsupported', () { + expect(resolveAppLocale(null, const Locale('en')), const Locale('en')); + expect(resolveAppLocale(null, const Locale('id')), const Locale('id')); + expect(resolveAppLocale(null, const Locale('fr')), const Locale('id')); + expect(resolveAppLocale(null, const Locale('in')), const Locale('id')); + expect( + resolveAppLocale(const Locale('zh'), const Locale('fr')), + const Locale('zh'), + ); + }); + + test( + 'should_clear_stored_chinese_preference_when_english_is_selected', + () async { + await LocaleController().select('zh'); + + final reopened = LocaleController(); + await reopened.load(); + await reopened.select('en'); + + final third = LocaleController(); + await third.load(); + expect(third.locale, const Locale('en')); + }, + ); + + test('should_clear_locale_when_code_is_unsupported', () async { + final controller = LocaleController(); + await controller.select('zh'); + expect(controller.locale, const Locale('zh')); + + await controller.select('tlh'); + + expect(controller.locale, isNull); + }); + + test( + 'should_resolve_to_device_locale_when_stored_code_is_unsupported', + () async { + await SharedPreferencesAsync().setString('ui.locale', 'fr'); + + final controller = LocaleController(); + await controller.load(); + + expect(controller.locale, isNull); + }, + ); + + test( + 'should_survive_load_and_select_when_preference_store_is_broken', + () async { + SharedPreferencesAsyncPlatform.instance = _BrokenPreferences(); + final controller = LocaleController(); + + await controller.load(); + expect(controller.locale, isNull); + + await controller.select('zh'); + expect(controller.locale, const Locale('zh')); + }, + ); +} + +/// Stands in for a platform whose preference store is unavailable. +base class _BrokenPreferences extends InMemorySharedPreferencesAsync { + _BrokenPreferences() : super.empty(); + + @override + Future getString(String key, SharedPreferencesOptions options) => + Future.error(StateError('preferences unavailable')); + + @override + Future setString( + String key, + String value, + SharedPreferencesOptions options, + ) => Future.error(StateError('preferences unavailable')); +} diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart new file mode 100644 index 0000000..4774d95 --- /dev/null +++ b/test/config/server_endpoint_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/config/app_config.dart'; +import 'package:fruitshorts/core/config/server_endpoint.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + AppConfig.clearRuntimeBaseUrl(); + }); + + tearDown(AppConfig.clearRuntimeBaseUrl); + + test('should_normalize_http_urls_and_reject_invalid_values', () { + expect( + ServerEndpoint.normalize(' https://192.168.1.9:8081/// '), + 'https://192.168.1.9:8081', + ); + expect( + ServerEndpoint.normalize('http://example.test:8081/path/'), + 'http://example.test:8081/path', + ); + expect(ServerEndpoint.normalize('ftp://example.test'), isNull); + expect(ServerEndpoint.normalize('example.test:8081'), isNull); + expect(ServerEndpoint.normalize('http://'), isNull); + }); + + test('should_prioritize_runtime_over_dart_define_over_default', () { + expect( + ServerEndpoint.resolve( + stored: ' http://runtime.test:8081/ ', + compiled: 'http://define.test:8081', + fallback: 'http://default.test:8081', + ), + 'http://runtime.test:8081', + ); + expect( + ServerEndpoint.resolve( + stored: 'ftp://invalid', + compiled: 'http://define.test:8081/', + fallback: 'http://default.test:8081', + ), + 'http://define.test:8081', + ); + expect( + ServerEndpoint.resolve( + stored: null, + compiled: '', + fallback: 'http://default.test:8081/', + ), + 'http://default.test:8081', + ); + }); + + test('should_use_the_configured_lan_address_for_device_default', () { + expect(AppConfig.devHostBaseUrl, 'http://39.162.50.99:8081'); + }); + + test('should_apply_stored_url_only_when_debug_is_enabled', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + ServerEndpoint.preferenceKey: ' http://39.162.50.99:8081/ ', + }); + final original = AppConfig.apiBaseUrl; + + await ServerEndpoint.load(debug: false); + expect(AppConfig.apiBaseUrl, original); + + await ServerEndpoint.load(debug: true); + expect(AppConfig.apiBaseUrl, 'http://39.162.50.99:8081'); + }); + + test('should_save_normalized_url_and_clear_override', () async { + expect(await ServerEndpoint.save('ftp://invalid'), isFalse); + expect( + await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), + isNull, + ); + + expect( + await ServerEndpoint.save(' http://39.162.50.99:8081/ '), + isTrue, + ); + expect( + await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), + 'http://39.162.50.99:8081', + ); + + await ServerEndpoint.clear(); + expect( + await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), + isNull, + ); + }); +} diff --git a/test/core/android_picture_in_picture_test.dart b/test/core/android_picture_in_picture_test.dart new file mode 100644 index 0000000..4dd72a0 --- /dev/null +++ b/test/core/android_picture_in_picture_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('test/pip_channel'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + AndroidPictureInPicture pip() => AndroidPictureInPicture(channel: channel); + + /// Simulates a native call arriving on the channel. + Future sendToDart(String method, Object? arguments) => messenger + .handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall(method, arguments), + ), + (_) {}, + ); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + channel.setMethodCallHandler(null); + }); + + group('platform support', () { + test('should_support_only_android_when_target_is_checked', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(pip().isPlatformSupported, isTrue); + + for (final platform in [ + TargetPlatform.iOS, + TargetPlatform.macOS, + TargetPlatform.linux, + TargetPlatform.windows, + TargetPlatform.fuchsia, + ]) { + debugDefaultTargetPlatformOverride = platform; + expect( + pip().isPlatformSupported, + isFalse, + reason: '$platform must not claim picture in picture support', + ); + } + }); + }); + + group('enter', () { + test('should_refuse_without_touching_channel_when_target_is_not_android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + expect(await pip().enter(), isFalse); + expect(calls, isEmpty); + }); + + test('should_count_as_refused_when_native_answer_is_missing', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + messenger.setMockMethodCallHandler(channel, (call) async => null); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + expect(await pip().enter(), isFalse); + }); + + test('should_report_entered_when_native_call_succeeds', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + expect(await pip().enter(), isTrue); + expect(calls.single.method, 'enter'); + expect(calls.single.arguments, isNull); + }); + }); + + group('state listener', () { + test('should_deliver_pip_state_changes_in_order_when_native_events_arrive', () async { + final received = []; + pip().setStateListener(received.add); + + await sendToDart('onPipChanged', true); + await sendToDart('onPipChanged', false); + + expect(received, [true, false]); + }); + + test('should_treat_only_literal_true_as_enabled_when_native_value_arrives', () async { + final received = []; + pip().setStateListener(received.add); + + await sendToDart('onPipChanged', null); + await sendToDart('onPipChanged', 'yes'); + await sendToDart('onPipChanged', 1); + + expect(received, [false, false, false]); + }); + + test('should_ignore_unrelated_native_methods_when_events_arrive', () async { + final received = []; + pip().setStateListener(received.add); + + await sendToDart('somethingElse', true); + await sendToDart('onPipChanged', true); + + expect(received, [true]); + }); + + test('should_silence_later_callbacks_when_listener_is_cleared', () async { + final received = []; + final observer = pip()..setStateListener(received.add); + await sendToDart('onPipChanged', true); + + observer.setStateListener(null); + await sendToDart('onPipChanged', true); + + expect(received, [true]); + }); + }); +} diff --git a/test/core/api_client_test.dart b/test/core/api_client_test.dart new file mode 100644 index 0000000..2b69055 --- /dev/null +++ b/test/core/api_client_test.dart @@ -0,0 +1,107 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fruitshorts/core/data/remote/api_client.dart'; + +import '../support/fake_http_adapter.dart'; + +/// 网络层换 dio 之后,把「对外契约」逐条钉住:解包、错误码映射、请求头、查询参数、跳过重试。 +void main() { + String envelope(Object? data, {int code = 0, String message = 'ok'}) { + return jsonEncode({'code': code, 'message': message, 'data': data}); + } + + test('should_return_data_field_when_envelope_code_is_zero', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope({'id': 7})), + ); + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter); + + final data = await client.get('/app/dramas/7'); + expect((data as Map)['id'], 7); + }); + + test('should_throw_api_exception_with_server_message_when_envelope_code_is_not_zero', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope(null, code: 40001, message: '邮箱格式不正确'), + ), + ); + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter); + + await expectLater( + client.post('/app/auth/register', body: {'target': 'bad'}), + throwsA( + isA() + .having((e) => e.code, 'code', 40001) + .having((e) => e.message, 'message', '邮箱格式不正确'), + ), + ); + }); + + test('should_map_to_api_exception_with_http_status_when_body_has_no_envelope', () async { + final adapter = FakeHttpAdapter((_) async => FakeHttpResponse('oops', 502)); + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter); + + await expectLater( + client.get('/app/home/feed'), + throwsA( + isA().having((e) => e.code, 'code', 502), + ), + ); + }); + + test('should_send_bearer_token_and_device_header_when_both_are_available', () async { + final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null))); + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + accessTokenReader: () => 'token-1', + deviceIdReader: () async => 'device-9', + ); + + await client.get('/app/me'); + + final request = adapter.requests.single; + expect(request.headers['Authorization'], 'Bearer token-1'); + expect(request.headers['X-Device-Id'], 'device-9'); + expect(request.headers['Accept'], 'application/json'); + }); + + test('should_put_query_map_into_request_uri_when_query_is_given', () async { + final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null))); + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter); + + await client.get('/app/search', query: {'keyword': '短剧', 'page': '2'}); + + final uri = adapter.requests.single.uri; + expect(uri.path, '/app/search'); + expect(uri.queryParameters['keyword'], '短剧'); + expect(uri.queryParameters['page'], '2'); + }); + + test('should_skip_refresh_when_call_opts_out_of_auth_retry', () async { + var refreshed = false; + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')), + ); + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + tokenRefresher: () async { + refreshed = true; + return true; + }, + ); + + await expectLater( + client.post('/app/auth/refresh', body: {'refreshToken': 'r1'}, skipAuthRetry: true), + throwsA(isA().having((e) => e.code, 'code', 401)), + ); + expect(refreshed, isFalse); + expect(adapter.requests, hasLength(1)); + }); +} diff --git a/test/core/haptics_scan_test.dart b/test/core/haptics_scan_test.dart new file mode 100644 index 0000000..f8a448c --- /dev/null +++ b/test/core/haptics_scan_test.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('should_keep_haptic_calls_inside_platform_wrapper', () { + final violations = []; + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + if (entity.path.endsWith('lib/core/platform/haptics.dart')) continue; + final lines = entity.readAsLinesSync(); + for (var index = 0; index < lines.length; index++) { + if (lines[index].contains('HapticFeedback.')) { + violations.add('${entity.path}:${index + 1}'); + } + } + } + + expect(violations, isEmpty, reason: violations.join('\n')); + }); +} diff --git a/test/core/haptics_test.dart b/test/core/haptics_test.dart new file mode 100644 index 0000000..f876eba --- /dev/null +++ b/test/core/haptics_test.dart @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('should_ignore_platform_errors_when_haptics_are_unavailable', () async { + const channel = SystemChannels.platform; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'unavailable'); + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + const haptics = FlutterHaptics(); + await expectLater(haptics.selectionClick(), completes); + await expectLater(haptics.lightImpact(), completes); + await expectLater(haptics.mediumImpact(), completes); + }); +} diff --git a/test/core/json_id_parsing_test.dart b/test/core/json_id_parsing_test.dart new file mode 100644 index 0000000..2fbaa11 --- /dev/null +++ b/test/core/json_id_parsing_test.dart @@ -0,0 +1,103 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/json_numbers.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; + +/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段以字符串下发。 +/// 这里钉住客户端在"收到字符串形式的 id / 计数"时照样能解析,不抛异常。 +void main() { + group('jsonToInt', () { + test('should_parse_int_when_value_is_number', () { + expect(jsonToInt(12345), 12345); + expect(jsonToInt(12.9), 12); + }); + + test('should_parse_int_when_value_is_numeric_string', () { + expect(jsonToInt('2111012753262120962'), 2111012753262120962); + expect(jsonToInt(' 42 '), 42); + }); + + test('should_fall_back_when_value_is_missing_or_invalid', () { + expect(jsonToInt(null), 0); + expect(jsonToInt(''), 0); + expect(jsonToInt('abc'), 0); + expect(jsonToInt(null, fallback: 7), 7); + }); + }); + + group('drama model', () { + test('should_parse_string_ids_when_backend_sends_ids_as_string', () { + final drama = Drama.fromCardJson({ + 'id': '2111012753262120962', + 'title': '闪婚后,傅先生每天都在撒糖', + 'totalEpisodes': 72, + 'viewCount': '3100000', + 'isHot': true, + }); + + expect(drama.id, '2111012753262120962'); + expect(drama.title, '闪婚后,傅先生每天都在撒糖'); + expect(drama.totalEpisodes, 72); + // viewCount 后端是 Long → 字符串 + expect(drama.viewCount, 3100000); + }); + + test('should_parse_view_count_when_backend_sends_number', () { + final drama = Drama.fromCardJson({'id': 5, 'title': 'T', 'viewCount': 1200}); + + expect(drama.id, '5'); + expect(drama.viewCount, 1200); + }); + + test('should_parse_detail_payload_when_ids_are_strings', () { + final drama = Drama.fromDetailJson({ + 'id': '2111012753262120962', + 'title': 'T', + 'totalEpisodes': 12, + 'freeEpisodes': 3, + 'viewCount': '99', + 'episodes': [ + {'id': '2111012753262120963', 'number': 1, 'title': 'E1', 'isFree': true}, + ], + }); + + expect(drama.id, '2111012753262120962'); + expect(drama.freeEpisodes, 3); + expect(drama.viewCount, 99); + expect(drama.episodes.first.id, '2111012753262120963'); + expect(drama.episodes.first.number, 1); + }); + }); + + group('auth session', () { + test('should_parse_expires_in_when_backend_sends_string', () { + final session = AuthSession.fromJson({ + 'accessToken': 'a', + 'refreshToken': 'r', + 'expiresIn': '7200', + 'user': {'uid': 'u-1', 'nickname': 'n'}, + }); + + expect(session.expiresIn, 7200); + expect(session.user.uid, 'u-1'); + }); + }); + + group('play ticket', () { + test('should_parse_episode_id_when_backend_sends_string', () { + final ticket = PlayTicket.fromJson({ + 'episodeId': '2111012753262120963', + 'accessible': false, + 'accessType': PlayTicket.coinRequired, + 'unlockCoin': 30, + }); + + expect(ticket.episodeId, '2111012753262120963'); + expect(ticket.unlockCoin, 30); + expect(ticket.needsUnlock, isTrue); + }); + }); +} diff --git a/test/core/platform_share_service_test.dart b/test/core/platform_share_service_test.dart new file mode 100644 index 0000000..b8c39dc --- /dev/null +++ b/test/core/platform_share_service_test.dart @@ -0,0 +1,31 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/platform_share_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('should_keep_title_synopsis_footer_order_when_share_text_is_sent', () async { + String? sharedText; + const channel = MethodChannel('com.stormg.lumadrama/share'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + sharedText = (call.arguments as Map)['text'] as String; + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + await const PlatformShareService().shareDrama( + title: 'The Signal', + synopsis: 'A short drama', + footer: 'FruitShorts', + ); + + expect(sharedText, 'The Signal\nA short drama\nFruitShorts'); + }); +} diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart new file mode 100644 index 0000000..55fc790 --- /dev/null +++ b/test/discover/discover_logic_test.dart @@ -0,0 +1,331 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/discover/discover_logic.dart'; + +import '../support/fake_watch_history.dart'; + +Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/poster.png', + episodes: [ + for (final number in numbers) + Episode( + id: '$id-$number', + number: number, + title: 'Episode $number', + videoUrl: 'https://example.test/clip.mp4', + isFree: true, + ), + ], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('should_resolve_resume_position_of_episode_one_when_constructed', () async { + final history = FakeWatchHistory()..saved = const Duration(seconds: 4); + final logic = DiscoverLogic(history: history, drama: _drama('signal')); + + expect(await logic.position, const Duration(seconds: 4)); + expect(history.reads, ['signal']); + expect(logic.searchOpen, isFalse); + logic.dispose(); + }); + + test('should_reread_resume_position_when_drama_is_swapped', () async { + final history = FakeWatchHistory(); + final logic = DiscoverLogic(history: history, drama: _drama('signal')); + + logic.drama = _drama('city'); + logic.refreshPosition(); + await logic.position; + + expect(history.reads, ['signal', 'city']); + logic.dispose(); + }); + + test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async { + final history = FakeWatchHistory() + ..saved = const Duration(seconds: 9) + ..lastEpisode = 'signal-2'; + final logic = DiscoverLogic( + history: history, + drama: _drama('signal', numbers: const []), + ); + + expect(await logic.position, const Duration(seconds: 9)); + expect(history.reads, ['signal']); + logic.dispose(); + }); + + test('should_have_no_resume_position_when_server_has_no_record', () async { + final history = FakeWatchHistory(); + final logic = DiscoverLogic( + history: history, + drama: _drama('signal', numbers: const []), + ); + + expect(await logic.position, isNull); + // 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次 + expect(history.reads, ['signal']); + logic.dispose(); + }); + + test('should_show_search_when_flag_is_on_or_text_is_typed', () { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + + expect(logic.showSearch, isFalse); + + logic.search.text = 'vengeance'; + logic.onSearchChanged(); + expect(logic.showSearch, isTrue); + + logic.search.clear(); + expect(logic.showSearch, isFalse); + + logic.searchOpen = true; + expect(logic.showSearch, isTrue); + logic.dispose(); + }); + + test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final catalog = [_drama('signal'), _drama('city')]; + + expect(logic.dramaFor('city', catalog)?.id, 'city'); + expect(logic.dramaFor('missing', catalog), isNull); + expect(logic.dramaFor(null, catalog), isNull); + expect(logic.dramaFor('city', const []), isNull); + logic.dispose(); + }); + + test('should_adopt_drama_only_when_page_is_active', () { + final history = FakeWatchHistory(); + final logic = DiscoverLogic(history: history, drama: _drama('signal')); + + void sync( + Drama next, { + required bool active, + required bool wasActive, + required bool dramaChanged, + int searchRequest = 0, + int previousSearchRequest = 0, + }) => logic.syncDrama( + active: active, + wasActive: wasActive, + nextDrama: next, + dramaChanged: dramaChanged, + searchRequest: searchRequest, + previousSearchRequest: previousSearchRequest, + isActive: () => active, + ); + + // An inactive page keeps showing its own drama. + sync(_drama('city'), active: false, wasActive: true, dramaChanged: true); + expect(logic.drama.id, 'signal'); + + // Becoming active adopts the incoming drama. + sync(_drama('city'), active: true, wasActive: false, dramaChanged: false); + expect(logic.drama.id, 'city'); + + // Staying active with a changed drama re-reads history. + history.reads.clear(); + sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true); + expect(logic.drama.id, 'vengeance'); + expect(history.reads, ['vengeance']); + + // Staying active with no change leaves both drama and history alone. + history.reads.clear(); + sync(_drama('other'), active: true, wasActive: true, dramaChanged: false); + expect(logic.drama.id, 'vengeance'); + expect(history.reads, isEmpty); + logic.dispose(); + }); + + testWidgets('should_open_and_focus_field_when_search_is_requested_and_page_is_active', ( + tester, + ) async { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + controller: logic.search, + focusNode: logic.searchFocus, + ), + ), + ), + ); + + logic.syncDrama( + active: true, + wasActive: true, + nextDrama: logic.drama, + dramaChanged: false, + searchRequest: 1, + previousSearchRequest: 0, + isActive: () => true, + ); + + expect(logic.searchOpen, isTrue); + await tester.pumpAndSettle(); + expect(logic.searchFocus.hasFocus, isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + logic.dispose(); + }); + + testWidgets('should_keep_search_field_closed_when_page_is_hidden', ( + tester, + ) async { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + controller: logic.search, + focusNode: logic.searchFocus, + ), + ), + ), + ); + + // The search request branch is gated on `active`, so a page that is not on + // screen must neither reveal the field nor pull focus away from the visible tab. + logic.syncDrama( + active: false, + wasActive: false, + nextDrama: logic.drama, + dramaChanged: false, + searchRequest: 1, + previousSearchRequest: 0, + isActive: () => false, + ); + + expect(logic.searchOpen, isFalse); + await tester.pumpAndSettle(); + expect(logic.searchFocus.hasFocus, isFalse); + + await tester.pumpWidget(const SizedBox.shrink()); + logic.dispose(); + }); + + testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( + tester, + ) async { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + final forwarded = []; + final popped = Completer(); + late BuildContext pageContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (inner) { + pageContext = inner; + return const SizedBox.shrink(); + }, + ), + ) + .then(popped.complete), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + logic.act( + pageContext, + DiscoverPageAction.home, + embedded: true, + onAction: forwarded.add, + ); + expect(forwarded, [DiscoverPageAction.home]); + expect(Navigator.of(pageContext).canPop(), isTrue); + + logic.act(pageContext, DiscoverPageAction.watchlist, embedded: false); + await tester.pumpAndSettle(); + + await expectLater(popped.future, completion(DiscoverPageAction.watchlist)); + logic.dispose(); + }); + + testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', ( + tester, + ) async { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (inner) { + context = inner; + return const Scaffold(body: SizedBox.shrink()); + }, + ), + ), + ); + + logic.openCard(context, null, embedded: false); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of(context)!; + expect(find.text(l10n.artworkOnlyNotice), findsOneWidget); + logic.dispose(); + }); + + testWidgets('should_forward_chosen_drama_when_card_is_embedded', (tester) async { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + late BuildContext context; + Drama? selected; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (inner) { + context = inner; + return const SizedBox.shrink(); + }, + ), + ), + ); + + logic.openCard( + context, + _drama('city'), + embedded: true, + onDramaSelected: (drama) => selected = drama, + ); + + expect(selected?.id, 'city'); + logic.dispose(); + }); + + test('should_do_nothing_when_notifying_after_dispose', () { + final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); + var notifications = 0; + logic.addListener(() => notifications++); + + logic.dispose(); + logic.notify(); + + expect(notifications, 0); + }); +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart new file mode 100644 index 0000000..de51edf --- /dev/null +++ b/test/discover/home_all_dramas_grid_test.dart @@ -0,0 +1,528 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets( + 'should_show_all_dramas_in_two_column_masonry_without_continue_watching', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.text('Continue Watching'), findsNothing); + expect(find.text('Recommended'), findsOneWidget); + for (final key in const [ + Key('discover-search-button'), + Key('discover-profile'), + ]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant( + of: find.byKey(key), + matching: find.byType(Icon), + ), + ) + .size, + 18, + ); + } + expect(find.text('For You'), findsNothing); + final masonry = tester.widget( + find.byKey(const Key('home-all-dramas-grid')), + ); + expect(masonry.children, hasLength(3)); + expect((masonry.children[1] as SizedBox).width, 10); + expect( + tester + .widget(find.byKey(const Key('home-all-dramas-section'))) + .padding, + const EdgeInsets.symmetric(horizontal: 14), + ); + expect(find.byType(GridView), findsNothing); + expect(find.byType(CustomScrollView), findsOneWidget); + final signalCard = find.byKey( + const Key('home-drama-card-the-signal-demo'), + ); + final cityCard = find.byKey( + const Key('home-drama-card-city-lights-demo'), + ); + final vengeanceCard = find.byKey( + const Key('home-drama-card-vengeance-demo'), + ); + expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy); + expect( + tester.getTopLeft(vengeanceCard).dx, + tester.getTopLeft(signalCard).dx, + ); + expect( + tester.getTopLeft(vengeanceCard).dy - + tester.getBottomLeft(signalCard).dy, + closeTo(10, 1), + ); + + final cover = find.byKey(const Key('home-drama-cover-city-lights-demo')); + expect(tester.getSize(cover).height, greaterThan(120)); + expect( + tester + .widget( + find.descendant(of: cover, matching: find.byType(Image)), + ) + .fit, + BoxFit.fitWidth, + ); + expect( + find.descendant( + of: cover, + matching: find.text('City Lights & Velvet Nights'), + ), + findsNothing, + ); + expect( + find.byKey(const Key('home-drama-title-city-lights-demo')), + findsOneWidget, + ); + expect( + tester + .getTopLeft( + find.byKey(const Key('home-drama-title-city-lights-demo')), + ) + .dy, + greaterThan(tester.getBottomLeft(cover).dy), + ); + expect( + tester + .widget( + find.byKey(const Key('home-drama-title-city-lights-demo')), + ) + .maxLines, + 1, + ); + expect( + tester + .widget( + find.byKey(const Key('home-drama-genre-city-lights-demo')), + ) + .maxLines, + 1, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('should_update_grid_when_category_is_selected', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.text('The Signal'), findsWidgets); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + + await tester.tap(find.text('Romance')); + await tester.pumpAndSettle(); + + expect(find.text('The Signal'), findsNothing); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + }); + + testWidgets('should_keep_discover_header_fixed_when_content_scrolls', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp( + tester, + categoryRepository: _GridCategoryRepository( + discoverFeed: _tallDiscoverFeed, + ), + ); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + final headerBefore = tester.getTopLeft( + find.byKey(const Key('discover-search-button')), + ); + final contentBefore = tester.getTopLeft( + find.byKey(const Key('home-category-chips')), + ); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -40)); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.byKey(const Key('discover-search-button'))), + headerBefore, + ); + expect( + tester.getTopLeft(find.byKey(const Key('home-category-chips'))).dy, + lessThan(contentBefore.dy), + ); + }); + + testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp( + tester, + categoryRepository: _GridCategoryRepository( + discoverFeed: const HomeFeed(), + ), + ); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.text('No matching dramas.'), findsOneWidget); + expect(find.byKey(const Key('empty-state-content')), findsOneWidget); + + await tester.tap(find.text('Romance')); + await tester.pumpAndSettle(); + + expect(find.text('No dramas in this category yet.'), findsOneWidget); + }); + + testWidgets('should_show_retry_when_category_feed_fails', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _FailingCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.text('Categories could not be loaded.'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.byKey(const Key('empty-state-icon')), findsOneWidget); + final icon = tester.getCenter(find.byKey(const Key('empty-state-icon'))); + final feed = tester.getRect(find.byType(CustomScrollView)); + expect(icon.dx, closeTo(feed.center.dx, 1)); + expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing); + }); + + testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('home-drama-card-city-lights-demo')), + 180, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_filter_trending_grid_when_search_text_changes', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), ''); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets('should_unfocus_search_without_losing_input_or_filter', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('discover-search'))); + await tester.pump(); + expect( + tester + .widget(find.byKey(const Key('discover-search'))) + .focusNode! + .hasFocus, + isTrue, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + + await tester.tapAt(const Offset(70, 23)); + await tester.pump(); + + final field = tester.widget( + find.byKey(const Key('discover-search')), + ); + expect(field.focusNode!.hasFocus, isFalse); + expect(field.controller!.text, 'velvet'); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + }); + + testWidgets('should_refresh_feed_when_pulled_down', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final counter = _CountingCategoryRepository( + _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), + ); + await _pumpApp(tester, categoryRepository: counter); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + final before = counter.categoryLoads + counter.feedLoads; + await tester.drag(find.byType(CustomScrollView), const Offset(0, 320)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); + expect(find.byType(RefreshProgressIndicator), findsNothing); + }); + + testWidgets('should_show_continue_watching_only_when_a_record_exists', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp( + tester, + categoryRepository: _GridCategoryRepository(), + history: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-1', + position: Duration(seconds: 42), + duration: Duration(minutes: 2), + ), + ], + ), + ); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('discover-continue-play')), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-new')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget); + }); +} + +Future _pumpApp( + WidgetTester tester, { + required CategoryRepository categoryRepository, + WatchHistoryRepository? history, +}) async { + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: categoryRepository, + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + ), + ); + await tester.pumpAndSettle(); +} + +class _GridCategoryRepository implements CategoryRepository { + _GridCategoryRepository({HomeFeed? discoverFeed}) + : discoverFeed = discoverFeed ?? _defaultDiscoverFeed; + + final HomeFeed discoverFeed; + + @override + Future> fetchCategories() async => const [ + ContentCategory(id: '7', name: 'Romance'), + ]; + + @override + Future fetchDiscoverFeed() async => discoverFeed; +} + +class _FailingCategoryRepository implements CategoryRepository { + @override + Future> fetchCategories() async => const [ + ContentCategory(id: '7', name: 'Romance'), + ]; + + @override + Future fetchDiscoverFeed() async { + throw StateError('discover feed unavailable'); + } +} + +final _defaultDiscoverFeed = HomeFeed( + sections: [ + FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas), + FeedSection( + code: 'category-7', + title: 'Romance', + dramas: [ + Drama( + id: 'city-lights-demo', + title: 'City Lights & Velvet Nights', + synopsis: 'City Lights & Velvet Nights synopsis', + posterUrl: '/files/city-lights-demo.png', + genre: 'Urban Romance', + detailLoaded: true, + ), + ], + ), + ], +); + +final _tallDiscoverFeed = HomeFeed( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [ + for (var index = 0; index < 12; index++) + Drama( + id: 'tall-$index', + title: 'Tall Drama $index', + synopsis: 'Tall Drama $index synopsis', + posterUrl: '/files/tall-$index.png', + genre: 'Urban Romance', + detailLoaded: true, + ), + ], + ), + ], +); + +class _CountingCategoryRepository implements CategoryRepository { + _CountingCategoryRepository(this.delegate); + + final CategoryRepository delegate; + int categoryLoads = 0; + int feedLoads = 0; + + @override + Future> fetchCategories() { + categoryLoads++; + return delegate.fetchCategories(); + } + + @override + Future fetchDiscoverFeed() { + feedLoads++; + return delegate.fetchDiscoverFeed(); + } +} diff --git a/test/download/download_api_test.dart b/test/download/download_api_test.dart new file mode 100644 index 0000000..1f4339a --- /dev/null +++ b/test/download/download_api_test.dart @@ -0,0 +1,118 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/download_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + String envelope(Object? data) => + jsonEncode({'code': 0, 'message': 'ok', 'data': data}); + + Map licenseJson({int progress = 35}) => { + 'licenseId': '2111012753262120962', + 'dramaId': '2111012753262120963', + 'dramaTitle': 'The Signal', + 'episodeId': '2111012753262120964', + 'episodeNo': 2, + 'deviceId': 'device-9', + 'quality': '720P', + 'playUrl': '/files/episode.mp4?e=1&s=sig', + 'licenseKey': 'license-key', + 'status': 'ACTIVE', + 'grantedAt': '2026-10-17T12:00:00', + 'expireAt': '2026-11-16T12:00:00', + 'taskStatus': 'DOWNLOADING', + 'progressPercent': progress, + 'downloadedBytes': '1024', + 'totalBytes': '4096', + 'errorMsg': '', + }; + + test( + 'should_call_all_download_contracts_and_parse_string_ids_when_api_is_used', + () async { + final adapter = FakeHttpAdapter((options) async { + final path = options.uri.path; + if (path == '/app/downloads/policy') { + return FakeHttpResponse( + envelope({ + 'maxPerDevice': 20, + 'licenseDays': 30, + 'wifiOnly': true, + 'maxQuality': 'AUTO', + 'maxStorageBytes': '0', + 'used': 1, + 'remaining': 19, + 'expirePolicyText': '30 days', + }), + ); + } + if (path == '/app/downloads/settings') { + return FakeHttpResponse(envelope(null)); + } + if (path == '/app/downloads' && options.method == 'DELETE') { + return FakeHttpResponse(envelope(2)); + } + if (path == '/app/downloads') { + return FakeHttpResponse( + envelope(options.method == 'GET' ? [licenseJson()] : licenseJson()), + ); + } + return FakeHttpResponse(envelope(licenseJson(progress: 80))); + }); + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + ); + final api = DownloadApi(client); + + final policy = await api.policy(); + await api.updateSettings(wifiOnly: false, maxQuality: '1080P'); + final granted = await api.grant('2111012753262120964'); + final listed = await api.list(); + final refreshed = await api.refreshUrl('2111012753262120964'); + final progress = await api.reportProgress( + '2111012753262120964', + status: 'DOWNLOADING', + progressPercent: 80, + ); + await api.remove('2111012753262120964'); + final cleared = await api.clear(); + + expect(policy.remaining, 19); + expect(policy.expirePolicyText, '30 days'); + expect(granted.licenseId, '2111012753262120962'); + expect(granted.dramaId, '2111012753262120963'); + expect(granted.episodeId, '2111012753262120964'); + expect(listed.single.taskStatus, 'DOWNLOADING'); + expect(refreshed.taskStatus, 'DOWNLOADING'); + expect(progress.progressPercent, 80); + expect(cleared, 2); + + expect( + adapter.requests.map( + (request) => '${request.method} ${request.uri.path}', + ), + [ + 'GET /app/downloads/policy', + 'PUT /app/downloads/settings', + 'POST /app/downloads', + 'GET /app/downloads', + 'GET /app/downloads/2111012753262120964/url', + 'POST /app/downloads/2111012753262120964/progress', + 'DELETE /app/downloads/2111012753262120964', + 'DELETE /app/downloads', + ], + ); + expect(adapter.requests[2].data, {'episodeId': '2111012753262120964'}); + expect(adapter.requests[1].data, { + 'wifiOnly': false, + 'maxQuality': '1080P', + }); + }, + ); +} diff --git a/test/download/download_controller_test.dart b/test/download/download_controller_test.dart new file mode 100644 index 0000000..ad30d81 --- /dev/null +++ b/test/download/download_controller_test.dart @@ -0,0 +1,178 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/remote/api_exception.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; + +DownloadPolicy _policy({bool wifiOnly = true, int remaining = 19}) => + DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: wifiOnly, + maxQuality: 'AUTO', + maxStorageBytes: 0, + used: 20 - remaining, + remaining: remaining, + expirePolicyText: '30 days', + ); + +DownloadLicense _license(String episodeId) => DownloadLicense( + licenseId: 'license-$episodeId', + dramaId: 'drama-1', + dramaTitle: 'The Signal', + episodeId: episodeId, + episodeNo: 1, + deviceId: 'device-9', + quality: '720P', + playUrl: '', + licenseKey: '', + status: 'ACTIVE', + grantedAt: DateTime(2026, 10, 17), + expireAt: DateTime(2026, 11, 16), + taskStatus: 'QUEUED', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: 100, + errorMsg: '', +); + +void main() { + test('should_load_policy_and_licenses_when_page_opens', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(), + licenses: [_license('1')], + ); + final controller = DownloadController(repository); + + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.error, isNull); + expect(controller.policy?.remaining, 19); + expect(controller.licenses.single.taskStatus, 'QUEUED'); + }); + + test('should_show_backend_message_when_grant_is_rejected', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(), + grantError: const ApiException(200003, 'download quota is full'), + ); + final controller = DownloadController(repository); + + final result = await controller.grant('episode-1'); + + expect(result.ok, isFalse); + expect(result.message, 'download quota is full'); + }); + + test('should_rollback_wifi_setting_when_update_fails', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(wifiOnly: true), + failSettings: true, + ); + final controller = DownloadController(repository); + await controller.load(); + + final ok = await controller.updateSettings(wifiOnly: false); + + expect(ok, isFalse); + expect(controller.policy?.wifiOnly, isTrue); + }); + + test('should_restore_license_when_delete_fails', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(), + licenses: [_license('1'), _license('2')], + failRemove: true, + ); + final controller = DownloadController(repository); + await controller.load(); + + final ok = await controller.remove('1'); + + expect(ok, isFalse); + expect(controller.licenses.map((item) => item.episodeId), ['1', '2']); + }); + + test('should_restore_licenses_when_clear_fails', () async { + final repository = ScriptedDownloadRepository( + policy: _policy(), + licenses: [_license('1'), _license('2')], + failClear: true, + ); + final controller = DownloadController(repository); + await controller.load(); + + final ok = await controller.clear(); + + expect(ok, isFalse); + expect(controller.licenses, hasLength(2)); + }); +} + +class ScriptedDownloadRepository implements DownloadRepository { + ScriptedDownloadRepository({ + required this.policy, + this.licenses = const [], + this.grantError, + this.failSettings = false, + this.failRemove = false, + this.failClear = false, + }); + + DownloadPolicy policy; + List licenses; + ApiException? grantError; + bool failSettings; + bool failRemove; + bool failClear; + + @override + Future fetchPolicy() async => policy; + + @override + Future> list() async => licenses; + + @override + Future grant(String episodeId, {String? quality}) async { + if (grantError != null) throw grantError!; + return _license(episodeId); + } + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async { + if (failSettings) throw StateError('settings unavailable'); + } + + @override + Future refreshUrl(String episodeId) async => + _license(episodeId); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async => _license(episodeId); + + @override + Future remove(String episodeId) async { + if (failRemove) throw StateError('remove unavailable'); + licenses = licenses.where((item) => item.episodeId != episodeId).toList(); + } + + @override + Future clear() async { + if (failClear) throw StateError('clear unavailable'); + final count = licenses.length; + licenses = const []; + return count; + } +} diff --git a/test/download/downloads_page_test.dart b/test/download/downloads_page_test.dart new file mode 100644 index 0000000..af2c1de --- /dev/null +++ b/test/download/downloads_page_test.dart @@ -0,0 +1,297 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/download_license.dart'; +import 'package:fruitshorts/core/data/models/download_policy.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/download_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/widgets/empty_state.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/downloads/downloads_page.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; + +DownloadPolicy _policy({bool wifiOnly = true, String maxQuality = 'AUTO'}) => + DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: wifiOnly, + maxQuality: maxQuality, + maxStorageBytes: 0, + used: 1, + remaining: 19, + expirePolicyText: 'Licenses last 30 days', + ); + +DownloadLicense _license(String episodeId) => DownloadLicense( + licenseId: 'license-$episodeId', + dramaId: 'drama-1', + dramaTitle: 'The Signal', + episodeId: episodeId, + episodeNo: 2, + deviceId: 'device-9', + quality: '720P', + playUrl: '', + licenseKey: '', + status: 'ACTIVE', + grantedAt: DateTime(2026, 10, 17, 12), + expireAt: DateTime(2026, 11, 16, 12), + taskStatus: 'DOWNLOADING', + progressPercent: 35, + downloadedBytes: 1024, + totalBytes: 4096, + errorMsg: '', +); + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets( + 'should_render_policy_and_backend_status_values_when_page_loads', + (tester) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(), + licenses: [_license('1')], + ); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(find.byKey(const Key('downloads-page')), findsOneWidget); + expect(find.byKey(const Key('download-policy')), findsOneWidget); + expect(find.text('Licenses last 30 days'), findsOneWidget); + expect(find.byKey(const Key('download-license-1')), findsOneWidget); + expect(find.textContaining('ACTIVE'), findsOneWidget); + expect(find.textContaining('DOWNLOADING'), findsOneWidget); + expect(find.textContaining('35%'), findsOneWidget); + }, + ); + + testWidgets('should_show_empty_state_when_license_list_is_empty', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository(policy: _policy()); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(find.byKey(const Key('downloads-empty')), findsOneWidget); + expect(find.byType(EmptyState), findsOneWidget); + }); + + testWidgets('should_show_retry_when_policy_or_list_load_fails', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(), + failLoad: true, + ); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(find.byKey(const Key('downloads-error')), findsOneWidget); + expect(find.byKey(const Key('downloads-retry')), findsOneWidget); + }); + + testWidgets('should_show_auth_gate_without_loading_when_signed_out', ( + tester, + ) async { + final auth = await _auth(signedIn: false); + final repository = FakeDownloadRepository(policy: _policy()); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(repository.loadCalls, 0); + }); + + testWidgets('should_update_wifi_preference_when_switch_is_tapped', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository(policy: _policy()); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + await tester.tap(find.byKey(const Key('download-wifi-switch'))); + await tester.pumpAndSettle(); + + expect(repository.settingsCalls, 1); + expect(repository.lastWifiOnly, isFalse); + }); + + testWidgets('should_clear_authorizations_only_after_confirmation', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(), + licenses: [_license('1')], + ); + final controller = DownloadController(repository); + final haptics = FakeHaptics(); + + await _pumpPage( + tester, + auth: auth, + controller: controller, + haptics: haptics, + ); + await tester.tap(find.byKey(const Key('download-clear'))); + await tester.pumpAndSettle(); + expect(repository.clearCalls, 0); + + await tester.tap(find.widgetWithText(FilledButton, 'Clear')); + await tester.pumpAndSettle(); + + expect(repository.clearCalls, 1); + expect(haptics.mediumImpacts, 1); + expect(find.byKey(const Key('downloads-empty')), findsOneWidget); + expect(find.byType(EmptyState), findsOneWidget); + }); + + testWidgets( + 'should_render_unique_quality_options_when_policy_uses_a_preset', + (tester) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(maxQuality: '720P'), + ); + final controller = DownloadController(repository); + + await _pumpPage(tester, auth: auth, controller: controller); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('download-quality')), findsOneWidget); + }, + ); +} + +Future _auth({required bool signedIn}) async { + if (signedIn) { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"1","nickname":"Viewer","avatarUrl":""}', + }); + } + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ); + final auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.load(); + return auth; +} + +Future _pumpPage( + WidgetTester tester, { + required AuthController auth, + required DownloadController controller, + FakeHaptics? haptics, +}) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: controller), + Provider.value(value: haptics ?? FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const DownloadsPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class FakeDownloadRepository implements DownloadRepository { + FakeDownloadRepository({ + required this.policy, + this.licenses = const [], + this.failLoad = false, + }); + + DownloadPolicy policy; + List licenses; + bool failLoad; + int loadCalls = 0; + int settingsCalls = 0; + int clearCalls = 0; + bool? lastWifiOnly; + + @override + Future fetchPolicy() async { + loadCalls++; + if (failLoad) throw StateError('offline'); + return policy; + } + + @override + Future> list() async { + if (failLoad) throw StateError('offline'); + return licenses; + } + + @override + Future grant(String episodeId, {String? quality}) async => + _license(episodeId); + + @override + Future updateSettings({bool? wifiOnly, String? maxQuality}) async { + settingsCalls++; + lastWifiOnly = wifiOnly; + } + + @override + Future refreshUrl(String episodeId) async => + _license(episodeId); + + @override + Future reportProgress( + String episodeId, { + String? deviceId, + String? status, + int? progressPercent, + int? downloadedBytes, + int? totalBytes, + String? errorMsg, + }) async => _license(episodeId); + + @override + Future remove(String episodeId) async { + licenses = licenses.where((item) => item.episodeId != episodeId).toList(); + } + + @override + Future clear() async { + clearCalls++; + final count = licenses.length; + licenses = const []; + return count; + } +} diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart new file mode 100644 index 0000000..48b5e86 --- /dev/null +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart'; + +import '../support/fake_watch_history.dart'; + +/// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。 +DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) => + DramaDetailLogic( + drama: drama, + history: history, + playback: FakePlaybackRepository(), + favorites: FavoriteController(InMemoryFavoriteRepository()), + ); + +Drama _card(String id) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/$id.jpg', + totalEpisodes: 3, + episodes: [ + Episode( + id: '$id-1', + number: 1, + title: '第 1 集', + isFree: true, + videoUrl: '/files/$id-1.mp4', + ), + ], +); + +Drama _detail(String id) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/$id.jpg', + totalEpisodes: 3, + detailLoaded: true, + episodes: [ + for (var number = 1; number <= 3; number++) + Episode( + id: '$id-$number', + number: number, + title: '第 $number 集', + isFree: true, + videoUrl: '/files/$id-$number.mp4', + ), + ], +); + +void main() { + test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async { + var calls = 0; + final logic = _logic(_card('signal'), FakeWatchHistory()); + + await logic.load(() async { + calls++; + return _detail('signal'); + }); + + expect(calls, 1); + expect(logic.loading, isFalse); + expect(logic.failed, isFalse); + expect(logic.drama.episodes, hasLength(3)); + logic.dispose(); + }); + + test('should_keep_card_and_report_reason_when_load_fails', () async { + final logic = _logic(_card('signal'), FakeWatchHistory()); + + await logic.load(() async => throw const ApiException(500, '服务异常')); + + expect(logic.failed, isTrue); + expect(logic.failureMessage, '服务异常'); + expect(logic.drama.episodes, hasLength(1)); + + await logic.load(() async => throw StateError('offline')); + expect(logic.failed, isTrue); + expect(logic.failureMessage, isNull); + logic.dispose(); + }); + + test('should_skip_request_when_detail_is_already_loaded', () async { + var calls = 0; + final logic = _logic(_detail('signal'), FakeWatchHistory()); + + await logic.load(() async { + calls++; + return _detail('signal'); + }); + + expect(calls, 0); + expect(logic.drama.episodes, hasLength(3)); + logic.dispose(); + }); +} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart new file mode 100644 index 0000000..ed939b3 --- /dev/null +++ b/test/drama_detail/drama_detail_view_test.dart @@ -0,0 +1,236 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190); + expect( + tester.getSize(find.byKey(const Key('detail-back'))), + const Size(36, 36), + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('detail-back')), + matching: find.byType(Icon), + ), + ) + .size, + 18, + ); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40); + expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); + + final synopsis = tester.widget( + find.byKey(const Key('detail-synopsis')), + ); + expect(synopsis.style?.fontSize, 11); + expect(synopsis.style?.height, 1.6); + + await tester.tap(find.byKey(const Key('detail-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('detail-favorite')), findsOneWidget); + expect(find.byKey(const Key('detail-download')), findsOneWidget); + expect(find.byKey(const Key('detail-share')), findsOneWidget); + expect(find.byKey(const Key('detail-report')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_fit_compact_viewport_when_detail_scrolls', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + final grid = tester.widget( + find.byKey(const Key('detail-episode-chips')), + ); + expect( + (grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount) + .crossAxisCount, + 6, + ); + await tester.drag( + find.byKey(const Key('detail-scroll')), + const Offset(0, -520), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_render_sorted_six_column_grid_and_open_original_index', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama(count: 80))); + await tester.pumpAndSettle(); + + final grid = tester.widget( + find.byKey(const Key('detail-episode-chips')), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 6); + expect(delegate.mainAxisSpacing, 8); + expect(delegate.crossAxisSpacing, 8); + expect(delegate.childAspectRatio, closeTo(1.35, 0.001)); + + final chip1 = find.byKey(const Key('detail-episode-chip-1')); + final chip2 = find.byKey(const Key('detail-episode-chip-2')); + final chip6 = find.byKey(const Key('detail-episode-chip-6')); + final chip7 = find.byKey(const Key('detail-episode-chip-7')); + expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy); + expect( + tester.getTopLeft(chip7).dy, + greaterThan(tester.getTopLeft(chip1).dy), + ); + expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); + expect(tester.getSize(chip1), tester.getSize(chip2)); + + await tester.drag( + find.byKey(const Key('detail-scroll')), + const Offset(0, -420), + ); + await tester.pumpAndSettle(); + final chip12 = find.byKey(const Key('detail-episode-chip-12')); + expect(chip12, findsOneWidget); + await tester.tap(chip12); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.initialIndex, 11); + }); + + testWidgets('should_sort_episode_chips_ascending_by_number', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama(count: 80, descending: true))); + await tester.pumpAndSettle(); + + final chip1 = find.byKey(const Key('detail-episode-chip-1')); + final chip2 = find.byKey(const Key('detail-episode-chip-2')); + final chip6 = find.byKey(const Key('detail-episode-chip-6')); + final chip7 = find.byKey(const Key('detail-episode-chip-7')); + expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy); + expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); + expect( + tester.getTopLeft(chip7).dy, + greaterThan(tester.getTopLeft(chip1).dy), + ); + expect(tester.getSize(chip1), tester.getSize(chip2)); + }); +} + +Widget _host(Drama drama) { + final catalog = FakeCatalogRepository(dramas: [drama]); + final interactions = FakeInteractionRepository(); + return MultiProvider( + providers: [ + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakePlaybackRepository()), + Provider.value(value: createWidgetPlayer), + Provider(create: (_) => PlaybackCoordinator()), + Provider.value(value: interactions), + Provider.value(value: FakeCommentRepository()), + Provider.value(value: FakeHaptics()), + ChangeNotifierProvider( + create: (_) => FavoriteController(InMemoryFavoriteRepository()), + ), + ChangeNotifierProvider( + create: (_) => InteractionController(interactions), + ), + ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), + ], + child: MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: DramaDetailPage(drama: drama), + ), + ); +} + +Drama _detailDrama({int count = 8, bool descending = false}) { + final numbers = List.generate( + count, + (index) => descending ? count - index : index + 1, + ); + return Drama( + id: 'detail-k25', + title: 'Detail K25', + synopsis: + 'She agreed to marry a mysterious stranger to save her family, ' + 'until she discovered his secret identity and the city he controls.', + posterUrl: '/files/detail-k25.png', + genre: 'Urban Romance', + category: 'Romance', + totalEpisodes: count, + viewCount: 4800000, + detailLoaded: true, + episodes: [ + for (final number in numbers) + Episode( + id: 'detail-k25-$number', + number: number, + title: 'Episode $number', + isFree: number == 1, + durationSec: 135, + locked: number > 1, + videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '', + ), + ], + ); +} diff --git a/test/empty_state/empty_state_test.dart b/test/empty_state/empty_state_test.dart new file mode 100644 index 0000000..8f1588e --- /dev/null +++ b/test/empty_state/empty_state_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/core/widgets/empty_state.dart'; + +void main() { + testWidgets('should_render_title_subtitle_action_and_title_only_semantics', ( + tester, + ) async { + var taps = 0; + await _pump( + tester, + theme: AppTheme.dark(), + child: EmptyState( + icon: Icons.movie_outlined, + title: 'No stories', + subtitle: 'New content will appear here.', + actionLabel: 'Retry', + onAction: () => taps++, + ), + ); + + expect(find.byKey(const Key('empty-state-art')), findsNothing); + expect(find.byKey(const Key('empty-state-art-inner')), findsNothing); + expect(find.byKey(const Key('empty-state-icon')), findsOneWidget); + expect(find.text('No stories'), findsOneWidget); + expect(find.text('New content will appear here.'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('empty-state-icon'))), + const Size(48, 48), + ); + expect( + tester.widget(find.byKey(const Key('empty-state-icon'))).color, + AppColors.gold, + ); + expect( + tester + .widget(find.byKey(const Key('empty-state-padding'))) + .padding, + const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + ); + expect( + tester + .widget(find.byKey(const Key('empty-state-title'))) + .style + ?.fontSize, + 15, + ); + final subtitle = tester.widget( + find.text('New content will appear here.'), + ); + expect(subtitle.style?.fontSize, 12.5); + expect(tester.widget(find.text('Retry')).style?.fontSize, 13); + expect( + tester.getSize(find.byKey(const Key('empty-state-action'))).height, + greaterThanOrEqualTo(44), + ); + + final semantics = tester.ensureSemantics(); + expect(find.bySemanticsLabel('No stories'), findsOneWidget); + expect( + find.bySemanticsLabel('New content will appear here.'), + findsNothing, + ); + + await tester.tap(find.byKey(const Key('empty-state-action'))); + expect(taps, 1); + semantics.dispose(); + }); + + testWidgets('should_render_compact_dimensions_for_embedded_empty_state', ( + tester, + ) async { + await _pump( + tester, + child: const EmptyState( + icon: Icons.chat_bubble_outline_rounded, + title: 'No comments yet.', + compact: true, + ), + ); + + expect( + tester.getSize(find.byKey(const Key('empty-state-icon'))), + const Size(32, 32), + ); + expect(find.byType(DecoratedBox), findsNothing); + expect( + tester + .widget(find.byKey(const Key('empty-state-title'))) + .style + ?.fontSize, + 15, + ); + expect( + tester + .widget(find.byKey(const Key('empty-state-padding'))) + .padding, + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ); + expect(find.byKey(const Key('empty-state-action')), findsNothing); + }); + + testWidgets('should_tint_the_bare_icon_for_light_and_dark', (tester) async { + await _pump( + tester, + theme: AppTheme.dark(), + child: const EmptyState(icon: Icons.history_rounded, title: 'No history'), + ); + expect( + tester.widget(find.byKey(const Key('empty-state-icon'))).color, + AppColors.gold, + ); + + await _pump( + tester, + theme: AppTheme.light(), + child: const EmptyState(icon: Icons.history_rounded, title: 'No history'), + ); + expect( + tester.widget(find.byKey(const Key('empty-state-icon'))).color, + AppColors.blue, + ); + }); + + testWidgets('should_fit_compact_width_without_overflow', (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pump( + tester, + child: const EmptyState( + icon: Icons.download_outlined, + title: 'No download authorizations yet.', + subtitle: 'New content will appear here.', + actionLabel: 'Retry', + ), + ); + + final content = tester.widget( + find.byKey(const Key('empty-state-content')), + ); + expect(content.constraints.maxWidth, 260); + expect(tester.takeException(), isNull); + }); +} + +Future _pump( + WidgetTester tester, { + ThemeData? theme, + required Widget child, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: theme ?? AppTheme.dark(), + home: Scaffold(body: child), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/engagement/engagement_controller_test.dart b/test/engagement/engagement_controller_test.dart deleted file mode 100644 index 6126e5b..0000000 --- a/test/engagement/engagement_controller_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/core/data/services/engagement_controller.dart'; -import 'package:nontonki/core/data/repositories/engagement_repository.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; - -void main() { - test('saved, liked and comments are kept by drama id', () async { - final repository = InMemoryEngagementRepository(); - final first = EngagementController(repository); - await first.load(); - await first.toggleSaved('signal'); - await first.toggleLiked('signal'); - await first.addComment('signal', ' Great clip '); - - expect(first.isSaved('signal'), isTrue); - expect(first.isSaved('city'), isFalse); - expect(first.isLiked('city'), isFalse); - expect(first.commentsFor('signal'), ['Great clip']); - expect(first.commentsFor('city'), isEmpty); - - final reopened = EngagementController(repository); - await reopened.load(); - expect(reopened.isSaved('signal'), isTrue); - expect(reopened.isLiked('signal'), isTrue); - expect(reopened.commentsFor('signal'), ['Great clip']); - await reopened.toggleSaved('signal'); - expect(reopened.isSaved('signal'), isFalse); - }); - - test('local engagement survives a new repository instance', () async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final first = EngagementController(SharedPreferencesEngagementRepository()); - await first.load(); - await first.toggleSaved('city'); - await first.addComment('city', 'Local note'); - - final reopened = EngagementController( - SharedPreferencesEngagementRepository(), - ); - await reopened.load(); - expect(reopened.isSaved('city'), isTrue); - expect(reopened.commentsFor('city'), ['Local note']); - }); -} diff --git a/test/formatting/localized_content_test.dart b/test/formatting/localized_content_test.dart new file mode 100644 index 0000000..efe7bb7 --- /dev/null +++ b/test/formatting/localized_content_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/formatting/localized_content.dart'; +import 'package:fruitshorts/l10n/app_localizations_en.dart'; +import 'package:fruitshorts/l10n/app_localizations_id.dart'; +import 'package:fruitshorts/l10n/app_localizations_zh.dart'; + +Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}) => + Drama( + id: id, + title: title, + synopsis: synopsis, + posterUrl: '/files/poster.png', + episodes: const [], + ); + +void main() { + final en = AppLocalizationsEn(); + final zh = AppLocalizationsZh(); + final id = AppLocalizationsId(); + + test('should_map_each_seeded_drama_to_its_own_localized_copy_when_looked_up', () { + final signal = _drama(id: 'the-signal-demo'); + final city = _drama(id: 'city-lights-demo'); + final vengeance = _drama(id: 'vengeance-demo'); + + expect(localizedDramaTitle(en, signal), en.dramaSignalTitle); + expect(localizedDramaTitle(en, city), en.dramaCityTitle); + expect(localizedDramaTitle(en, vengeance), en.dramaVengeanceTitle); + + expect(localizedDramaSynopsis(en, signal), en.dramaSignalSynopsis); + expect(localizedDramaSynopsis(en, city), en.dramaCitySynopsis); + expect(localizedDramaSynopsis(en, vengeance), en.dramaVengeanceSynopsis); + }); + + test('should_keep_two_dramas_apart_when_localized_copy_is_mapped', () { + final titles = { + localizedDramaTitle(en, _drama(id: 'the-signal-demo')), + localizedDramaTitle(en, _drama(id: 'city-lights-demo')), + localizedDramaTitle(en, _drama(id: 'vengeance-demo')), + }; + final synopses = { + localizedDramaSynopsis(en, _drama(id: 'the-signal-demo')), + localizedDramaSynopsis(en, _drama(id: 'city-lights-demo')), + localizedDramaSynopsis(en, _drama(id: 'vengeance-demo')), + }; + + expect(titles, hasLength(3)); + expect(synopses, hasLength(3)); + }); + + test('should_translate_localized_copy_when_content_is_looked_up', () { + final signal = _drama(id: 'the-signal-demo'); + + expect(localizedDramaTitle(zh, signal), zh.dramaSignalTitle); + expect(localizedDramaSynopsis(zh, signal), zh.dramaSignalSynopsis); + expect(localizedDramaTitle(zh, signal), isNot(en.dramaSignalTitle)); + }); + + test('should_keep_catalog_text_in_every_locale_when_drama_is_unknown', () { + final custom = _drama( + id: 'custom-demo', + title: 'Custom Title', + synopsis: 'Custom synopsis', + ); + + expect(localizedDramaTitle(en, custom), 'Custom Title'); + expect(localizedDramaTitle(zh, custom), 'Custom Title'); + expect(localizedDramaSynopsis(en, custom), 'Custom synopsis'); + expect(localizedDramaSynopsis(zh, custom), 'Custom synopsis'); + }); + + test('should_ignore_raw_catalog_text_when_drama_is_seeded', () { + final signal = _drama( + id: 'the-signal-demo', + title: 'stale title', + synopsis: 'stale synopsis', + ); + + expect(localizedDramaTitle(en, signal), isNot('stale title')); + expect(localizedDramaSynopsis(en, signal), isNot('stale synopsis')); + }); + + test('should_use_named_titles_when_episode_number_is_one_to_four', () { + expect(localizedEpisodeTitle(en, 1), en.episodeOneTitle); + expect(localizedEpisodeTitle(en, 2), en.episodeTwoTitle); + expect(localizedEpisodeTitle(en, 3), en.episodeThreeTitle); + expect(localizedEpisodeTitle(en, 4), en.episodeFourTitle); + }); + + test('should_fall_back_to_numbered_label_when_episode_number_is_later', () { + for (final number in [5, 12, 100]) { + expect(localizedEpisodeTitle(en, number), en.episodeLabel(number)); + expect(localizedEpisodeTitle(en, number), contains('$number')); + } + expect(localizedEpisodeTitle(zh, 12), zh.episodeLabel(12)); + }); + + test('should_build_share_copy_from_localized_title_synopsis_and_footer', () { + final signal = _drama(id: 'the-signal-demo'); + final copies = { + for (final l10n in [en, zh, id]) + '${localizedDramaTitle(l10n, signal)}\n' + '${localizedDramaSynopsis(l10n, signal)}\n' + '${l10n.shareFooter}', + }; + + expect(copies, hasLength(3)); + for (final copy in copies) { + expect(copy, contains('FruitShorts')); + expect(copy.split('\n'), hasLength(3)); + expect(copy.split('\n').every((line) => line.isNotEmpty), isTrue); + } + }); +} diff --git a/test/formatting/localized_date_time_test.dart b/test/formatting/localized_date_time_test.dart new file mode 100644 index 0000000..2df78be --- /dev/null +++ b/test/formatting/localized_date_time_test.dart @@ -0,0 +1,41 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/formatting/localized_date_time.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; + +void main() { + testWidgets( + 'should_use_indonesian_material_date_and_time_when_locale_is_id', + (tester) async { + final value = DateTime(2026, 10, 17, 12, 30); + late String formatted; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('id'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + formatted = formatLocalizedDateTime(context, value); + final localizations = MaterialLocalizations.of(context); + final expectedDate = localizations.formatCompactDate( + value.toLocal(), + ); + final expectedTime = localizations.formatTimeOfDay( + TimeOfDay.fromDateTime(value.toLocal()), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf( + context, + ), + ); + expect(formatted, '$expectedDate $expectedTime'); + return const SizedBox.shrink(); + }, + ), + ), + ); + }, + ); +} diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart new file mode 100644 index 0000000..6e0d181 --- /dev/null +++ b/test/home/app_bottom_nav_test.dart @@ -0,0 +1,46 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; + +void main() { + testWidgets('should_render_64_high_navigation_and_keep_item_keys', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: AppBottomNav( + items: [ + AppBottomNavItem( + tabKey: const Key('tab-home'), + icon: Icons.movie_outlined, + selectedIcon: Icons.movie_rounded, + label: 'Home', + selected: true, + onTap: () {}, + ), + AppBottomNavItem( + tabKey: const Key('tab-my'), + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, + label: 'Me', + onTap: () {}, + ), + ], + ), + ), + ), + ); + + expect(find.byKey(const Key('tab-home')), findsOneWidget); + expect(find.byKey(const Key('tab-my')), findsOneWidget); + expect(find.text('Home'), findsOneWidget); + expect( + tester.getSize(find.byType(AppBottomNav)).height, + AppDimens.navHeight, + ); + }); +} diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart new file mode 100644 index 0000000..85c609c --- /dev/null +++ b/test/home/home_logic_test.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/pages/discover/discover_logic.dart'; +import 'package:fruitshorts/pages/home/home_logic.dart'; +import 'package:fruitshorts/pages/mine/mine_logic.dart'; + +Drama _drama(String id) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/poster.png', + episodes: const [], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final dramas = [_drama('signal'), _drama('city')]; + + test( + 'should_make_first_catalog_entry_the_current_drama_when_catalog_loads', + () { + final logic = HomeLogic(dramas); + + expect(logic.currentDrama.id, 'signal'); + expect(logic.tab, 0); + expect(logic.mySection, 0); + expect(logic.drawerOpen, isFalse); + expect(logic.profile, isFalse); + expect(logic.videoImmersive, isFalse); + logic.dispose(); + }, + ); + + test('should_not_build_shell_when_catalog_is_empty', () { + expect(() => HomeLogic(const []), throwsStateError); + }); + + test('should_not_raise_request_counter_when_only_tab_switches', () { + final logic = HomeLogic(dramas); + var notifications = 0; + logic.addListener(() => notifications++); + + logic.select(1); + + expect(logic.tab, 1); + expect(logic.searchRequest, 0); + expect(logic.sectionRequest, 0); + expect(logic.resumeRequest, 0); + expect(logic.backRequest, 0); + expect(notifications, 1); + logic.dispose(); + }); + + test('should_raise_only_its_own_counter_when_each_select_flag_is_used', () { + final logic = HomeLogic(dramas); + + logic.select(1, search: true); + expect(logic.tab, 1); + expect(logic.searchRequest, 1); + expect(logic.sectionRequest, 0); + + logic.select(2, mySection: 0); + expect(logic.mySection, 0); + expect(logic.sectionRequest, 1); + expect(logic.searchRequest, 1); + + logic.select(0, resume: true); + expect(logic.resumeRequest, 1); + expect(logic.sectionRequest, 1); + + expect(logic.backRequest, 0); + logic.dispose(); + }); + + test('should_default_to_second_section_when_watchlist_shortcut_is_used', () { + final logic = HomeLogic(dramas); + + logic.select(2, watchlist: true); + + expect(logic.tab, 2); + expect(logic.mySection, 1); + expect(logic.sectionRequest, 1); + logic.dispose(); + }); + + test( + 'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', + () { + final logic = HomeLogic(dramas); + + logic.openProfile(); + expect(logic.profile, isTrue); + expect(logic.tab, 2); + expect(logic.mySection, 0); + expect(logic.sectionRequest, 1); + + logic.openLibrary(section: 1); + expect(logic.profile, isFalse); + expect(logic.mySection, 1); + expect(logic.sectionRequest, 2); + + logic.openLibrary(); + expect(logic.profile, isFalse); + expect(logic.mySection, 1); + expect(logic.sectionRequest, 3); + logic.dispose(); + }, + ); + + test('should_return_to_discover_and_handle_immersive_back', () { + final logic = HomeLogic(dramas); + + // Home is the launch tab; the first back press lands on Discover. + logic.onSystemBack(); + expect(logic.tab, 1); + logic.onSystemBack(); + expect(logic.tab, 1); + expect(logic.backRequest, 0); + + logic.select(0); + logic.onSystemBack(); + expect(logic.tab, 1); + expect(logic.backRequest, 0); + + logic.select(0); + logic.onImmersiveChanged(true); + logic.onSystemBack(); + expect(logic.backRequest, 1); + expect(logic.tab, 0); + + logic.onImmersiveChanged(false); + logic.onSystemBack(); + expect(logic.tab, 1); + logic.dispose(); + }); + + test('should_notify_only_on_actual_change_when_menu_state_is_set', () { + final logic = HomeLogic(dramas); + var notifications = 0; + logic.addListener(() => notifications++); + + logic.setMenuOpen(false); + expect(notifications, 0); + + logic.setMenuOpen(true); + expect(logic.drawerOpen, isTrue); + expect(notifications, 1); + + logic.setMenuOpen(true); + expect(notifications, 1); + logic.dispose(); + }); + + test('should_update_state_when_drama_and_immersive_callbacks_fire', () { + final logic = HomeLogic(dramas); + + logic.onDramaChanged(dramas[1]); + expect(logic.currentDrama.id, 'city'); + + logic.onImmersiveChanged(true); + expect(logic.videoImmersive, isTrue); + logic.dispose(); + }); + + test('should_map_onto_root_tabs_when_my_page_actions_fire', () { + final logic = HomeLogic(dramas); + + logic.myAction(MyPageAction.discover); + expect(logic.tab, 1); + expect(logic.searchRequest, 1); + + logic.myAction(MyPageAction.resume); + expect(logic.tab, 0); + expect(logic.resumeRequest, 1); + + logic.myAction(MyPageAction.home); + expect(logic.tab, 0); + expect(logic.resumeRequest, 1); + logic.dispose(); + }); + + test('should_do_nothing_when_notifying_after_dispose', () { + final logic = HomeLogic(dramas); + var notifications = 0; + logic.addListener(() => notifications++); + + logic.dispose(); + logic.notify(); + + expect(notifications, 0); + }); + + testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', ( + tester, + ) async { + final logic = HomeLogic(dramas); + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (inner) { + context = inner; + return const SizedBox.shrink(); + }, + ), + ), + ); + + logic.discoverAction(context, DiscoverPageAction.watchlist); + expect(logic.tab, 2); + expect(logic.profile, isFalse); + expect(logic.mySection, 1); + expect(logic.sectionRequest, 1); + + logic.discoverAction(context, DiscoverPageAction.my); + expect(logic.tab, 2); + expect(logic.profile, isFalse); + + logic.discoverAction(context, DiscoverPageAction.resume); + expect(logic.tab, 0); + expect(logic.resumeRequest, 1); + + logic.discoverAction(context, DiscoverPageAction.home); + expect(logic.tab, 0); + + expect(tester.takeException(), isNull); + logic.dispose(); + }); +} diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart new file mode 100644 index 0000000..0c9d15a --- /dev/null +++ b/test/home/home_nav_ui_test.dart @@ -0,0 +1,163 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +const _keys = [ + Key('home-tabs-bar'), + Key('tab-home'), + Key('tab-discover'), + Key('tab-my'), +]; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); + }); + + testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', ( + tester, + ) async { + final haptics = FakeHaptics(); + await _pumpApp(tester, haptics); + haptics.reset(); + + for (final key in _keys) { + expect(find.byKey(key), findsOneWidget); + } + for (final key in _keys.skip(1)) { + expect(tester.getSize(find.byKey(key)).height, greaterThanOrEqualTo(48)); + } + + final barFinder = find.byKey(const Key('home-tabs-bar')); + final bar = tester.widget(barFinder); + final decoration = bar.decoration as BoxDecoration; + final border = decoration.border as Border; + expect(decoration.color, AppColors.bar.withValues(alpha: 0.95)); + expect(border.top.color, AppColors.outlineVariantDark); + expect(border.top.color, isNot(Colors.white)); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byType(AnimatedScale), + ), + ) + .duration, + lessThanOrEqualTo(const Duration(milliseconds: 200)), + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byIcon(Icons.play_circle_fill_rounded), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(haptics.selectionClicks, 1); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(haptics.selectionClicks, 1); + expect( + find.descendant( + of: find.byKey(const Key('tab-discover')), + matching: find.byIcon(Icons.explore_rounded), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byIcon(Icons.play_circle_outline_rounded), + ), + findsOneWidget, + ); + }); + + testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { + await _pumpApp(tester); + + expect(find.byKey(const Key('tab-home')), findsOneWidget); + expect(find.byKey(const Key('tab-discover')), findsOneWidget); + expect(find.byKey(const Key('tab-my')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + expect(find.text('Viewer'), findsOneWidget); + expect( + find.byKey(const Key('mine-library-tab-0'), skipOffstage: false), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + }); + + testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( + tester, + ) async { + final haptics = FakeHaptics(); + await _pumpApp(tester, haptics); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + haptics.reset(); + + await tester.drag(find.byType(ListView), const Offset(0, -700)); + await tester.pumpAndSettle(); + await tester.tap(find.text('App Language')); + await tester.pumpAndSettle(); + + expect(haptics.calls, isEmpty); + }); +} + +Future _pumpApp(WidgetTester tester, [FakeHaptics? haptics]) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: haptics ?? FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); +} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart new file mode 100644 index 0000000..753de68 --- /dev/null +++ b/test/home/home_preview_layout_test.dart @@ -0,0 +1,158 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episode')), findsOneWidget); + final cardRect = tester.getRect( + find.byKey(const Key('home-feed-info-card')), + ); + final episodeRect = tester.getRect( + find.byKey(const Key('home-feed-episode')), + ); + expect(episodeRect.left, lessThan(cardRect.center.dx)); + final railCenter = tester.getCenter(find.byKey(const Key('rail-like'))); + final railBottom = tester.getBottomLeft(find.byKey(const Key('rail-save'))); + expect(railCenter.dy, greaterThan(844 * 0.4)); + expect(railBottom.dy, lessThan(cardRect.top)); + expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); + for (final key in const [Key('home-feed-search'), Key('home-feed-more')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ) + .size, + 18, + ); + } + expect( + tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height, + 34, + ); + expect(find.text('HOT'), findsNothing); + expect(find.textContaining('Season'), findsNothing); + expect(find.byKey(const Key('home-feed-follow')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_use_episode_title_then_drama_title_as_fallback', ( + tester, + ) async { + Future pumpEpisode(Episode episode) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: _drama, + episode: episode, + liked: false, + saved: false, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDownload: () {}, + onClear: () {}, + onSearch: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + await pumpEpisode( + const Episode( + id: 'featured', + number: 2, + title: 'Midnight Contract', + isFree: true, + ), + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + 'Midnight Contract', + ); + + await pumpEpisode( + const Episode(id: 'fallback', number: 2, title: '', isFree: true), + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + 'Fallback Drama', + ); + }); +} + +const _drama = Drama( + id: 'featured-drama', + title: 'Fallback Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, +); diff --git a/test/home/home_server_setting_test.dart b/test/home/home_server_setting_test.dart new file mode 100644 index 0000000..239da4f --- /dev/null +++ b/test/home/home_server_setting_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/home/home_view.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets( + 'should_show_server_settings_and_open_dialog_when_debug_tools_are_enabled', + (tester) async { + await _pumpFailureView(tester, showDebugTools: true); + + final button = find.byKey(const Key('home-server-settings')); + expect(button, findsOneWidget); + expect(find.text('Server settings'), findsOneWidget); + + await tester.tap(button); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('server-endpoint-input')), findsOneWidget); + expect(find.text('Restart the app to apply.'), findsOneWidget); + }, + ); + + testWidgets('should_hide_server_settings_when_debug_tools_are_disabled', ( + tester, + ) async { + await _pumpFailureView(tester, showDebugTools: false); + + expect(find.byKey(const Key('home-server-settings')), findsNothing); + expect(find.text('Server settings'), findsNothing); + }); +} + +Future _pumpFailureView( + WidgetTester tester, { + required bool showDebugTools, +}) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: HomeFailureView(onRetry: () {}, showDebugTools: showDebugTools), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/integration/live_auth_test.dart b/test/integration/live_auth_test.dart new file mode 100644 index 0000000..9fbf452 --- /dev/null +++ b/test/integration/live_auth_test.dart @@ -0,0 +1,34 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; + +/// 联调测试:直连本机客户端接口服务(8081)验证 **POST + JSON body + 错误码映射**。 +/// +/// 特意用"不存在的邮箱"登录:既能走完整的 POST 链路,又不会在库里留下测试数据。 +/// 服务没启动时自动跳过,不影响日常 `flutter test`。 +void main() { + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081'); + final api = AuthApi(client); + + tearDownAll(client.close); + + test('should_surface_business_error_when_sign_in_uses_unknown_email', () async { + try { + await api.login( + email: 'no-such-user-for-dio-check@example.com', + password: 'abcd1234', + deviceId: 'dio-check-device', + ); + fail('不存在的邮箱不应该登录成功'); + } on ApiException catch (error) { + // 关键点:错误码来自响应体(不是 HTTP 状态码),说明统一解包生效 + expect(error.code, isNot(0)); + expect(error.message, isNotEmpty); + } catch (_) { + markTestSkipped('客户端接口服务未启动,已跳过联调测试'); + } + }); +} diff --git a/test/integration/live_catalog_test.dart b/test/integration/live_catalog_test.dart new file mode 100644 index 0000000..274cf60 --- /dev/null +++ b/test/integration/live_catalog_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/catalog_api.dart'; +import 'package:fruitshorts/core/data/remote/playback_api.dart'; + +/// 联调测试:直连本机客户端接口服务(8081)。 +/// 服务没启动时自动跳过,不影响日常 `flutter test`。 +void main() { + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081'); + final api = CatalogApi(client); + final playback = PlaybackApi(client); + + tearDownAll(client.close); + + Future serverUp() async { + try { + await api.homeFeed(); + return true; + } catch (_) { + return false; + } + } + + test('should_parse_banner_and_sections_when_home_feed_is_fetched', () async { + if (!await serverUp()) { + markTestSkipped('客户端接口服务未启动,已跳过联调测试'); + return; + } + final feed = await api.homeFeed(); + expect(feed.sections, isNotEmpty); + expect(feed.dramas, isNotEmpty); + final card = feed.dramas.first; + expect(card.id, isNotEmpty); + expect(card.title, isNotEmpty); + expect(card.posterUrl, startsWith('/files/')); + }); + + test('should_order_episodes_by_number_and_attach_url_when_detail_is_fetched', () async { + if (!await serverUp()) { + markTestSkipped('客户端接口服务未启动,已跳过联调测试'); + return; + } + final feed = await api.homeFeed(); + final card = feed.dramas.first; + final detail = await api.dramaDetail(card.id); + + expect(detail.id, card.id); + expect(detail.episodes, isNotEmpty); + final numbers = detail.episodes.map((episode) => episode.number).toList(); + expect(numbers, orderedEquals([...numbers]..sort())); + for (final episode in detail.episodes) { + if (episode.locked) { + expect(episode.videoUrl, isEmpty); + } else { + expect(episode.videoUrl, startsWith('/files/')); + } + } + }); + + test('should_return_play_url_when_episode_is_playable', () async { + if (!await serverUp()) { + markTestSkipped('客户端接口服务未启动,已跳过联调测试'); + return; + } + final feed = await api.homeFeed(); + final detail = await api.dramaDetail(feed.dramas.first.id); + final playable = detail.episodes + .where((episode) => episode.videoUrl.isNotEmpty) + .toList(); + if (playable.isEmpty) { + return; + } + + final ticket = await playback.ticket(playable.first.id); + expect(ticket.accessible, isTrue); + expect(ticket.playUrl, startsWith('/files/')); + }); +} diff --git a/test/integration/live_watch_flow_test.dart b/test/integration/live_watch_flow_test.dart new file mode 100644 index 0000000..aafd8df --- /dev/null +++ b/test/integration/live_watch_flow_test.dart @@ -0,0 +1,114 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/catalog_api.dart'; +import 'package:fruitshorts/core/data/remote/library_api.dart'; +import 'package:fruitshorts/core/data/remote/playback_api.dart'; +import 'package:fruitshorts/core/data/remote/watch_api.dart'; + +/// K1 端到端联调:直连本机客户端接口服务(8081),把"首页 → 详情 → 播放票据 → +/// 上报进度 → 读回续看位置 → 收藏 → 收藏列表 → 取消收藏"整条链路走一遍。 +/// +/// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。 +void main() { + const baseUrl = 'http://127.0.0.1:8081'; + final email = 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com'; + const password = 'Passw0rd!23'; + + AuthSession? session; + final client = ApiClient( + baseUrl: baseUrl, + accessTokenReader: () => session?.accessToken, + deviceIdReader: () async => 'k1-live-device', + ); + final catalog = CatalogApi(client); + final playback = PlaybackApi(client); + final watch = WatchApi(client); + final library = LibraryApi(client); + + tearDownAll(client.close); + + Future register() async { + try { + final raw = await client.post( + '/app/auth/register', + body: { + 'targetType': 'EMAIL', + 'target': email, + 'password': password, + 'nickname': 'K1 联调', + 'deviceId': 'k1-live-device', + }, + ); + if (raw is! Map) return false; + session = AuthSession.fromJson(raw); + return true; + } catch (_) { + return false; + } + } + + test('should_complete_the_watch_flow_when_backend_is_running', () async { + if (!await register()) { + markTestSkipped('客户端接口服务未启动,已跳过联调测试'); + return; + } + expect(session?.user.uid, isNotEmpty); + + // 1) 首页 → 取第一部剧 + final feed = await catalog.homeFeed(); + expect(feed.dramas, isNotEmpty); + final card = feed.dramas.first; + expect(card.id, isNotEmpty); + + // 2) 详情:剧集列表按集号升序,可播剧集带已签名地址 + final detail = await catalog.dramaDetail(card.id); + expect(detail.id, card.id); + expect(detail.episodes, isNotEmpty); + final playable = detail.episodes + .where((episode) => episode.videoUrl.isNotEmpty) + .toList(); + if (playable.isEmpty) { + markTestSkipped('这部演示剧没有可播剧集,已跳过播放相关断言'); + return; + } + + // 3) 播放票据:签名地址带 e/s,客户端原样使用 + final ticket = await playback.ticket(playable.first.id); + expect(ticket.accessible, isTrue); + expect(ticket.accessType, isNotEmpty); + expect(ticket.playUrl, startsWith('/files/')); + expect(ticket.playUrl, contains('e=')); + expect(ticket.playUrl, contains('s=')); + + // 4) 上报进度 → 读回续看位置 + const position = Duration(seconds: 42); + final saved = await watch.saveProgress( + episodeId: playable.first.id, + position: position, + duration: Duration(seconds: ticket.durationSec), + ); + expect(saved?.episodeId, playable.first.id); + expect(saved?.position, position); + + final resume = await watch.progressOfDrama(card.id); + expect(resume?.episodeId, playable.first.id); + expect(resume?.position, position); + + // 最近观看里能看到这部剧 + final recent = await watch.recent(limit: 20); + expect(recent.map((item) => item.dramaId), contains(card.id)); + + // 5) 收藏 → 出现在收藏列表 → 取消收藏 + final savedState = await library.toggleFavorite(card.id); + expect(savedState, isTrue); + final favorites = await library.favorites(); + expect(favorites.map((drama) => drama.id), contains(card.id)); + + await library.removeFavorite(card.id); + final afterRemove = await library.favorites(); + expect(afterRemove.map((drama) => drama.id), isNot(contains(card.id))); + }); +} diff --git a/test/interaction/interaction_api_test.dart b/test/interaction/interaction_api_test.dart new file mode 100644 index 0000000..47d9417 --- /dev/null +++ b/test/interaction/interaction_api_test.dart @@ -0,0 +1,67 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/interaction_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + String envelope(Object? data) => + jsonEncode({'code': 0, 'message': 'ok', 'data': data}); + + test('should_send_like_target_and_parse_string_count_when_toggled', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope({'liked': true, 'count': '2111012753262120962'}), + ), + ); + final api = InteractionApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final result = await api.toggleLike( + targetType: InteractionTargetType.drama, + targetId: '5', + ); + + expect(adapter.requests.single.method, 'POST'); + expect(adapter.requests.single.uri.path, '/app/likes/toggle'); + expect(adapter.requests.single.data, { + 'targetType': 'DRAMA', + 'targetId': '5', + }); + expect(result.liked, isTrue); + expect(result.count, 2111012753262120962); + }); + + test('should_submit_fixed_reason_code_and_optional_description_when_reporting', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope('2111012753262120999')), + ); + final api = InteractionApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final reportId = await api.report( + const ReportRequest( + targetType: InteractionTargetType.comment, + targetId: '7', + reasonCode: 'SPAM', + description: 'Repeated advertising', + ), + ); + + expect(adapter.requests.single.uri.path, '/app/reports'); + expect(adapter.requests.single.data, { + 'targetType': 'COMMENT', + 'targetId': '7', + 'reasonCode': 'SPAM', + 'description': 'Repeated advertising', + }); + expect(reportId, '2111012753262120999'); + }); +} diff --git a/test/interaction/interaction_controller_test.dart b/test/interaction/interaction_controller_test.dart new file mode 100644 index 0000000..b2690b3 --- /dev/null +++ b/test/interaction/interaction_controller_test.dart @@ -0,0 +1,60 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/services/interaction_controller.dart'; + +import '../support/fake_interaction_repositories.dart'; + +void main() { + test('should_update_drama_like_optimistically_and_use_server_state_when_toggle_succeeds', () async { + final repository = FakeInteractionRepository(); + final gate = Completer(); + repository.likeGate = gate; + final controller = InteractionController(repository); + + final future = controller.toggleDramaLike('5'); + expect(controller.isLiked('5'), isTrue); + expect(repository.likeTargets, ['DRAMA:5']); + + repository.nextLikeResult = const LikeToggleResult(liked: false, count: 0); + gate.complete(); + final result = await future; + + expect(result.isSuccess, isTrue); + expect(controller.isLiked('5'), isFalse); + controller.dispose(); + }); + + test('should_rollback_drama_like_when_toggle_fails', () async { + final repository = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 3), + ); + final controller = InteractionController(repository); + await controller.toggleDramaLike('5'); + repository.likeError = const ApiException(500, '点赞失败'); + + final result = await controller.toggleDramaLike('5'); + + expect(result.isSuccess, isFalse); + expect(result.errorMessage, '点赞失败'); + expect(controller.isLiked('5'), isTrue); + controller.dispose(); + }); + + test('should_clear_account_scoped_drama_likes_when_session_changes', () async { + final repository = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 1), + ); + final controller = InteractionController(repository); + await controller.toggleDramaLike('5'); + + controller.clear(); + + expect(controller.isLiked('5'), isFalse); + controller.dispose(); + }); +} diff --git a/test/interaction/report_dialog_test.dart b/test/interaction/report_dialog_test.dart new file mode 100644 index 0000000..41262d7 --- /dev/null +++ b/test/interaction/report_dialog_test.dart @@ -0,0 +1,55 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/interaction/widgets/report_dialog.dart'; + +void main() { + testWidgets('should_return_selected_reason_and_description_when_submitted', ( + tester, + ) async { + ReportDialogResult? result; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + key: const Key('open-report-dialog'), + onPressed: () async { + result = await showReportDialog( + context, + title: 'Report comment', + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open-report-dialog'))); + await tester.pumpAndSettle(); + + expect(find.text('Spam'), findsOneWidget); + expect(find.text('Harassment'), findsOneWidget); + expect(find.text('Sexual content'), findsOneWidget); + expect(find.text('Advertising'), findsOneWidget); + expect(find.text('Other'), findsOneWidget); + + await tester.tap(find.byKey(const Key('report-reason-AD'))); + await tester.enterText( + find.byKey(const Key('report-description')), + 'Repeat promotion', + ); + await tester.tap(find.byKey(const Key('report-submit'))); + await tester.pumpAndSettle(); + + expect(result?.reason, ReportReason.ad); + expect(result?.description, 'Repeat promotion'); + }); +} diff --git a/test/library/favorite_controller_test.dart b/test/library/favorite_controller_test.dart new file mode 100644 index 0000000..0e5a96c --- /dev/null +++ b/test/library/favorite_controller_test.dart @@ -0,0 +1,118 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; + +Drama _card(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'synopsis', + posterUrl: '/files/$id.png', +); + +void main() { + test('should_roll_back_when_toggle_fails', () async { + final repository = InMemoryFavoriteRepository()..failToggle = true; + final controller = FavoriteController(repository); + + final ok = await controller.toggle('signal'); + + expect(ok, isFalse); + // 乐观更新必须回滚:不能"看起来收藏了,其实服务端没存" + expect(controller.isSaved('signal'), isFalse); + expect(controller.cards, isEmpty); + }); + + test('should_keep_saved_state_when_toggle_succeeds', () async { + final controller = FavoriteController(InMemoryFavoriteRepository()); + + final ok = await controller.toggle('signal', card: _card('signal')); + + expect(ok, isTrue); + expect(controller.isSaved('signal'), isTrue); + // 卡片随手带上:收藏完打开"我的收藏"就能看到 + expect(controller.cards.map((drama) => drama.id), ['signal']); + }); + + test('should_remove_card_and_state_when_toggled_off', () async { + final repository = InMemoryFavoriteRepository(saved: {'signal'}); + final controller = FavoriteController(repository); + // 先按服务端状态对齐,再取消收藏 + await controller.load(); + + await controller.toggle('signal'); + + expect(controller.isSaved('signal'), isFalse); + expect(controller.cards, isEmpty); + }); + + test('should_expose_error_and_keep_old_state_when_list_fails', () async { + final repository = InMemoryFavoriteRepository()..failList = true; + final controller = FavoriteController(repository); + + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.error, 'favoritesError'); + }); + + test('should_load_cards_and_saved_ids_when_list_succeeds', () async { + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal'), _card('city')], + ); + final controller = FavoriteController(repository); + + await controller.load(); + + expect(controller.error, isNull); + expect(controller.cards.map((drama) => drama.id), ['signal']); + expect(controller.isSaved('signal'), isTrue); + expect(controller.isSaved('city'), isFalse); + }); + + test('should_roll_back_when_remove_fails', () async { + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal')], + )..failRemove = true; + final controller = FavoriteController(repository); + await controller.load(); + + final ok = await controller.remove('signal'); + + expect(ok, isFalse); + expect(controller.isSaved('signal'), isTrue); + expect(controller.cards.map((drama) => drama.id), ['signal']); + }); + + test( + 'should_restore_removed_card_at_original_position_when_remove_fails', + () async { + final repository = InMemoryFavoriteRepository( + saved: {'signal', 'city'}, + cards: [_card('signal'), _card('city')], + )..failRemove = true; + final controller = FavoriteController(repository); + await controller.load(); + + await controller.remove('signal'); + + expect(controller.cards.map((drama) => drama.id), ['signal', 'city']); + }, + ); + + test('should_clear_state_when_signing_out', () async { + final controller = FavoriteController( + InMemoryFavoriteRepository(saved: {'signal'}, cards: [_card('signal')]), + ); + await controller.load(); + + controller.clear(); + + expect(controller.savedIds, isEmpty); + expect(controller.cards, isEmpty); + }); +} diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart new file mode 100644 index 0000000..3d12478 --- /dev/null +++ b/test/library/favorites_page_test.dart @@ -0,0 +1,161 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/widgets/empty_state.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/favorites/favorites_page.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; + +Drama _card(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'synopsis', + posterUrl: '/files/$id.png', + totalEpisodes: 12, +); + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_favorite_cards_and_visible_remove_action', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final favorites = FavoriteController( + InMemoryFavoriteRepository( + saved: {'signal', 'city'}, + cards: [_card('signal'), _card('city')], + ), + ); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + expect(find.text('Drama signal'), findsOneWidget); + expect(find.text('Drama city'), findsOneWidget); + expect(find.byKey(const Key('favorite-remove-signal')), findsOneWidget); + }); + + testWidgets('should_keep_card_and_show_failure_when_remove_fails', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal')], + )..failRemove = true; + final favorites = FavoriteController(repository); + final haptics = FakeHaptics(); + + await _pumpPage(tester, auth: auth, favorites: favorites, haptics: haptics); + await tester.tap(find.byKey(const Key('favorite-remove-signal'))); + await tester.pumpAndSettle(); + + expect(haptics.lightImpacts, 1); + expect(find.text('Drama signal'), findsOneWidget); + expect( + find.text('Could not update favorites. Please retry.'), + findsOneWidget, + ); + }); + + testWidgets('should_show_empty_state_when_favorite_list_is_empty', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final favorites = FavoriteController(InMemoryFavoriteRepository()); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + expect(find.byKey(const Key('favorites-empty')), findsOneWidget); + expect(find.byType(EmptyState), findsOneWidget); + }); + + testWidgets('should_show_retry_when_favorite_list_fails', (tester) async { + final auth = await _auth(signedIn: true); + final repository = InMemoryFavoriteRepository()..failList = true; + final favorites = FavoriteController(repository); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + expect(find.byKey(const Key('favorites-error')), findsOneWidget); + expect(find.byKey(const Key('favorites-retry')), findsOneWidget); + }); + + testWidgets( + 'should_show_auth_gate_without_loading_favorites_when_signed_out', + (tester) async { + final auth = await _auth(signedIn: false); + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal')], + ); + final favorites = FavoriteController(repository); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(repository.listCalls, 0); + }, + ); +} + +Future _auth({required bool signedIn}) async { + if (signedIn) { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"1","nickname":"Viewer","avatarUrl":""}', + }); + } + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ); + final auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.load(); + return auth; +} + +Future _pumpPage( + WidgetTester tester, { + required AuthController auth, + required FavoriteController favorites, + FakeHaptics? haptics, +}) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: favorites), + Provider.value(value: haptics ?? FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const FavoritesPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart new file mode 100644 index 0000000..5aed167 --- /dev/null +++ b/test/mine/mine_logic_test.dart @@ -0,0 +1,279 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/config/locale_controller.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/mine/mine_logic.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_watch_history.dart'; + +Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/poster.png', + episodes: [ + for (final number in numbers) + Episode( + id: '$id-$number', + number: number, + title: 'Episode $number', + videoUrl: 'https://example.test/clip.mp4', + isFree: true, + ), + ], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'should_keep_requested_section_and_read_episode_one_when_constructed', + () async { + final history = FakeWatchHistory()..saved = const Duration(seconds: 7); + final logic = MineLogic( + history: history, + drama: _drama('signal'), + initialSection: 1, + ); + + expect(logic.section, 1); + expect(logic.filter, 0); + expect(await logic.position, const Duration(seconds: 7)); + expect(history.reads, ['signal']); + logic.dispose(); + }, + ); + + test( + 'should_resume_from_last_watch_when_list_card_has_no_episodes', + () async { + final history = FakeWatchHistory() + ..saved = const Duration(seconds: 7) + ..lastEpisode = 'signal-3'; + final logic = MineLogic( + history: history, + drama: _drama('signal', numbers: const []), + initialSection: 1, + ); + + expect(await logic.position, const Duration(seconds: 7)); + expect(history.reads, ['signal']); + logic.dispose(); + }, + ); + + test('should_notify_when_section_and_filter_are_selected', () { + final logic = MineLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + initialSection: 0, + ); + var notifications = 0; + logic.addListener(() => notifications++); + + logic.selectSection(1); + expect(logic.section, 1); + expect(notifications, 1); + + logic.selectFilter(2); + expect(logic.filter, 2); + expect(notifications, 2); + logic.dispose(); + }); + + test('should_adopt_drama_only_when_page_is_active', () { + final history = FakeWatchHistory(); + final logic = MineLogic( + history: history, + drama: _drama('signal'), + initialSection: 0, + ); + + void sync( + Drama next, { + required bool active, + required bool wasActive, + required bool dramaChanged, + int sectionRequest = 0, + int previousSectionRequest = 0, + int initialSection = 0, + }) => logic.syncPage( + active: active, + wasActive: wasActive, + nextDrama: next, + dramaChanged: dramaChanged, + sectionRequest: sectionRequest, + previousSectionRequest: previousSectionRequest, + initialSection: initialSection, + ); + + sync(_drama('city'), active: false, wasActive: true, dramaChanged: true); + expect(logic.drama.id, 'signal'); + + sync(_drama('city'), active: true, wasActive: false, dramaChanged: false); + expect(logic.drama.id, 'city'); + + history.reads.clear(); + sync( + _drama('vengeance'), + active: true, + wasActive: true, + dramaChanged: true, + ); + expect(logic.drama.id, 'vengeance'); + expect(history.reads, ['vengeance']); + + history.reads.clear(); + sync(_drama('other'), active: true, wasActive: true, dramaChanged: false); + expect(logic.drama.id, 'vengeance'); + expect(history.reads, isEmpty); + logic.dispose(); + }); + + test( + 'should_reset_section_when_section_request_arrives_while_page_is_inactive', + () { + final logic = MineLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + initialSection: 0, + ); + + logic.syncPage( + active: false, + wasActive: false, + nextDrama: logic.drama, + dramaChanged: false, + sectionRequest: 1, + previousSectionRequest: 0, + initialSection: 1, + ); + + expect(logic.section, 1); + logic.dispose(); + }, + ); + + testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( + tester, + ) async { + final logic = MineLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + initialSection: 0, + ); + final forwarded = []; + final popped = Completer(); + late BuildContext pageContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (inner) { + pageContext = inner; + return const SizedBox.shrink(); + }, + ), + ) + .then(popped.complete), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + logic.act( + pageContext, + MyPageAction.discover, + embedded: true, + onAction: forwarded.add, + ); + expect(forwarded, [MyPageAction.discover]); + expect(Navigator.of(pageContext).canPop(), isTrue); + + logic.act(pageContext, MyPageAction.resume, embedded: false); + await tester.pumpAndSettle(); + + await expectLater(popped.future, completion(MyPageAction.resume)); + logic.dispose(); + }); + + testWidgets('should_switch_app_locale_when_language_sheet_is_used', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final locale = LocaleController(); + final logic = MineLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + initialSection: 0, + ); + late BuildContext context; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: locale, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (inner) { + context = inner; + return const Scaffold(body: SizedBox.shrink()); + }, + ), + ), + ), + ); + + unawaited(logic.showLanguage(context)); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of(context)!; + expect(find.text(l10n.english), findsOneWidget); + expect(find.text(l10n.simplifiedChinese), findsOneWidget); + expect(find.text('Bahasa Indonesia'), findsOneWidget); + + await tester.tap(find.text('Bahasa Indonesia')); + await tester.pumpAndSettle(); + expect(locale.locale, const Locale('id')); + + unawaited(logic.showLanguage(context)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.simplifiedChinese)); + await tester.pumpAndSettle(); + + expect(locale.locale, const Locale('zh')); + logic.dispose(); + }); + + test('should_do_nothing_when_notifying_after_dispose', () { + final logic = MineLogic( + history: FakeWatchHistory(), + drama: _drama('s'), + initialSection: 0, + ); + var notifications = 0; + logic.addListener(() => notifications++); + + logic.dispose(); + logic.notify(); + + expect(notifications, 0); + }); +} diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart new file mode 100644 index 0000000..d9c9946 --- /dev/null +++ b/test/mine/mine_preview_layout_test.dart @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_me_user_wallet_continue_and_language_rows', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-1', + position: Duration(minutes: 2), + duration: Duration(minutes: 5), + ), + ], + ), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mine-user-row')), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + for (final key in const [Key('mine-search-button'), Key('mine-avatar')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ) + .size, + 18, + ); + } + expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('mine-language')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget); + final tab0 = find.byKey(const Key('mine-library-tab-0')); + final tab1 = find.byKey(const Key('mine-library-tab-1')); + final tab2 = find.byKey(const Key('mine-library-tab-2')); + expect(tester.getTopLeft(tab0).dx, lessThan(tester.getTopLeft(tab1).dx)); + expect(tester.getTopLeft(tab1).dx, lessThan(tester.getTopLeft(tab2).dx)); + expect( + tester.getTopLeft(tab1).dx - tester.getTopRight(tab0).dx, + closeTo(20, 1), + ); + expect( + (tester + .widget( + find.descendant( + of: tab0, + matching: find.byType(AnimatedContainer), + ), + ) + .decoration + as BoxDecoration) + .color, + AppColors.blue, + ); + expect( + (tester + .widget( + find.descendant( + of: tab1, + matching: find.byType(AnimatedContainer), + ), + ) + .decoration + as BoxDecoration) + .color, + Colors.transparent, + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/mine/server_endpoint_setting_test.dart b/test/mine/server_endpoint_setting_test.dart new file mode 100644 index 0000000..7eb9c49 --- /dev/null +++ b/test/mine/server_endpoint_setting_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/config/locale_controller.dart'; +import 'package:fruitshorts/core/config/server_endpoint.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/mine/mine_logic.dart'; + +const _drama = Drama( + id: 'drama-1', + title: 'The Signal', + synopsis: 'synopsis', + posterUrl: '/files/1.jpg', +); + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_show_server_address_when_debug_entry_is_enabled', ( + tester, + ) async { + await _pumpSettings(tester, showServerSetting: true); + expect(find.byKey(const Key('server-endpoint-setting')), findsOneWidget); + expect(find.text('Server address'), findsOneWidget); + }); + + testWidgets('should_hide_server_address_when_debug_entry_is_disabled', ( + tester, + ) async { + await _pumpSettings(tester, showServerSetting: false); + expect(find.byKey(const Key('server-endpoint-setting')), findsNothing); + expect(find.text('Server address'), findsNothing); + }); + + testWidgets('should_reject_invalid_server_address_without_saving', ( + tester, + ) async { + await _pumpSettings(tester, showServerSetting: true); + await tester.tap(find.byKey(const Key('server-endpoint-setting'))); + await tester.pumpAndSettle(); + + expect(find.text('Restart the app to apply.'), findsOneWidget); + await tester.enterText( + find.byKey(const Key('server-endpoint-input')), + 'ftp://invalid', + ); + await tester.tap(find.byKey(const Key('server-endpoint-save'))); + await tester.pumpAndSettle(); + + expect(find.text('Enter a valid http(s) server address.'), findsOneWidget); + expect( + await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), + isNull, + ); + }); +} + +Future _pumpSettings( + WidgetTester tester, { + required bool showServerSetting, +}) async { + final logic = MineLogic( + history: InMemoryWatchHistoryRepository(), + drama: _drama, + initialSection: 0, + ); + addTearDown(logic.dispose); + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => LocaleController(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + key: const Key('open-settings'), + onPressed: () => logic.showLanguage( + context, + showServerSetting: showServerSetting, + ), + child: const Text('Open settings'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open-settings'))); + await tester.pumpAndSettle(); +} diff --git a/test/notice/notice_api_test.dart b/test/notice/notice_api_test.dart new file mode 100644 index 0000000..81f336d --- /dev/null +++ b/test/notice/notice_api_test.dart @@ -0,0 +1,52 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/notice_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_parse_notice_page_with_string_ids_when_requested', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': '3', + 'list': [ + { + 'id': '2111012753262120962', + 'title': 'Maintenance', + 'content': 'Line one\nLine two', + 'type': 'SYSTEM', + 'publishAt': '2026-10-17T12:30:00', + 'expireAt': null, + }, + ], + }, + }), + ), + ); + final api = NoticeApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.page(pageIndex: 2, pageSize: 20); + + expect(adapter.requests.single.uri.path, '/app/notices'); + expect(adapter.requests.single.queryParameters, { + 'pageIndex': '2', + 'pageSize': '20', + }); + expect(page.total, 3); + expect(page.notices.single.id, '2111012753262120962'); + expect(page.notices.single.content, 'Line one\nLine two'); + expect(page.notices.single.type, 'SYSTEM'); + expect(page.notices.single.publishAt, DateTime(2026, 10, 17, 12, 30)); + expect(page.notices.single.expireAt, isNull); + }); +} diff --git a/test/notice/notices_controller_test.dart b/test/notice/notices_controller_test.dart new file mode 100644 index 0000000..bd28557 --- /dev/null +++ b/test/notice/notices_controller_test.dart @@ -0,0 +1,95 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/notice.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/repositories/notice_repository.dart'; +import 'package:fruitshorts/core/data/services/notices_controller.dart'; + +void main() { + test('should_append_next_page_until_loaded_count_reaches_total', () async { + final repository = ScriptedNoticeRepository([ + NoticePage(total: 3, notices: [_notice('1'), _notice('2')]), + NoticePage(total: 3, notices: [_notice('3')]), + ]); + final controller = NoticesController(repository); + + await controller.load(); + expect(controller.notices.map((item) => item.id), ['1', '2']); + expect(controller.hasMore, isTrue); + + await controller.loadMore(); + expect(controller.notices.map((item) => item.id), ['1', '2', '3']); + expect(controller.hasMore, isFalse); + expect(repository.calls.map((call) => call.pageIndex), [1, 2]); + }); + + test('should_show_empty_state_when_total_is_zero', () async { + final controller = NoticesController( + ScriptedNoticeRepository([const NoticePage(total: 0)]), + ); + + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.notices, isEmpty); + expect(controller.hasMore, isFalse); + expect(controller.error, isNull); + }); + + test('should_preserve_backend_message_when_page_load_fails', () async { + final repository = ScriptedNoticeRepository([]) + ..error = const ApiException(500, '公告服务暂不可用'); + final controller = NoticesController(repository); + + await controller.load(); + + expect(controller.error, '公告服务暂不可用'); + expect(controller.notices, isEmpty); + }); + + test('should_replace_list_when_refreshed', () async { + final repository = ScriptedNoticeRepository([ + NoticePage(total: 2, notices: [_notice('1'), _notice('2')]), + NoticePage(total: 1, notices: [_notice('9')]), + ]); + final controller = NoticesController(repository); + await controller.load(); + + await controller.refresh(); + + expect(controller.notices.map((item) => item.id), ['9']); + expect(controller.hasMore, isFalse); + }); +} + +AppNotice _notice(String id) => AppNotice( + id: id, + title: 'Notice $id', + content: 'Content $id', + type: 'SYSTEM', + publishAt: DateTime(2026, 10, 17, 12), +); + +class ScriptedNoticeRepository implements NoticeRepository { + ScriptedNoticeRepository(this.pages); + + final List pages; + final List calls = []; + Object? error; + + @override + Future page({required int pageIndex, int pageSize = 20}) async { + calls.add(NoticePageCall(pageIndex: pageIndex, pageSize: pageSize)); + if (error != null) throw error!; + if (pages.isEmpty) return const NoticePage(total: 0); + return pages.removeAt(0); + } +} + +class NoticePageCall { + const NoticePageCall({required this.pageIndex, required this.pageSize}); + + final int pageIndex; + final int pageSize; +} diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart new file mode 100644 index 0000000..1767c7f --- /dev/null +++ b/test/notice/notices_entry_test.dart @@ -0,0 +1,59 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/notice.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_open_notices_from_mine_menu_without_login_gate', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository( + pages: [const NoticePage(total: 0)], + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mine-notices')), findsOneWidget); + await tester.tap(find.byKey(const Key('mine-notices'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notices-page')), findsOneWidget); + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + }); +} diff --git a/test/notice/notices_page_test.dart b/test/notice/notices_page_test.dart new file mode 100644 index 0000000..8ec1a6b --- /dev/null +++ b/test/notice/notices_page_test.dart @@ -0,0 +1,169 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/notice.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/repositories/notice_repository.dart'; +import 'package:fruitshorts/core/formatting/localized_date_time.dart'; +import 'package:fruitshorts/core/widgets/empty_state.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/notices/notices_page.dart'; +import 'package:provider/provider.dart'; + +import '../support/fake_notice_repository.dart'; + +void main() { + testWidgets('should_render_first_page_and_append_next_page', (tester) async { + final repository = FakeNoticeRepository( + pages: [ + NoticePage(total: 3, notices: [_notice('1'), _notice('2')]), + NoticePage(total: 3, notices: [_notice('3')]), + ], + ); + await _pump(tester, repository); + + expect(find.text('Notice 1'), findsOneWidget); + expect(find.byKey(const Key('notices-load-more')), findsOneWidget); + + await tester.tap(find.byKey(const Key('notices-load-more'))); + await tester.pumpAndSettle(); + + expect(find.text('Notice 3'), findsOneWidget); + expect(repository.calls.last.pageIndex, 2); + }); + + testWidgets('should_show_empty_state_when_no_notices_exist', (tester) async { + await _pump( + tester, + FakeNoticeRepository(pages: [const NoticePage(total: 0)]), + ); + + expect(find.byKey(const Key('notices-empty')), findsOneWidget); + expect(find.byType(EmptyState), findsOneWidget); + }); + + testWidgets('should_show_backend_error_and_retry_when_page_fails', ( + tester, + ) async { + await _pump( + tester, + FakeNoticeRepository(error: const ApiException(500, '公告加载失败')), + ); + + expect(find.text('公告加载失败'), findsOneWidget); + expect(find.byKey(const Key('notices-retry')), findsOneWidget); + }); + + testWidgets('should_show_loading_state_before_first_page_arrives', ( + tester, + ) async { + final repository = FakeNoticeRepository( + pages: [const NoticePage(total: 0)], + delay: const Duration(milliseconds: 50), + ); + await tester.pumpWidget(_app(repository)); + await tester.pump(); + + expect(find.byKey(const Key('notices-loading')), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('should_open_detail_with_newlines_and_indonesian_time', ( + tester, + ) async { + final notice = _notice( + '2111012753262120962', + content: 'Line one\nLine two', + ); + await _pump( + tester, + FakeNoticeRepository( + pages: [ + NoticePage(total: 1, notices: [notice]), + ], + ), + ); + + await tester.tap( + find.byKey(const ValueKey('notice-item-2111012753262120962')), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notice-detail-close')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('notice-detail-content')), + ) + .data, + 'Line one\nLine two', + ); + final context = tester.element( + find.byKey(const Key('notice-detail-content')), + ); + final expected = formatLocalizedDateTime(context, notice.publishAt!); + expect( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text(expected), + ), + findsOneWidget, + ); + }); + + testWidgets('should_scroll_long_detail_without_overflow_on_small_screen', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final notice = _notice( + '1', + title: 'A very long notice title that wraps across several lines', + content: List.filled(40, 'Long notice content line.').join('\n'), + ); + await _pump( + tester, + FakeNoticeRepository( + pages: [ + NoticePage(total: 1, notices: [notice]), + ], + ), + ); + + await tester.tap(find.byKey(const ValueKey('notice-item-1'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notice-detail-content')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +AppNotice _notice( + String id, { + String? title, + String content = 'Notice content', +}) => AppNotice( + id: id, + title: title ?? 'Notice $id', + content: content, + type: 'SYSTEM', + publishAt: DateTime(2026, 10, 17, 12, 30), +); + +Future _pump(WidgetTester tester, NoticeRepository repository) async { + await tester.pumpWidget(_app(repository)); + await tester.pumpAndSettle(); +} + +Widget _app(NoticeRepository repository) => Provider.value( + value: repository, + child: MaterialApp( + locale: const Locale('id'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const NoticesPage(), + ), +); diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 416fec0..582494b 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/core/data/models/drama.dart'; -import 'package:nontonki/core/data/services/playback_session.dart'; -import 'package:nontonki/core/data/models/player_port.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/models/player_port.dart'; void main() { final episodes = List.generate( @@ -14,12 +14,12 @@ void main() { id: 'ep-$i', number: i + 1, title: 'Episode $i', - assetPath: 'test-$i.mp4', + videoUrl: 'https://example.test/$i.mp4', isFree: i < 4, ), ); - test('warm playback starts before old native disposal finishes', () async { + test('should_start_warm_playback_when_old_native_disposal_is_still_running', () async { final release = Completer(); final factory = _Factory(disposeGates: {'ep-0': release}); final session = PlaybackSession( @@ -43,7 +43,7 @@ void main() { await session.close(); }); - test('failed native release blocks another route from allocating', () async { + test('should_block_another_route_from_allocating_when_native_release_fails', () async { final factory = _Factory(disposeFailures: {'ep-0'}); final coordinator = PlaybackCoordinator(); PlaybackSession createSession() => PlaybackSession( @@ -70,7 +70,7 @@ void main() { }); test( - 'next is prepared silently and promoted without another initialize', + 'should_prepare_next_silently_and_promote_it_when_swiped', () async { final factory = _Factory(); final prepared = []; @@ -105,7 +105,7 @@ void main() { }, ); - test('in-flight preload is reused when swiped before ready', () async { + test('should_reuse_in_flight_preload_when_swiped_before_ready', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); final session = PlaybackSession( @@ -129,7 +129,7 @@ void main() { await session.close(); }); - test('rapid reverse and skipped selection discard stale warm work', () async { + test('should_discard_stale_warm_work_when_reversing_rapidly_or_skipping_selection', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); final session = PlaybackSession( @@ -155,7 +155,7 @@ void main() { }); test( - 'category adjacency and locked next never preload the wrong episode', + 'should_never_preload_wrong_episode_when_next_is_locked_or_category_based', () async { final factory = _Factory(); final session = PlaybackSession( @@ -174,7 +174,7 @@ void main() { }, ); - test('preload failure is isolated and selecting it retries once', () async { + test('should_isolate_preload_failure_and_retry_once_when_selected', () async { final factory = _Factory(failOnce: {'ep-1'}); final session = PlaybackSession( episodes: episodes, @@ -192,7 +192,7 @@ void main() { await session.close(); }); - test('completion in background never starts either player', () async { + test('should_start_no_player_when_completion_happens_in_background', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); final session = PlaybackSession( @@ -217,7 +217,7 @@ void main() { await session.close(); }); - test('closing during preloading disposes pending resources', () async { + test('should_dispose_pending_resources_when_closing_during_preload', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); final session = PlaybackSession( @@ -235,7 +235,7 @@ void main() { }); test( - 'home and series share a two-player budget across route handoff', + 'should_share_two_player_budget_when_handing_off_between_home_and_series', () async { final factory = _Factory(); final coordinator = PlaybackCoordinator(); diff --git a/test/playback/playback_queue_test.dart b/test/playback/playback_queue_test.dart index 7c259f4..d1dce8d 100644 --- a/test/playback/playback_queue_test.dart +++ b/test/playback/playback_queue_test.dart @@ -1,28 +1,78 @@ // Author: fruit.com.cn import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/core/data/repositories/catalog_repository.dart'; -import 'package:nontonki/core/data/services/playback_queue.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/services/playback_queue.dart'; void main() { - test( - 'home feed advances between dramas, series player between episodes', - () async { - final dramas = await DemoCatalogRepository().fetchDramas(); - final home = PlaybackQueue.home(dramas); - expect(home.length, 3); - expect(home.entryAt(0).drama.id, dramas[0].id); - expect(home.entryAt(1).drama.id, dramas[1].id); - expect(home.entryAt(1).episode.number, 1); - expect(home.nextIndex(0), 1); - expect(home.nextIndex(2), isNull); + test('should_advance_between_dramas_on_home_and_between_episodes_in_series_when_queue_is_built', () { + final dramas = [_drama('a', 3), _drama('b', 2), _drama('c', 4)]; - final series = PlaybackQueue.series(dramas[0]); - expect(series.length, 3); - expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id); - expect(series.entryAt(1).episode.number, 2); - expect(series.nextIndex(0), 1); - expect(series.nextIndex(2), isNull); - }, - ); + final home = PlaybackQueue.home(dramas); + expect(home.length, 3); + expect(home.entryAt(0).drama.id, 'a'); + expect(home.entryAt(1).drama.id, 'b'); + expect(home.entryAt(1).episode.number, 1); + expect(home.nextIndex(0), 1); + expect(home.nextIndex(2), isNull); + + final series = PlaybackQueue.series(dramas[0]); + expect(series.length, 3); + expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id); + expect(series.entryAt(1).episode.number, 2); + expect(series.nextIndex(0), 1); + expect(series.nextIndex(2), isNull); + }); + + test('should_leave_dramas_out_when_they_have_no_episodes', () { + final home = PlaybackQueue.home([ + _drama('a', 2), + Drama( + id: 'empty', + title: '没有剧集', + synopsis: '', + posterUrl: '/files/empty.jpg', + ), + ]); + expect(home.length, 1); + expect(home.entryAt(0).drama.id, 'a'); + }); + + test('should_play_preview_episode_when_list_card_has_one', () { + final home = PlaybackQueue.home([ + Drama.fromCardJson(const { + 'id': 5, + 'title': '闪婚后', + 'cover': '/files/cover.jpg', + 'totalEpisodes': 77, + 'preview': { + 'id': 9, + 'number': 1, + 'title': '第 1 集', + 'isFree': true, + 'playUrl': '/files/episode/9.mp4', + }, + }), + ]); + + expect(home.length, 1); + expect(home.entryAt(0).episode.videoUrl, '/files/episode/9.mp4'); + }); } + +Drama _drama(String id, int episodeCount) => Drama( + id: id, + title: 'Drama $id', + synopsis: '简介 $id', + posterUrl: '/files/$id.jpg', + episodes: [ + for (var index = 1; index <= episodeCount; index++) + Episode( + id: '$id-$index', + number: index, + title: '第 $index 集', + isFree: true, + videoUrl: 'https://example.test/$id-$index.mp4', + ), + ], +); diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index 93c84b2..336d3b6 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/core/data/models/drama.dart'; -import 'package:nontonki/core/data/services/playback_session.dart'; -import 'package:nontonki/core/data/models/player_port.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/models/player_port.dart'; void main() { final episodes = List.generate( @@ -14,12 +14,12 @@ void main() { id: 'ep-${i + 1}', number: i + 1, title: 'Episode ${i + 1}', - assetPath: 'assets/media/episode_0${i + 1}.mp4', + videoUrl: 'https://example.test/episode_0${i + 1}.mp4', isFree: i < 2, ), ); - test('locked episode never creates or initializes a player', () async { + test('should_create_no_player_when_episode_is_locked', () async { final factory = FakeFactory(); final session = PlaybackSession( episodes: episodes, @@ -32,7 +32,7 @@ void main() { await session.close(); }); - test('rapid selection drops stale initialize and keeps one player', () async { + test('should_drop_stale_initialize_and_keep_one_player_when_selection_is_rapid', () async { final factory = FakeFactory(holdFirstInitialize: true); final session = PlaybackSession( episodes: episodes, @@ -54,7 +54,7 @@ void main() { }); test( - 'background and overlay pause without forgetting playback intent', + 'should_pause_without_forgetting_intent_when_backgrounding_or_overlay_shows', () async { final factory = FakeFactory(); final session = PlaybackSession( @@ -82,7 +82,7 @@ void main() { }, ); - test('resume preparation finishes before video starts', () async { + test('should_finish_resume_preparation_before_video_starts_when_resuming', () async { final factory = FakeFactory(); final prepared = Completer(); final session = PlaybackSession( @@ -101,7 +101,48 @@ void main() { await session.close(); }); - test('automatic advance respects visibility and playback intent', () async { + test('should_resume_playback_when_swiping_after_pause', () async { + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + ); + await session.select(0); + session.setUserWantsPlay(false); + await Future.delayed(Duration.zero); + expect(session.userWantsPlay, isFalse); + + await session.select(1); + + expect(session.userWantsPlay, isTrue); + await Future.delayed(Duration.zero); + final playing = session.active; + expect(playing, isA()); + expect((playing! as FakePlayer).playCalls, greaterThan(0)); + await session.close(); + }); + + test('should_keep_user_pause_when_reselecting_the_same_episode', () async { + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + ); + await session.select(0); + session.setUserWantsPlay(false); + await Future.delayed(Duration.zero); + + // 页面重新可见等场景会重复选中同一集,这时不能把暂停改成播放。 + await session.select(0); + + expect(session.userWantsPlay, isFalse); + expect(session.canAutoAdvance, isFalse); + await session.close(); + }); + + test('should_respect_visibility_and_intent_when_advancing_automatically', () async { final factory = FakeFactory(); final session = PlaybackSession( episodes: episodes, diff --git a/test/playback/playback_ticket_expiry_test.dart b/test/playback/playback_ticket_expiry_test.dart new file mode 100644 index 0000000..f301312 --- /dev/null +++ b/test/playback/playback_ticket_expiry_test.dart @@ -0,0 +1,120 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/playback_api.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/video_player_port.dart'; + +import '../support/fake_http_adapter.dart'; + +/// 播放票据有有效期(契约 §1.4:签名默认 15 分钟): +/// 缓存期内不重复取,过期或显式强刷必须重新取,且**签名地址原样使用**。 +void main() { + String envelope(String playUrl) => jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'episodeId': '11', + 'dramaId': '5', + 'number': 1, + 'title': '第 1 集', + 'durationSec': 180, + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': playUrl, + 'qualities': ['AUTO'], + 'unlockCoin': 0, + 'previewSeconds': 0, + }, + }); + + _TicketEnv build() { + var now = DateTime(2026, 10, 16, 12); + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope('/files/episode/11.mp4?e=1760000000&s=9f2c'), + ), + ); + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter); + final repository = HttpPlaybackRepository( + PlaybackApi(client), + ttl: const Duration(minutes: 10), + clock: () => now, + ); + return _TicketEnv( + repository: repository, + adapter: adapter, + advance: (Duration d) => now = now.add(d), + ); + } + + test('should_reuse_ticket_when_cache_is_still_fresh', () async { + final env = build(); + await env.repository.ticketFor('11'); + env.advance(const Duration(minutes: 9)); + await env.repository.ticketFor('11'); + + expect(env.adapter.requests, hasLength(1)); + }); + + test('should_refetch_ticket_when_cache_expired', () async { + final env = build(); + await env.repository.ticketFor('11'); + env.advance(const Duration(minutes: 11)); + final ticket = await env.repository.ticketFor('11'); + + expect(env.adapter.requests, hasLength(2)); + expect(ticket.playUrl, contains('?e=1760000000&s=9f2c')); + }); + + test('should_refetch_ticket_when_explicitly_forced', () async { + final env = build(); + await env.repository.ticketFor('11'); + await env.repository.ticketFor('11', forceRefresh: true); + + expect(env.adapter.requests, hasLength(2)); + }); + + test('should_report_freshness_when_asking_before_expiry', () async { + final env = build(); + expect(env.repository.isFresh('11'), isFalse); + await env.repository.ticketFor('11'); + expect(env.repository.isFresh('11'), isTrue); + env.advance(const Duration(minutes: 11)); + expect(env.repository.isFresh('11'), isFalse); + }); + + test('should_keep_signed_query_verbatim_when_resolving_media_uri', () { + const episode = Episode( + id: '11', + number: 1, + title: '第 1 集', + isFree: true, + videoUrl: '/files/episode/11.mp4?e=1760000000&s=9f2c', + ); + + final uri = VideoPlayerPort.resolveUri(episode); + + // 只补 host,路径与查询串原样保留(签名丢了就等于 403) + expect(uri.path, '/files/episode/11.mp4'); + expect(uri.query, 'e=1760000000&s=9f2c'); + expect(uri.toString(), endsWith('/files/episode/11.mp4?e=1760000000&s=9f2c')); + }); +} + +/// 测试用的小环境:仓库 + 假适配器 + 可推进的时钟。 +class _TicketEnv { + _TicketEnv({ + required this.repository, + required this.adapter, + required this.advance, + }); + + final HttpPlaybackRepository repository; + final FakeHttpAdapter adapter; + final void Function(Duration) advance; +} diff --git a/test/playback/progress_throttle_test.dart b/test/playback/progress_throttle_test.dart new file mode 100644 index 0000000..90b3697 --- /dev/null +++ b/test/playback/progress_throttle_test.dart @@ -0,0 +1,178 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; + +import '../support/fake_watch_history.dart'; +import '../support/widget_player.dart'; + +Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/poster.png', + episodes: [ + for (final number in numbers) + Episode( + id: '$id-$number', + number: number, + title: 'Episode $number', + videoUrl: 'https://example.test/clip.mp4', + isFree: true, + ), + ], +); + +PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) { + final logic = PlayerLogic( + history: history, + playback: FakePlaybackRepository(), + pictureInPicture: AndroidPictureInPicture( + channel: const MethodChannel('test/progress/pip'), + ), + ); + if (drama != null) { + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: (episode) => WidgetPlayer(), + coordinator: PlaybackCoordinator(), + ); + } + return logic; +} + +/// 排空 session 串行队列,避免测试结束在 dispose 中途。 +Future _drain(PlayerLogic logic) async { + for (var attempt = 0; attempt < 4; attempt++) { + await pumpEventQueue(); + await logic.session.playbackSettled; + await logic.session.preloadSettled; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('progress throttle', () { + test('should_report_once_per_interval_when_progress_ticks_keep_coming', () { + final logic = _logic(FakeWatchHistory()); + final start = DateTime(2026, 10, 16, 12); + logic.lastProgressWrite = start; + + // 契约建议每 10 秒一次:3 秒、9 秒都不报,第 10 秒报一次 + expect(logic.progressInterval, const Duration(seconds: 10)); + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 3))), isFalse); + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 9))), isFalse); + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 10))), isTrue); + // 报过之后节流窗口重新开始 + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 15))), isFalse); + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 20))), isTrue); + logic.dispose(); + }); + + test('should_write_immediately_when_saving_on_episode_switch_or_exit', () async { + final history = FakeWatchHistory(); + final drama = _drama('signal'); + final logic = _logic(history, drama: drama); + + // 切集/退出/切后台走 saveCurrentProgress:不受 10 秒节流限制 + await logic.queueProgress( + drama, + drama.episodes[0], + const Duration(seconds: 5), + duration: const Duration(minutes: 3), + ); + await logic.queueProgress( + drama, + drama.episodes[1], + const Duration(seconds: 6), + duration: const Duration(minutes: 3), + ); + + expect(history.writes, [ + 'signal-1|5000|false', + 'signal-2|6000|false', + ]); + logic.dispose(); + }); + + test('should_report_finished_when_episode_plays_to_the_end', () async { + final history = FakeWatchHistory(); + final drama = _drama('signal'); + final logic = _logic(history, drama: drama); + + await logic.queueProgress( + drama, + drama.episodes[0], + const Duration(minutes: 3), + duration: const Duration(minutes: 3), + finished: true, + ); + + expect(history.writes, ['signal-1|180000|true']); + logic.dispose(); + }); + }); + + group('resume positioning', () { + test('should_take_position_from_server_when_record_matches_the_episode', () async { + final history = FakeWatchHistory( + )..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-2', + position: Duration(seconds: 42), + duration: Duration(minutes: 3), + ); + final logic = _logic(history, drama: _drama('signal')); + await _drain(logic); + + await logic.loadResumePoint(); + + expect(logic.resumePositionFor('signal-2'), const Duration(seconds: 42)); + // 记录里不是这一集:不给定位(从头播,避免"看第 1 集却跳到第 2 集的位置") + expect(logic.resumePositionFor('signal-1'), isNull); + expect(logic.resumePositionFor('signal-3'), isNull); + logic.dispose(); + }); + + test('should_have_no_resume_position_when_server_read_fails', () async { + final history = FakeWatchHistory()..failReads = true; + final logic = _logic(history, drama: _drama('signal')); + await _drain(logic); + + await logic.loadResumePoint(); + + expect(logic.resumePoint, isNull); + expect(logic.resumeLoaded, isTrue); + logic.dispose(); + }); + + test('should_not_seek_when_position_is_zero', () async { + final history = FakeWatchHistory() + ..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-1', + position: Duration.zero, + duration: Duration(minutes: 3), + ); + final logic = _logic(history, drama: _drama('signal')); + await _drain(logic); + + await logic.loadResumePoint(); + + expect(logic.resumePositionFor('signal-1'), isNull); + logic.dispose(); + await _drain(logic); + }); + }); +} diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 84b3eb4..53ad74b 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/pages/player/widgets/seek_progress_bar.dart'; +import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; void main() { - test('seek target stays inside playable duration', () { + test('should_keep_seek_target_inside_playable_duration_when_dragging', () { const duration = Duration(seconds: 12); expect(seekPositionForFraction(0, duration), Duration.zero); expect( @@ -16,7 +16,7 @@ void main() { expect(seekPositionForFraction(1, duration), lessThan(duration)); }); - testWidgets('horizontal slider drag previews and seeks on release', ( + testWidgets('should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', ( tester, ) async { final video = ValueNotifier( @@ -56,14 +56,16 @@ void main() { await tester.pump(); expect(started, 1); expect(target, isNull); + expect(find.textContaining('/ 00:12'), findsOneWidget); await gesture.up(); await tester.pumpAndSettle(); expect(target, isNotNull); expect(target!, greaterThan(const Duration(seconds: 3))); expect(target!, lessThan(const Duration(seconds: 12))); + expect(find.textContaining('/ 00:12'), findsNothing); }); - testWidgets('compact seek bar shows time only while dragging', ( + testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', ( tester, ) async { final video = ValueNotifier( @@ -80,21 +82,20 @@ void main() { body: SeekProgressBar( valueListenable: video, semanticsLabel: 'Seek video', - compactIdle: true, onSeek: (_) async {}, ), ), ), ); - expect(find.text('00:03'), findsNothing); + expect(find.textContaining('00:12'), findsNothing); final gesture = await tester.startGesture( tester.getCenter(find.byType(Slider)), ); await gesture.moveBy(const Offset(40, 0)); await tester.pump(); - expect(find.text('00:12'), findsOneWidget); + expect(find.textContaining('/ 00:12'), findsOneWidget); await gesture.up(); await tester.pumpAndSettle(); - expect(find.text('00:12'), findsNothing); + expect(find.textContaining('00:12'), findsNothing); }); } diff --git a/test/playback/video_player_port_test.dart b/test/playback/video_player_port_test.dart new file mode 100644 index 0000000..d40a7f3 --- /dev/null +++ b/test/playback/video_player_port_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/config/app_config.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/services/video_player_port.dart'; + +Episode _episode(String videoUrl) => Episode( + id: '1', + number: 1, + title: '第 1 集', + isFree: true, + videoUrl: videoUrl, +); + +void main() { + test('should_prefix_service_base_when_play_url_is_relative', () { + final uri = VideoPlayerPort.resolveUri( + _episode('/files/episode/202610/clip.mp4'), + ); + + expect(uri.isAbsolute, isTrue); + // Uri.parse().toString() 会把 host 规范成小写,而 devHostBaseUrl 里的 + // Bonjour 名字带大写(jiangdeMacBook-Air.local)—— 比较时统一小写, + // 否则会依赖运行平台(flutter test 里 defaultTargetPlatform 默认是 android) + expect( + uri.toString().toLowerCase(), + '${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(), + ); + }); + + test('should_keep_url_untouched_when_play_url_is_absolute', () { + final uri = VideoPlayerPort.resolveUri( + _episode('https://cdn.example.test/clip.mp4'), + ); + + expect(uri.toString(), 'https://cdn.example.test/clip.mp4'); + }); +} diff --git a/test/playback/watch_history_test.dart b/test/playback/watch_history_test.dart index 58d50d1..fb7a9c6 100644 --- a/test/playback/watch_history_test.dart +++ b/test/playback/watch_history_test.dart @@ -1,12 +1,13 @@ // Author: fruit.com.cn import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/core/data/repositories/watch_history_repository.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +/// 进度仓库从"本地按 profile+剧+集"换成"服务端每剧一条": +/// 这里钉住续看点换算、空记录、字符串数值解析、失败透传与清理。 void main() { - test('resume position clamps invalid and completed values', () { + test('should_clamp_invalid_and_completed_values_when_resolving_resume_position', () { const duration = Duration(seconds: 8); expect( clampResumePosition(const Duration(seconds: -1), duration), @@ -26,31 +27,101 @@ void main() { ); }); - test('watch history is scoped by profile, drama and episode', () async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final history = SharedPreferencesWatchHistoryRepository(); - await history.save( - 'profile-a', - 'drama-a', - 'episode-1', - const Duration(seconds: 3), + test('should_return_resume_point_when_server_has_progress', () async { + final history = InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'drama-a', + episodeId: 'episode-3', + position: Duration(seconds: 42), + duration: Duration(minutes: 3), + ), + ], ); - await history.save( - 'profile-a', - 'drama-a', - 'episode-2', - const Duration(seconds: 5), + + final point = await history.resumePointFor('drama-a'); + + expect(point?.episodeId, 'episode-3'); + expect(point?.position, const Duration(seconds: 42)); + }); + + test('should_return_no_resume_point_when_server_has_no_record', () async { + final history = InMemoryWatchHistoryRepository(); + + expect(await history.resumePointFor('drama-a'), isNull); + }); + + test('should_return_no_resume_point_when_record_has_no_episode', () async { + final history = InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'drama-a', + episodeId: '', + position: Duration(seconds: 5), + duration: Duration(minutes: 3), + ), + ], ); - expect( - await history.positionFor('profile-a', 'drama-a', 'episode-1'), - const Duration(seconds: 3), + + expect(await history.resumePointFor('drama-a'), isNull); + }); + + test('should_expose_fraction_when_duration_is_known', () { + const record = WatchProgress( + dramaId: 'd', + episodeId: 'e', + position: Duration(seconds: 30), + duration: Duration(seconds: 120), ); - expect( - await history.positionFor('profile-b', 'drama-a', 'episode-1'), - isNull, + expect(record.fraction, closeTo(0.25, 0.0001)); + expect(record.hasPosition, isTrue); + + const unknown = WatchProgress( + dramaId: 'd', + episodeId: 'e', + position: Duration(seconds: 30), + duration: Duration.zero, ); - expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2'); - expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull); + expect(unknown.fraction, 0); + }); + + test('should_parse_string_numbers_when_server_sends_ids_and_ms_as_strings', () { + final record = WatchProgress.fromJson({ + 'dramaId': '2111012753262120962', + 'episodeId': '2111012753262120963', + 'positionMs': '42000', + 'durationMs': '180000', + 'finished': false, + 'playCount': '2', + }); + + expect(record.dramaId, '2111012753262120962'); + expect(record.episodeId, '2111012753262120963'); + expect(record.position, const Duration(seconds: 42)); + expect(record.duration, const Duration(seconds: 180)); + expect(record.playCount, 2); + }); + + test('should_throw_when_reading_progress_fails', () async { + final history = InMemoryWatchHistoryRepository()..failReads = 1; + + expect(() => history.progressFor('drama-a'), throwsStateError); + }); + + test('should_drop_record_when_removing_progress', () async { + final history = InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'drama-a', + episodeId: 'e1', + position: Duration(seconds: 5), + duration: Duration(minutes: 1), + ), + ], + ); + + await history.remove('drama-a'); + + expect(await history.progressFor('drama-a'), isNull); }); } diff --git a/test/player/episode_picker_cell_test.dart b/test/player/episode_picker_cell_test.dart new file mode 100644 index 0000000..196feec --- /dev/null +++ b/test/player/episode_picker_cell_test.dart @@ -0,0 +1,73 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/pages/player/widgets/episode_picker_cell.dart'; + +void main() { + testWidgets('should_show_lock_when_episode_needs_unlock', (tester) async { + await tester.pumpWidget( + _host( + const Episode( + id: '12', + number: 12, + title: 'Paid', + isFree: false, + locked: true, + ), + ), + ); + + expect(find.byIcon(Icons.lock_outline), findsOneWidget); + expect(find.text('12'), findsOneWidget); + final cell = tester.getRect(find.byType(EpisodePickerCell)); + final cover = tester.getRect(find.byKey(const ValueKey('episode-lock-cover-12'))); + final number = tester.getRect(find.text('12')); + final lock = tester.getRect(find.byIcon(Icons.lock_outline)); + expect(number.overlaps(lock), isFalse); + expect(number.top, greaterThanOrEqualTo(lock.bottom)); + final wash = tester.widget( + find.byKey(const ValueKey('episode-lock-cover-12')), + ); + expect(cover.width, greaterThanOrEqualTo(cell.width - 1)); + expect(cover.height, greaterThanOrEqualTo(cell.height - 1)); + expect(cover.contains(lock.center), isTrue); + expect(wash.color.a, greaterThan(0)); + expect(wash.color.a, lessThan(1)); + }); + + testWidgets('should_hide_lock_when_episode_is_free', (tester) async { + await tester.pumpWidget( + _host( + const Episode( + id: '1', + number: 1, + title: 'Free', + isFree: true, + ), + ), + ); + + expect(find.byIcon(Icons.lock_outline), findsNothing); + expect(find.byKey(const ValueKey('episode-lock-cover-1')), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} + +Widget _host(Episode episode) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 52, + height: 50, + child: EpisodePickerCell( + episode: episode, + selected: false, + lockLabel: 'Locked', + onTap: () {}, + ), + ), + ), + ); +} diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart new file mode 100644 index 0000000..e6912cd --- /dev/null +++ b/test/player/player_center_toggle_test.dart @@ -0,0 +1,47 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; + +void main() { + testWidgets('should_use_one_shared_52px_center_toggle_style', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + PlayerCenterToggle( + key: const Key('test-center-toggle-a'), + playing: false, + onTap: () {}, + ), + PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: true, + onTap: () {}, + ), + ], + ), + ), + ), + ); + + for (final key in const [ + Key('test-center-toggle-a'), + Key('player-play-toggle'), + ]) { + expect(tester.getSize(find.byKey(key)), const Size(52, 52)); + final container = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(Container)), + ); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, const Color(0x80000000)); + expect(decoration.shape, BoxShape.circle); + final icon = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ); + expect(icon.size, 26); + } + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart new file mode 100644 index 0000000..45fc167 --- /dev/null +++ b/test/player/player_fullscreen_test.dart @@ -0,0 +1,525 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; +import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_open_fullscreen_overlay_from_discover_card', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + await tester.pumpAndSettle(); + + _expectFullscreenOverlay(tester); + expect(find.byType(PageView), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-download')), findsOneWidget); + expect(find.byKey(const Key('player-more-pip')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_open_fullscreen_overlay_from_detail_cta_and_chip', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + _expectFullscreenOverlay(tester); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); + await tester.pumpAndSettle(); + _expectFullscreenOverlay(tester); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_show_center_toggle_only_when_paused', (tester) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + expect( + find.descendant( + of: find.byKey(const Key('player-play-toggle')), + matching: find.byIcon(Icons.play_arrow_rounded), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('player-play-toggle'))); + await tester.pump(); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + }); + + testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsNothing); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + + await tester.pump(const Duration(seconds: 5)); + await tester.pump(); + + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + expect(find.byKey(const Key('player-video-tap')), findsOneWidget); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('player-exit-clear-screen'))), + const Size(36, 36), + ); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsNothing); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); + }); + + testWidgets('should_fit_player_header_on_320_width', (tester) async { + await _pumpApp(tester, size: const Size(320, 568)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byKey(const Key('player-back'))), + const Size(36, 36), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_keep_series_clear_state_when_swiping_next_episode', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); + }); + + testWidgets('should_open_speed_sheet_and_apply_selected_speed', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-speed')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-speed'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); + for (final value in ['0.5', '0.75', '1.0', '1.25', '1.5', '2.0']) { + expect(find.byKey(Key('player-speed-$value')), findsOneWidget); + } + + await tester.tap(find.byKey(const Key('player-speed-1.5'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-speed-sheet')), findsNothing); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); + }); + + testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-speed'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-speed-1.5'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-more')), findsNothing); + + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); + }); + + testWidgets('should_enable_drag_time_preview_on_player_progress', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + final bar = tester.widget( + find.byKey(const Key('player-progress')), + ); + expect(bar.showTimes, isTrue); + }); + + testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + + await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect(find.byKey(const Key('home-feed-scrim')), findsNothing); + expect(find.byKey(const Key('home-feed-video-tap')), findsOneWidget); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))), + const Size(36, 36), + ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + expect( + find.ancestor( + of: find.byKey(const Key('home-feed-exit-clear-screen')), + matching: find.byType(SafeArea), + ), + findsWidgets, + ); + expect( + tester + .getTopLeft(find.byKey(const Key('home-feed-exit-clear-screen'))) + .dy, + greaterThanOrEqualTo(8), + ); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isFalse, + ); + + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + + await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); + expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); + + testWidgets('should_show_home_center_toggle_only_when_paused', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isFalse, + ); + + await tester.tap(find.byKey(const Key('home-feed-center-play'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); + + testWidgets('should_keep_home_clear_state_when_swiping_to_next_page', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); +} + +void _expectFullscreenOverlay(WidgetTester tester) { + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-more')), findsOneWidget); + for (final key in const [Key('player-back'), Key('player-more')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + } + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect(find.byKey(const Key('player-episodes')), findsOneWidget); + expect(find.byKey(const Key('player-comments')), findsOneWidget); + expect(find.byKey(const Key('player-next')), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + + expect(find.byKey(const Key('rail-like')), findsNothing); + expect(find.byKey(const Key('rail-save')), findsNothing); +} + +Future _pumpApp( + WidgetTester tester, { + Size size = const Size(390, 844), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart new file mode 100644 index 0000000..ff0b5c9 --- /dev/null +++ b/test/player/player_logic_test.dart @@ -0,0 +1,469 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; +import 'package:fruitshorts/core/data/models/player_port.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; + +import '../support/fake_watch_history.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; + +Drama _drama( + String id, { + List numbers = const [1, 2, 3], + bool isFree = true, +}) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/poster.png', + episodes: [ + for (final number in numbers) + Episode( + id: '$id-$number', + number: number, + title: 'Episode $number', + // 与后端一致:只有可播放的剧集才带地址,锁定集为空 + videoUrl: isFree ? 'https://example.test/clip.mp4' : '', + isFree: isFree, + ), + ], +); + +AndroidPictureInPicture _pip() => + AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip')); + +PlayerLogic _logic( + FakeWatchHistory history, { + PlaybackRepository? playback, +}) => PlayerLogic( + history: history, + playback: playback ?? FakePlaybackRepository(), + pictureInPicture: _pip(), +); + +/// Drains the session's serialized work so a test never ends mid-disposal. +/// +/// `start` kicks off selection without awaiting it, so the first read of +/// `playbackSettled` can still be the untouched initial future. Pumping the +/// event queue between reads closes that race. +Future _drain(PlayerLogic logic) async { + for (var attempt = 0; attempt < 4; attempt++) { + await pumpEventQueue(); + await logic.session.playbackSettled; + await logic.session.preloadSettled; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('displayEpisode', () { + test('should_fall_back_until_episode_number_is_picked_for_that_drama_when_resolving', () { + final logic = _logic(FakeWatchHistory()); + final drama = _drama('signal'); + final fallback = drama.episodes.first; + + expect(logic.displayEpisode(drama, fallback).number, 1); + + logic.pickedEpisode['signal'] = 3; + expect(logic.displayEpisode(drama, fallback).number, 3); + + // Picks are scoped to their own drama. + final city = _drama('city'); + expect(logic.displayEpisode(city, city.episodes.first).number, 1); + }); + + test('should_stop_overriding_fallback_when_picked_number_is_unknown', () { + final logic = _logic(FakeWatchHistory()); + final drama = _drama('signal'); + final fallback = drama.episodes.first; + + logic.pickedEpisode['signal'] = 99; + + expect(logic.displayEpisode(drama, fallback).number, 1); + }); + }); + + group('nextVisibleIndex', () { + test('should_walk_visible_list_and_stop_at_the_end_when_advancing', () { + final logic = _logic(FakeWatchHistory()); + logic.visibleIndices = [0, 2, 4]; + + expect(logic.nextVisibleIndex(0), 2); + expect(logic.nextVisibleIndex(2), 4); + expect(logic.nextVisibleIndex(4), isNull); + }); + + test('should_have_no_successor_when_index_is_outside_visible_list', () { + final logic = _logic(FakeWatchHistory()); + logic.visibleIndices = [0, 2, 4]; + + expect(logic.nextVisibleIndex(1), isNull); + expect(logic.nextVisibleIndex(9), isNull); + expect(logic.nextVisibleIndex(-1), isNull); + }); + + test('should_have_no_successor_when_visible_list_is_empty', () { + final logic = _logic(FakeWatchHistory()); + logic.visibleIndices = []; + + expect(logic.nextVisibleIndex(0), isNull); + }); + }); + + group('surface flags', () { + test('should_set_immersive_when_picture_in_picture_callback_fires', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + final immersive = []; + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.onImmersiveChanged = immersive.add; + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + logic.onPipChanged(true); + + expect(logic.pip, isTrue); + expect(logic.pipPreparing, isFalse); + expect(logic.immersive, isTrue); + expect(logic.chromeHidden, isTrue); + expect(immersive.last, isTrue); + + logic.onPipChanged(false); + expect(logic.pip, isFalse); + expect(logic.immersive, isFalse); + + logic.dispose(); + await _drain(logic); + }); + + test('should_ignore_picture_in_picture_callback_when_unmounted', () { + final logic = _logic(FakeWatchHistory()); + logic.isMounted = () => false; + + logic.onPipChanged(true); + + expect(logic.pip, isFalse); + }); + }); + + group('history writes', () { + test('should_serialize_progress_writes_in_call_order_when_saving', () async { + final history = FakeWatchHistory(); + final logic = _logic(history); + final drama = _drama('signal'); + + await logic.queueProgress( + drama, + drama.episodes[0], + const Duration(seconds: 1), + ); + await logic.queueProgress( + drama, + drama.episodes[1], + const Duration(seconds: 2), + ); + + expect(history.writes, ['signal-1|1000|false', 'signal-2|2000|false']); + }); + + test('should_keep_later_writes_when_an_earlier_write_fails', () async { + final history = FakeWatchHistory(); + final logic = _logic(history); + final drama = _drama('signal'); + + history.failWrites = true; + await logic.queueProgress( + drama, + drama.episodes[0], + const Duration(seconds: 1), + ); + + history.failWrites = false; + await logic.queueProgress( + drama, + drama.episodes[1], + const Duration(seconds: 2), + ); + + expect(history.writes, ['signal-2|2000|false']); + }); + + test('should_write_nothing_when_no_player_is_tracked', () async { + final history = FakeWatchHistory(); + final logic = _logic(history); + + await logic.saveCurrentProgress(); + await logic.saveCurrentProgress(); + + expect(history.writes, isEmpty); + expect(history.reads, isEmpty); + }); + }); + + group('start', () { + test('should_play_single_series_when_route_is_detail', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + expect(logic.queue.length, 3); + expect(logic.visibleIndices, [0, 1, 2]); + expect(logic.session.currentIndex, 0); + expect(factory.created, hasLength(2)); + expect(factory.created.first.plays, 1); + + logic.dispose(); + await _drain(logic); + expect(factory.live, 0); + }); + + test('should_play_one_episode_per_feed_drama_when_in_home_mode', () async { + final factory = _RecordingFactory(); + final feed = [_drama('signal'), _drama('city'), _drama('vengeance')]; + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage( + drama: feed.first, + initialIndex: 0, + homeMode: true, + feedDramas: feed, + ), + isMounted: () => true, + ); + + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + expect(logic.queue.length, 3); + expect(logic.queue.entryAt(1).drama.id, 'city'); + expect(logic.queue.entryAt(1).episode.number, 1); + expect(logic.visibleIndices, [0, 1, 2]); + + logic.dispose(); + await _drain(logic); + }); + + test('should_never_reach_player_factory_when_episode_is_locked', () async { + final factory = _RecordingFactory(); + final drama = _drama('locked', isFree: false); + // 锁定的判定权在后端:票据返回不可播 + 无地址,客户端不得自行推断 + final logic = _logic( + FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), + ); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + expect(logic.session.isLocked, isTrue); + expect(factory.created, isEmpty); + + logic.dispose(); + await _drain(logic); + }); + + test('should_preload_only_what_follows_when_starting_from_a_later_index', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: drama, initialIndex: 2), + isMounted: () => true, + ); + + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + expect(logic.session.currentIndex, 2); + // The last episode has no successor to warm. + expect(factory.created, hasLength(1)); + + logic.dispose(); + await _drain(logic); + }); + + test('should_hold_no_players_when_route_is_inactive', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0, active: false), + isMounted: () => true, + ); + + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + expect(factory.created, isEmpty); + expect(logic.session.loading, isFalse); + + logic.dispose(); + await _drain(logic); + }); + }); + + group('lifecycle', () { + test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + final player = factory.created.first; + expect(player.plays, 1); + expect(player.pauses, 0); + + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + expect(player.pauses, 1); + + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + expect(player.plays, 2); + + logic.dispose(); + await _drain(logic); + }); + + test('should_stop_playing_without_losing_intent_when_route_becomes_inactive', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + final start = PlayerPage(drama: drama, initialIndex: 0); + logic.attach(start, isMounted: () => true); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + final player = factory.created.first; + expect(player.plays, 1); + + logic.onPageUpdated(start); + await _drain(logic); + expect(player.plays, 1); + + logic.attach( + PlayerPage(drama: drama, initialIndex: 0, active: false), + isMounted: () => true, + ); + logic.onPageUpdated(start); + await _drain(logic); + expect(player.pauses, 1); + expect(logic.session.userWantsPlay, isTrue); + + logic.dispose(); + await _drain(logic); + }); + }); + + group('dispose', () { + test('should_release_every_player_when_dispose_runs_twice', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + expect(factory.live, greaterThan(0)); + + logic.dispose(); + await _drain(logic); + + expect(factory.live, 0); + expect(() => logic.dispose(), returnsNormally); + }); + + test('should_disable_further_notifications_when_disposed', () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + var notifications = 0; + logic.addListener(() => notifications++); + + logic.dispose(); + await _drain(logic); + logic.notify(); + + expect(notifications, 0); + }); + }); +} + +/// Counts native-style allocations so leaks are visible to a test. +class _RecordingFactory { + final List<_RecordingPlayer> created = []; + int live = 0; + + PlayerPort create(Episode episode) { + live++; + final player = _RecordingPlayer(onDispose: () => live--); + created.add(player); + return player; + } +} + +class _RecordingPlayer implements PlayerPort { + _RecordingPlayer({required this.onDispose}); + + final void Function() onDispose; + int plays = 0; + int pauses = 0; + bool disposed = false; + + @override + Future initialize() async {} + + @override + Future play() async => plays++; + + @override + Future pause() async => pauses++; + + @override + Future dispose() async { + if (disposed) return; + disposed = true; + onDispose(); + } +} diff --git a/test/splash/splash_gate_test.dart b/test/splash/splash_gate_test.dart new file mode 100644 index 0000000..0806c82 --- /dev/null +++ b/test/splash/splash_gate_test.dart @@ -0,0 +1,59 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/home/home_view.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets( + 'should_keep_splash_with_loading_until_home_feed_settles', + (tester) async { + final repository = _PendingCatalog(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider( + create: (_) => CatalogController(repository)..load(), + child: const HomePage(), + ), + ), + ); + + expect(find.byKey(const Key('splash-screen')), findsOneWidget); + expect(find.byKey(const Key('splash-loading')), findsOneWidget); + expect(find.byKey(const Key('home-empty')), findsNothing); + final clip = tester.widget(find.byType(ClipRRect)); + expect(clip.borderRadius, BorderRadius.circular(18)); + + repository.pending.complete(const HomeFeed()); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('splash-screen')), findsNothing); + expect(find.byKey(const Key('home-empty')), findsOneWidget); + }, + ); +} + +class _PendingCatalog implements CatalogRepository { + final Completer pending = Completer(); + + @override + Future fetchHomeFeed() => pending.future; + + @override + Future fetchDiscoverFeed() => pending.future; + + @override + Future fetchDrama(String dramaId) => + Future.error(StateError('unused')); +} diff --git a/test/support/fake_account_repository.dart b/test/support/fake_account_repository.dart new file mode 100644 index 0000000..2135a01 --- /dev/null +++ b/test/support/fake_account_repository.dart @@ -0,0 +1,132 @@ +// Author: fruit.com.cn + +import 'package:fruitshorts/core/data/models/account_device.dart'; +import 'package:fruitshorts/core/data/models/account_export.dart'; +import 'package:fruitshorts/core/data/models/account_profile.dart'; +import 'package:fruitshorts/core/data/repositories/account_repository.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; + +/// 账号中心测试替身;可预置数据与失败文案。 +class FakeAccountRepository implements AccountRepository { + FakeAccountRepository({ + AccountProfile? profile, + List devices = const [], + AccountExport? export, + }) : _profile = profile ?? defaultProfile, + _devices = [...devices], + _export = export ?? defaultExport; + + static const defaultProfile = AccountProfile( + uid: 'U-K19', + nickname: 'Nadia', + avatarUrl: '', + gender: 'female', + countryCode: 'ID', + regionCode: 'JK', + language: 'id', + timezone: 'Asia/Jakarta', + currency: 'IDR', + status: 'normal', + maskedTarget: 'n***@example.com', + vipLevel: 0, + ); + + static const defaultExport = AccountExport( + profile: defaultProfile, + credentials: [ + AccountCredential( + type: 'EMAIL', + masked: 'n***@example.com', + verified: true, + ), + ], + consentCount: 1, + devices: [], + raw: { + 'profile': {'uid': 'U-K19', 'nickname': 'Nadia'}, + 'credentials': [ + {'type': 'EMAIL', 'masked': 'n***@example.com', 'verified': true}, + ], + 'consents': [ + {'scene': 'privacy'}, + ], + 'devices': [], + }, + ); + + AccountProfile _profile; + final List _devices; + final AccountExport _export; + AccountProfileUpdate? lastUpdate; + String? lastDeactivateReason; + int profileCalls = 0; + int deviceCalls = 0; + int exportCalls = 0; + bool failProfile = false; + bool failDevices = false; + bool failExport = false; + bool failUpdate = false; + bool failDeactivate = false; + bool failCancel = false; + bool throwUnexpected = false; + + @override + Future profile() async { + profileCalls++; + if (throwUnexpected) throw StateError('offline'); + if (failProfile) throw const ApiException(500, '资料加载失败'); + return _profile; + } + + @override + Future updateProfile(AccountProfileUpdate update) async { + lastUpdate = update; + if (throwUnexpected) throw StateError('offline'); + if (failUpdate) throw const ApiException(400, '昵称不可用'); + _profile = AccountProfile( + uid: _profile.uid, + nickname: update.nickname ?? _profile.nickname, + avatarUrl: _profile.avatarUrl, + gender: update.gender ?? _profile.gender, + countryCode: _profile.countryCode, + regionCode: _profile.regionCode, + language: _profile.language, + timezone: _profile.timezone, + currency: _profile.currency, + status: _profile.status, + maskedTarget: _profile.maskedTarget, + vipLevel: _profile.vipLevel, + ); + return _profile; + } + + @override + Future> devices() async { + deviceCalls++; + if (throwUnexpected) throw StateError('offline'); + if (failDevices) throw const ApiException(500, '设备加载失败'); + return [..._devices]; + } + + @override + Future export() async { + exportCalls++; + if (throwUnexpected) throw StateError('offline'); + if (failExport) throw const ApiException(500, '导出失败'); + return _export; + } + + @override + Future deactivate(String reason) async { + lastDeactivateReason = reason; + if (failDeactivate) throw const ApiException(100009, '注销申请已在处理中'); + return DateTime(2026, 11, 3, 9, 30); + } + + @override + Future cancelDeactivation() async { + if (failCancel) { + throw const ApiException(100009, '没有待处理的注销申请'); + } + } +} diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart new file mode 100644 index 0000000..fc6d54a --- /dev/null +++ b/test/support/fake_catalog.dart @@ -0,0 +1,70 @@ +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; + +/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。 +class FakeCatalogRepository implements CatalogRepository { + FakeCatalogRepository({List? dramas}) : dramas = dramas ?? demoDramas; + + final List dramas; + + @override + Future fetchHomeFeed() async => _feed(); + + @override + Future fetchDiscoverFeed() async => _feed(); + + @override + Future fetchDrama(String dramaId) async { + return dramas.firstWhere((drama) => drama.id == dramaId); + } + + HomeFeed _feed() => HomeFeed( + sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)], + ); +} + +final List demoDramas = [ + _drama( + 'the-signal-demo', + 'The Signal', + 'Suspense · Mystery', + format: 1, + ), + _drama( + 'city-lights-demo', + 'City Lights & Velvet Nights', + 'Urban Romance', + format: 8, + ), + _drama( + 'vengeance-demo', + 'Vengeance Rising', + 'Action · Thriller', + format: 15, + ), +]; + +Drama _drama( + String id, + String title, + String genre, { + required int format, +}) => Drama( + id: id, + title: title, + synopsis: '$title synopsis', + posterUrl: '/files/$id.png', + genre: genre, + detailLoaded: true, + episodes: [ + for (var index = 0; index < 7; index++) + Episode( + id: '$id-${index + 1}', + number: index + 1, + title: 'Episode ${index + 1}', + isFree: true, + videoUrl: 'https://example.test/$format-$index.mp4', + ), + ], +); diff --git a/test/support/fake_category.dart b/test/support/fake_category.dart new file mode 100644 index 0000000..b18fbb1 --- /dev/null +++ b/test/support/fake_category.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/category_repository.dart'; + +/// 分类测试仓库:分类清单固定,发现 feed 复用注入的目录仓库。 +class FakeCategoryRepository implements CategoryRepository { + FakeCategoryRepository({ + required this.catalog, + this.categories = const [ContentCategory(id: '7', name: 'Romance')], + }); + + final CatalogRepository catalog; + final List categories; + + @override + Future> fetchCategories() async => categories; + + @override + Future fetchDiscoverFeed() => catalog.fetchDiscoverFeed(); +} diff --git a/test/support/fake_haptics.dart b/test/support/fake_haptics.dart new file mode 100644 index 0000000..15314a9 --- /dev/null +++ b/test/support/fake_haptics.dart @@ -0,0 +1,36 @@ +// Author: fruit.com.cn + +import 'package:fruitshorts/core/platform/haptics.dart'; + +/// Deterministic haptics recorder for widget tests. +class FakeHaptics implements Haptics { + final List calls = []; + int selectionClicks = 0; + int lightImpacts = 0; + int mediumImpacts = 0; + + void reset() { + calls.clear(); + selectionClicks = 0; + lightImpacts = 0; + mediumImpacts = 0; + } + + @override + Future selectionClick() async { + calls.add('selection'); + selectionClicks++; + } + + @override + Future lightImpact() async { + calls.add('light'); + lightImpacts++; + } + + @override + Future mediumImpact() async { + calls.add('medium'); + mediumImpacts++; + } +} diff --git a/test/support/fake_http_adapter.dart b/test/support/fake_http_adapter.dart new file mode 100644 index 0000000..900fbb0 --- /dev/null +++ b/test/support/fake_http_adapter.dart @@ -0,0 +1,46 @@ +// Author: fruit.com.cn + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +/// 假响应:[body] 会按 UTF-8 编码成字节,[statusCode] 默认 200。 +class FakeHttpResponse { + FakeHttpResponse(this.body, [this.statusCode = 200]); + + final String body; + final int statusCode; +} + +/// dio 的测试替身:把请求交给回调处理,全程不联网。 +/// +/// 顺带记录每个收到的请求,测试可以直接断言"发了几次、带了什么头"。 +class FakeHttpAdapter implements HttpClientAdapter { + FakeHttpAdapter(this.handler); + + final Future Function(RequestOptions options) handler; + + /// 按顺序记录收到的请求,用于断言请求次数与请求头。 + final List requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + final response = await handler(options); + return ResponseBody.fromBytes( + utf8.encode(response.body), + response.statusCode, + headers: const { + 'content-type': ['application/json; charset=utf-8'], + }, + ); + } + + @override + void close({bool force = false}) {} +} diff --git a/test/support/fake_interaction_repositories.dart b/test/support/fake_interaction_repositories.dart new file mode 100644 index 0000000..51ebf04 --- /dev/null +++ b/test/support/fake_interaction_repositories.dart @@ -0,0 +1,177 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:fruitshorts/core/data/models/comment.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; + +class CommentPageCall { + const CommentPageCall({ + required this.dramaId, + required this.rootId, + required this.cursor, + required this.size, + }); + + final String dramaId; + final String? rootId; + final String? cursor; + final int size; +} + +class PublishCall { + const PublishCall({ + required this.dramaId, + required this.episodeId, + required this.content, + required this.parentId, + }); + + final String dramaId; + final String? episodeId; + final String content; + final String? parentId; +} + +class FakeCommentRepository implements CommentRepository { + FakeCommentRepository({ + List? pages, + this.publishResult, + this.publishError, + this.deleteError, + this.pageError, + }) : pages = pages ?? const []; + + final List pages; + final DramaComment? publishResult; + final Object? publishError; + final Object? deleteError; + final Object? pageError; + final List pageCalls = []; + final List publishCalls = []; + final List deleteCalls = []; + int _pageIndex = 0; + + @override + Future page({ + required String dramaId, + String? rootId, + String? cursor, + int size = commentPageSize, + }) async { + pageCalls.add( + CommentPageCall( + dramaId: dramaId, + rootId: rootId, + cursor: cursor, + size: size, + ), + ); + final error = pageError; + if (error != null) throw error; + if (_pageIndex >= pages.length) return const CommentPage(); + return pages[_pageIndex++]; + } + + @override + Future publish({ + required String dramaId, + String? episodeId, + required String content, + String? parentId, + }) async { + publishCalls.add( + PublishCall( + dramaId: dramaId, + episodeId: episodeId, + content: content, + parentId: parentId, + ), + ); + final error = publishError; + if (error != null) throw error; + return publishResult ?? + commentFixture( + id: 'new-comment', + dramaId: dramaId, + content: content, + mine: true, + ); + } + + @override + Future delete(String commentId) async { + deleteCalls.add(commentId); + final error = deleteError; + if (error != null) throw error; + } +} + +class FakeInteractionRepository implements InteractionRepository { + FakeInteractionRepository({ + this.nextLikeResult = const LikeToggleResult(liked: true, count: 1), + }); + + LikeToggleResult nextLikeResult; + Object? likeError; + Object? reportError; + Completer? likeGate; + String reportId = 'report-1'; + final List likeTargets = []; + final List reports = []; + + @override + Future toggleLike({ + required InteractionTargetType targetType, + required String targetId, + }) async { + likeTargets.add('${targetType.apiValue}:$targetId'); + final gate = likeGate; + if (gate != null) await gate.future; + final error = likeError; + if (error != null) throw error; + return nextLikeResult; + } + + @override + Future report(ReportRequest request) async { + reports.add(request); + final error = reportError; + if (error != null) throw error; + return reportId; + } +} + +DramaComment commentFixture({ + String id = '1001', + String dramaId = '5', + String? parentId, + String? rootId, + String content = 'A comment', + String status = 'published', + int likeCount = 0, + int replyCount = 0, + bool liked = false, + bool mine = false, +}) => DramaComment( + id: id, + dramaId: dramaId, + episodeId: '0', + parentId: parentId ?? '0', + rootId: rootId ?? '0', + userId: mine ? '9' : '8', + uid: mine ? 'me' : 'viewer', + nickname: mine ? 'Me' : 'Viewer', + avatarUrl: '', + content: content, + status: status, + auditReason: '', + likeCount: likeCount, + replyCount: replyCount, + reportCount: 0, + createdAt: DateTime(2026, 10, 17, 12), + mine: mine, + liked: liked, +); diff --git a/test/support/fake_notice_repository.dart b/test/support/fake_notice_repository.dart new file mode 100644 index 0000000..ad18585 --- /dev/null +++ b/test/support/fake_notice_repository.dart @@ -0,0 +1,32 @@ +// Author: fruit.com.cn + +import 'package:fruitshorts/core/data/models/notice.dart'; +import 'package:fruitshorts/core/data/repositories/notice_repository.dart'; + +/// Scripted notice repository for controller and widget tests. +class FakeNoticeRepository implements NoticeRepository { + FakeNoticeRepository({List? pages, this.error, this.delay}) + : pages = pages ?? []; + + final List pages; + Object? error; + Duration? delay; + final List calls = []; + + @override + Future page({required int pageIndex, int pageSize = 20}) async { + calls.add(FakeNoticeCall(pageIndex: pageIndex, pageSize: pageSize)); + if (delay != null) await Future.delayed(delay!); + if (error != null) throw error!; + if (pages.isEmpty) return const NoticePage(total: 0); + return pages.removeAt(0); + } +} + +/// Captured notice page request. +class FakeNoticeCall { + const FakeNoticeCall({required this.pageIndex, required this.pageSize}); + + final int pageIndex; + final int pageSize; +} diff --git a/test/support/fake_watch_history.dart b/test/support/fake_watch_history.dart new file mode 100644 index 0000000..6616b7a --- /dev/null +++ b/test/support/fake_watch_history.dart @@ -0,0 +1,73 @@ +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; + +/// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。 +/// +/// 服务端模型:每部剧一条续看记录(不再是 profile+剧+集),所以 `reads` 里是 dramaId。 +class FakeWatchHistory implements WatchHistoryRepository { + final List reads = []; + final List writes = []; + + /// 便捷写法:只关心"有续看位置"时直接给这个值(默认落在第 1 集)。 + Duration? saved; + + /// 便捷写法:续看记录落在哪一集(配合 [saved] 使用)。 + String? lastEpisode; + + /// 精确控制:按剧返回不同记录。 + final Map records = {}; + + /// [recent] 的返回值。 + List recentRecords = const []; + + bool failReads = false; + bool failWrites = false; + + @override + Future progressFor(String dramaId) async { + reads.add(dramaId); + if (failReads) throw StateError('history unavailable'); + final record = records[dramaId]; + if (record != null) return record; + if (saved == null) return null; + return WatchProgress( + dramaId: dramaId, + episodeId: lastEpisode ?? '$dramaId-1', + position: saved!, + duration: const Duration(minutes: 5), + ); + } + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async { + if (failWrites) throw StateError('history unavailable'); + writes.add('$episodeId|${position.inMilliseconds}|$finished'); + return WatchProgress( + dramaId: '', + episodeId: episodeId, + position: position, + duration: duration ?? Duration.zero, + finished: finished, + ); + } + + @override + Future> recent({int limit = 20}) async { + if (failReads) throw StateError('history unavailable'); + return recentRecords.take(limit).toList(); + } + + @override + Future remove(String dramaId) async => records.remove(dramaId); + + @override + Future clear() async { + records.clear(); + recentRecords = const []; + } +} diff --git a/test/support/widget_player.dart b/test/support/widget_player.dart index 36d7204..4aef63d 100644 --- a/test/support/widget_player.dart +++ b/test/support/widget_player.dart @@ -1,7 +1,6 @@ // Author: fruit.com.cn - -import 'package:nontonki/core/data/models/drama.dart'; -import 'package:nontonki/core/data/models/player_port.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/player_port.dart'; // Widget tests exercise navigation and scheduling without a native decoder. PlayerPort createWidgetPlayer(Episode episode) => WidgetPlayer(); diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart new file mode 100644 index 0000000..f4402d4 --- /dev/null +++ b/test/theme/app_dimens_test.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; + +void main() { + test('should_expose_the_k20_spacing_and_type_scale', () { + expect(AppDimens.pageHorizontal, 16); + expect(AppDimens.cardRadius, 12); + expect(AppDimens.innerRadius, 10); + expect(AppDimens.caption, 12); + expect(AppDimens.body, 14); + expect(AppDimens.listTitle, 15); + expect(AppDimens.sectionTitle, 16); + expect(AppDimens.pageTitle, 20); + expect(AppDimens.navLabel, 9); + expect(AppDimens.buttonHeight, 48); + expect(AppDimens.primaryButtonHeight, 52); + expect(AppDimens.tapTarget, 44); + expect(AppDimens.navHeight, 54); + expect(AppDimens.progressHeight, 3); + }); +} diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart new file mode 100644 index 0000000..4a7eb85 --- /dev/null +++ b/test/theme/app_theme_test.dart @@ -0,0 +1,84 @@ +// Author: fruit.com.cn + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; + +double _contrast(Color foreground, Color background) { + final first = foreground.computeLuminance(); + final second = background.computeLuminance(); + return (max(first, second) + 0.05) / (min(first, second) + 0.05); +} + +void main() { + test('should_expose_obsidian_flare_brand_tokens', () { + expect(AppColors.blue, const Color(0xFFFF5B3D)); + expect(AppColors.bluePressed, const Color(0xFFFF4321)); + expect(AppColors.blueSoft, const Color(0xFF9278EA)); + expect(AppColors.secondary, const Color(0xFF9278EA)); + expect(AppColors.gold, const Color(0xFFFBBF24)); + expect(AppColors.ink, const Color(0xFF0B0C10)); + expect(AppColors.card, const Color(0xFF1B1C21)); + expect(AppColors.cardAlt, const Color(0xFF1C1D21)); + expect(AppColors.raised, const Color(0xFF1C1D21)); + expect(AppColors.bar, const Color(0xFF0D0D11)); + expect(AppColors.success, const Color(0xFF34D399)); + expect(AppColors.onPrimary, const Color(0xFFFFFFFF)); + expect( + _contrast(AppColors.onGold, AppColors.gold), + greaterThanOrEqualTo(4.5), + ); + }); + + test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () { + expect( + _contrast(AppColors.onPrimary, AppColors.blue), + greaterThanOrEqualTo(3), + ); + expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(3)); + expect( + _contrast(AppColors.blueSoft, AppColors.ink), + greaterThanOrEqualTo(3), + ); + }); + + test('should_use_flare_brand_in_light_and_dark_color_schemes', () { + for (final theme in [AppTheme.light(), AppTheme.dark()]) { + expect(theme.colorScheme.primary, AppColors.blue); + expect(theme.colorScheme.onPrimary, AppColors.onPrimary); + } + }); + + test('should_define_non_white_outline_tokens_in_both_themes', () { + final light = AppTheme.light().colorScheme; + final dark = AppTheme.dark().colorScheme; + + expect(light.outline, AppColors.outlineLight); + expect(light.outlineVariant, AppColors.outlineVariantLight); + expect(dark.outline, AppColors.outlineDark); + expect(dark.outlineVariant, AppColors.outlineVariantDark); + expect(dark.outlineVariant.computeLuminance(), lessThan(0.2)); + expect(light.outlineVariant.computeLuminance(), lessThan(0.92)); + }); + + testWidgets('should_expose_chili_brand_through_widget_theme', (tester) async { + late ThemeData applied; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + home: Builder( + builder: (context) { + applied = Theme.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(applied.colorScheme.primary, AppColors.blue); + expect(applied.colorScheme.onPrimary, AppColors.onPrimary); + }); +} diff --git a/test/theme/glass_panel_test.dart b/test/theme/glass_panel_test.dart new file mode 100644 index 0000000..ec82aee --- /dev/null +++ b/test/theme/glass_panel_test.dart @@ -0,0 +1,43 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/glass_panel.dart'; + +void main() { + testWidgets('should_blur_and_paint_a_bordered_glass_surface', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: GlassPanel( + radius: 26, + padding: EdgeInsets.all(16), + child: Text('glass'), + ), + ), + ), + ), + ); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, isNotNull); + expect(find.text('glass'), findsOneWidget); + + final clip = tester.widget(find.byType(ClipRRect).first); + expect(clip.borderRadius, BorderRadius.circular(26)); + + final decorated = tester.widget( + find + .descendant( + of: find.byType(GlassPanel), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = decorated.decoration as BoxDecoration; + expect(decoration.gradient, isA()); + expect(decoration.border!.top.color, AppColors.glassBorder); + }); +} diff --git a/test/theme/hardcoded_blue_scan_test.dart b/test/theme/hardcoded_blue_scan_test.dart new file mode 100644 index 0000000..7e3ddf8 --- /dev/null +++ b/test/theme/hardcoded_blue_scan_test.dart @@ -0,0 +1,25 @@ +// Author: fruit.com.cn + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('should_use_theme_tokens_when_lib_code_is_scanned_for_legacy_blue', () { + final pattern = RegExp( + r'0xFF2F7BFF|0xff2F7BFF|0xFF8FB4FF|0xff8FB4FF|(?[]; + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final lines = entity.readAsLinesSync(); + for (var index = 0; index < lines.length; index++) { + if (pattern.hasMatch(lines[index])) { + violations.add('${entity.path}:${index + 1}: ${lines[index].trim()}'); + } + } + } + + expect(violations, isEmpty, reason: violations.join('\n')); + }); +} diff --git a/test/watch/watch_history_provider_test.dart b/test/watch/watch_history_provider_test.dart new file mode 100644 index 0000000..2b10336 --- /dev/null +++ b/test/watch/watch_history_provider_test.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_open_watch_history_with_catalog_provider_registered', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"1","nickname":"Viewer","avatarUrl":""}', + }); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Watch History')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('watch-records-page')), findsOneWidget); + expect(find.text('No watch history yet.'), findsOneWidget); + }); +} diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart new file mode 100644 index 0000000..2fd55b2 --- /dev/null +++ b/test/watch/watch_records_controller_test.dart @@ -0,0 +1,241 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/services/watch_records_controller.dart'; + +WatchProgress _progress( + String dramaId, { + String episodeId = 'episode-1', + int positionMs = 12000, + int durationMs = 60000, +}) => WatchProgress( + dramaId: dramaId, + episodeId: episodeId, + position: Duration(milliseconds: positionMs), + duration: Duration(milliseconds: durationMs), + updatedAt: DateTime(2026, 10, 17, 12), +); + +Drama _detail(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'synopsis', + posterUrl: '/files/$id.png', + detailLoaded: true, + episodes: const [ + Episode(id: 'episode-1', number: 1, title: 'Episode 1', isFree: true), + ], +); + +void main() { + test( + 'should_parse_updated_time_and_keep_string_ids_when_recent_json_arrives', + () { + final progress = WatchProgress.fromJson({ + 'dramaId': '2111012753262120962', + 'episodeId': '2111012753262120963', + 'positionMs': '12000', + 'durationMs': '60000', + 'updatedAt': '2026-10-17T12:34:56', + }); + + expect(progress.dramaId, '2111012753262120962'); + expect(progress.episodeId, '2111012753262120963'); + expect(progress.updatedAt, DateTime(2026, 10, 17, 12, 34, 56)); + }, + ); + + test('should_dedupe_detail_requests_and_cap_concurrency_at_four', () async { + final history = ControlledWatchHistory([ + for (var index = 0; index < 6; index++) _progress('$index'), + _progress('0'), + ]); + final catalog = ScriptedWatchCatalog(); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + ); + + await controller.load(); + + expect(controller.records, hasLength(6)); + expect(catalog.calls, hasLength(6)); + expect(catalog.maxActive, 4); + expect(controller.error, isNull); + }); + + test('should_keep_other_records_when_one_detail_request_fails', () async { + final history = ControlledWatchHistory([_progress('1'), _progress('2')]); + final catalog = ScriptedWatchCatalog(failedIds: {'1'}); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + ); + + await controller.load(); + + expect(controller.records, hasLength(2)); + expect(controller.records.first.detailFailed, isTrue); + expect(controller.records.first.drama, isNull); + expect(controller.records.last.drama?.id, '2'); + + catalog.failedIds.clear(); + await controller.retryDetail('1'); + expect(controller.records.first.detailFailed, isFalse); + expect(controller.records.first.drama?.id, '1'); + }); + + test( + 'should_remove_record_optimistically_and_restore_it_when_delete_fails', + () async { + final history = ControlledWatchHistory([_progress('1'), _progress('2')]); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + await controller.load(); + final gate = Completer(); + history.removeGate = gate; + history.failRemove = true; + + final pending = controller.remove('1'); + await Future.delayed(Duration.zero); + expect(controller.records.map((record) => record.dramaId), ['2']); + + gate.complete(); + expect(await pending, isFalse); + expect(controller.records.map((record) => record.dramaId), ['1', '2']); + expect(controller.actionError, 'recordDeleteFailed'); + }, + ); + + test( + 'should_clear_records_optimistically_and_restore_them_when_clear_fails', + () async { + final history = ControlledWatchHistory([_progress('1'), _progress('2')]); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + await controller.load(); + final gate = Completer(); + history.clearGate = gate; + history.failClear = true; + + final pending = controller.clear(); + await Future.delayed(Duration.zero); + expect(controller.records, isEmpty); + + gate.complete(); + expect(await pending, isFalse); + expect(controller.records.map((record) => record.dramaId), ['1', '2']); + expect(controller.actionError, 'recordClearFailed'); + }, + ); + + test('should_expose_retryable_error_when_recent_request_fails', () async { + final history = ControlledWatchHistory([])..failRecent = true; + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.error, 'progressError'); + expect(controller.records, isEmpty); + }); + + test('should_preserve_backend_message_when_recent_request_fails', () async { + final history = ControlledWatchHistory([]) + ..recentError = const ApiException(100000, '账号不存在,请先注册'); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + ); + + await controller.load(); + + expect(controller.error, '账号不存在,请先注册'); + }); +} + +class ControlledWatchHistory implements WatchHistoryRepository { + ControlledWatchHistory(this.items); + + final List items; + bool failRecent = false; + Object? recentError; + bool failRemove = false; + bool failClear = false; + Completer? removeGate; + Completer? clearGate; + + @override + Future progressFor(String dramaId) async => null; + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async => null; + + @override + Future> recent({int limit = 20}) async { + if (recentError != null) throw recentError!; + if (failRecent) throw StateError('recent unavailable'); + return items.take(limit).toList(); + } + + @override + Future remove(String dramaId) async { + if (removeGate != null) await removeGate!.future; + if (failRemove) throw StateError('remove unavailable'); + items.removeWhere((item) => item.dramaId == dramaId); + } + + @override + Future clear() async { + if (clearGate != null) await clearGate!.future; + if (failClear) throw StateError('clear unavailable'); + items.clear(); + } +} + +class ScriptedWatchCatalog implements CatalogRepository { + ScriptedWatchCatalog({Set? failedIds}) + : failedIds = failedIds ?? {}; + + final Set failedIds; + final List calls = []; + int _active = 0; + int maxActive = 0; + + @override + Future fetchHomeFeed() async => const HomeFeed(); + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchDrama(String dramaId) async { + calls.add(dramaId); + _active++; + if (_active > maxActive) maxActive = _active; + await Future.delayed(const Duration(milliseconds: 5)); + _active--; + if (failedIds.contains(dramaId)) throw StateError('detail unavailable'); + return _detail(dramaId); + } +} diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart new file mode 100644 index 0000000..d8c99e3 --- /dev/null +++ b/test/watch/watch_records_page_test.dart @@ -0,0 +1,245 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +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/widgets/empty_state.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_real_record_details_when_recent_history_loads', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final catalog = FakeCatalogRepository(); + final history = CountingWatchHistory([ + WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-1', + position: const Duration(seconds: 12), + duration: const Duration(seconds: 60), + updatedAt: DateTime(2026, 10, 17, 12, 34), + ), + ]); + + await _pumpPage(tester, auth: auth, history: history, catalog: catalog); + + expect(find.byKey(const Key('watch-records-page')), findsOneWidget); + expect(find.text('The Signal'), findsOneWidget); + expect( + find.byKey(const Key('watch-record-episode-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets( + 'should_show_failed_placeholder_and_keep_other_cards_when_detail_fails', + (tester) async { + final auth = await _auth(signedIn: true); + final history = CountingWatchHistory([ + _progress('broken'), + _progress('the-signal-demo'), + ]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: _FailingWatchCatalog(), + ); + + expect( + find.byKey(const Key('watch-record-failed-broken')), + findsOneWidget, + ); + expect(find.text('The Signal'), findsOneWidget); + expect( + find.byKey(const Key('watch-record-retry-broken')), + findsOneWidget, + ); + }, + ); + + testWidgets('should_show_auth_gate_without_loading_records_when_signed_out', ( + tester, + ) async { + final auth = await _auth(signedIn: false); + final history = CountingWatchHistory([_progress('the-signal-demo')]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + ); + + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(history.recentCalls, 0); + }); + + testWidgets( + 'should_clear_records_only_after_confirmation_when_clear_is_tapped', + (tester) async { + final auth = await _auth(signedIn: true); + final history = CountingWatchHistory([_progress('the-signal-demo')]); + final haptics = FakeHaptics(); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + haptics: haptics, + ); + await tester.tap(find.byKey(const Key('watch-records-clear'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('watch-records-clear')), findsOneWidget); + expect(history.clearCalls, 0); + + await tester.tap(find.widgetWithText(FilledButton, 'Clear')); + await tester.pumpAndSettle(); + + expect(history.clearCalls, 1); + expect(haptics.mediumImpacts, 1); + expect(find.text('No watch history yet.'), findsOneWidget); + expect(find.byType(EmptyState), findsOneWidget); + }, + ); + + testWidgets('should_show_backend_message_when_recent_request_fails', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final history = _FailingRecentHistory(); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + ); + + expect(find.text('账号不存在,请先注册'), findsOneWidget); + expect(find.byKey(const Key('watch-records-retry')), findsOneWidget); + }); +} + +WatchProgress _progress(String dramaId) => WatchProgress( + dramaId: dramaId, + episodeId: '$dramaId-1', + position: const Duration(seconds: 12), + duration: const Duration(seconds: 60), + updatedAt: DateTime(2026, 10, 17, 12), +); + +Future _auth({required bool signedIn}) async { + if (signedIn) { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"1","nickname":"Viewer","avatarUrl":""}', + }); + } + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ); + final auth = AuthController(AuthApi(client), AuthSessionStore()); + await auth.load(); + return auth; +} + +Future _pumpPage( + WidgetTester tester, { + required AuthController auth, + required WatchHistoryRepository history, + required CatalogRepository catalog, + FakeHaptics? haptics, +}) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: history), + Provider.value(value: catalog), + Provider.value(value: haptics ?? FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const WatchRecordsPage(), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class CountingWatchHistory extends InMemoryWatchHistoryRepository { + CountingWatchHistory(this.visibleRecords); + + final List visibleRecords; + int recentCalls = 0; + int clearCalls = 0; + + @override + Future> recent({int limit = 20}) async { + recentCalls++; + return visibleRecords.take(limit).toList(); + } + + @override + Future clear() async { + clearCalls++; + await super.clear(); + } +} + +class _FailingRecentHistory extends InMemoryWatchHistoryRepository { + @override + Future> recent({int limit = 20}) async { + throw const ApiException(100000, '账号不存在,请先注册'); + } +} + +class _FailingWatchCatalog implements CatalogRepository { + final _delegate = FakeCatalogRepository(); + + @override + Future fetchHomeFeed() => _delegate.fetchHomeFeed(); + + @override + Future fetchDiscoverFeed() => _delegate.fetchDiscoverFeed(); + + @override + Future fetchDrama(String dramaId) async { + if (dramaId == 'broken') throw StateError('missing detail'); + return _delegate.fetchDrama(dramaId); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index ccdbd1d..430b533 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,25 +3,77 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:nontonki/app.dart'; -import 'package:nontonki/pages/player/player_view.dart'; +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/comment.dart'; +import 'package:fruitshorts/core/data/models/interaction.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'support/fake_catalog.dart'; +import 'support/fake_category.dart'; +import 'support/fake_interaction_repositories.dart'; import 'support/widget_player.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; void main() { - setUp(() { + /// 默认按「已登录」起测:账号体系接上后,"登录"不再是本地演示开关, + /// 而是本地会话(这里直接给 shared_preferences 的内存实现塞一份会话)。 + /// 昵称/uid 与演示文案保持一致,避免为显示细节改写既有断言。 + void seedSignedInSession() { + SharedPreferencesAsyncPlatform + .instance = InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"M1CZWVYE","nickname":"Vicky sheng","avatarUrl":""}', + }); + } + + void seedSignedOutSession() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + } + + setUp(() { + seedSignedInSession(); }); + /// 所有 widget 测试都注入离线仓库:票据 / 进度 / 收藏都不碰网络, + /// 这样断言的是 UI 行为,而不是后端有没有起。 + Widget appUnderTest({ + PlayerFactory? playerFactory, + CatalogRepository? catalogRepository, + CommentRepository? commentRepository, + InteractionRepository? interactionRepository, + }) { + final catalog = catalogRepository ?? FakeCatalogRepository(); + return LumaDramaApp( + playerFactory: playerFactory ?? createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository( + cards: FakeCatalogRepository().dramas, + ), + playbackRepository: FakePlaybackRepository(), + commentRepository: commentRepository ?? FakeCommentRepository(), + interactionRepository: + interactionRepository ?? FakeInteractionRepository(), + ); + } + testWidgets( - 'home swipe promotes next without recreating it or showing loading', + 'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped', (tester) async { final created = []; await tester.pumpWidget( - LumaDramaApp( + appUnderTest( playerFactory: (episode) { created.add(episode.id); return WidgetPlayer(); @@ -29,6 +81,8 @@ void main() { ), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); expect(created, hasLength(2)); expect(find.byType(CircularProgressIndicator), findsNothing); await tester.drag(find.byType(PageView), const Offset(0, -550)); @@ -43,221 +97,91 @@ void main() { }, ); - testWidgets('immersive home opens the series detail', (tester) async { + testWidgets('should_open_series_detail_when_immersive_home_is_tapped', ( + tester, + ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); - expect(find.byType(Slider), findsOneWidget); + expect(find.byType(Slider), findsNothing); expect(find.text('Home'), findsWidgets); - expect(find.text('Recommended'), findsOneWidget); - expect(find.textContaining('Playing now'), findsOneWidget); - expect(find.byTooltip('Episodes'), findsWidgets); - expect(find.byTooltip('Watch in landscape'), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsOneWidget); - - await tester.tap(find.byTooltip('Explore series')); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('rail-like')), findsOneWidget); + expect(find.byKey(const Key('rail-comments')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + await tester.tap(find.byKey(const Key('home-feed-more'))); await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); - expect(find.text('Episodes'), findsOneWidget); - }); - - testWidgets('landscape control hides tabs and restores them on exit', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Watch in landscape')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); - expect(find.byTooltip('Exit fullscreen'), findsOneWidget); - await tester.tap(find.byTooltip('Exit fullscreen')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets('home quick drawer opens current series and switches root tabs', ( - tester, - ) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Open quick menu')); - await tester.pumpAndSettle(); - expect(tester.widget(find.byType(PlayerPage)).active, isFalse); - expect(find.text('Quick access'), findsOneWidget); - expect( - find.descendant( - of: find.byType(Drawer), - matching: find.text('Same demo clip · free'), - ), - findsOneWidget, - ); - expect(tester.takeException(), isNull); - - await tester.tap(find.byKey(const Key('drawer-open-series'))); - await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); + expect(find.byKey(const Key('home-feed-download')), findsOneWidget); + expect(find.byKey(const Key('home-feed-pip')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(tester.widget(find.byType(PlayerPage)).active, isTrue); - - await tester.tap(find.byTooltip('Open quick menu')); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('drawer-discover'))); - await tester.pumpAndSettle(); - expect(find.text('Trending Micro-Dramas'), findsOneWidget); - expect( - Navigator.of(tester.element(find.text('Trending Micro-Dramas'))).canPop(), - isFalse, - ); - }); - - testWidgets('interface language changes without a caption control', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); - await tester.pumpAndSettle(); - expect(find.byTooltip('Captions'), findsNothing); - await tester.tap(find.byTooltip('My')); - await tester.pumpAndSettle(); - expect(find.text('Continue Watching'), findsOneWidget); - expect(find.text('History'), findsOneWidget); - expect(find.text('Watchlist'), findsOneWidget); - await tester.scrollUntilVisible( - find.text('App Language'), - 120, - scrollable: find - .descendant( - of: find.byType(ListView), - matching: find.byType(Scrollable), - ) - .first, - ); - expect(find.text('PREFERENCES'), findsOneWidget); - await tester.tap(find.text('App Language')); - await tester.pumpAndSettle(); - await tester.tap(find.text('简体中文').last); - await tester.pumpAndSettle(); - expect(find.text('我的'), findsWidgets); - expect(find.text('继续观看'), findsOneWidget); - expect(find.text('找剧'), findsOneWidget); - await tester.tap(find.text('找剧')); - await tester.pumpAndSettle(); - expect(find.text('热门短剧'), findsOneWidget); - expect(find.text('排行榜'), findsOneWidget); - }); - - testWidgets('home controls fit a compact Android viewport', (tester) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); - await tester.pumpAndSettle(); - expect(find.text('Recommended'), findsOneWidget); - await tester.drag(find.byType(PageView), const Offset(0, -500)); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - await tester.tap(find.byTooltip('Explore series')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Watch episode 1')); - await tester.pumpAndSettle(); - expect(find.byType(Slider), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets('My page fits a compact Android viewport and returns home', ( - tester, - ) async { - tester.view.physicalSize = const Size(360, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('My')); - await tester.pumpAndSettle(); - expect(find.text('Continue Watching'), findsOneWidget); - expect(find.text('History'), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.tap(find.text('Home').last); - await tester.pumpAndSettle(); - expect(find.text('Recommended'), findsOneWidget); + expect(find.text('Watch Full'), findsNothing); + expect(find.byTooltip('Explore series'), findsNothing); }); testWidgets( - 'Discover shows three mock playable dramas and one pending poster', + 'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used', (tester) async { tester.view.physicalSize = const Size(360, 720); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(PlayerPage, skipOffstage: false)) + .active, + isFalse, ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Discover')); - await tester.pumpAndSettle(); - - expect(find.text('Trending Micro-Dramas'), findsOneWidget); - expect(find.text('Filter'), findsOneWidget); - expect(find.text('Top Charts'), findsOneWidget); - expect(find.text('New Releases'), findsOneWidget); - expect(find.text('Watchlist'), findsOneWidget); - expect(find.text('Same demo clip · free'), findsNWidgets(3)); - expect(find.text('Video pending'), findsOneWidget); + expect(find.text('Quick access'), findsOneWidget); + expect(find.text('Bahasa Indonesia'), findsOneWidget); + expect(find.text('7 EPISODES'), findsOneWidget); expect(tester.takeException(), isNull); - await tester.enterText(find.byType(TextField), 'Vengeance'); + await tester.tap(find.byKey(const Key('drawer-open-series'))); await tester.pumpAndSettle(); - expect(find.text('Vengeance Rising'), findsOneWidget); - expect(find.text('Same demo clip · free'), findsOneWidget); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(PlayerPage, skipOffstage: false)) + .active, + isFalse, + ); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.tap(find.text('Watchlist')); + await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(find.text('No shows here yet.'), findsOneWidget); - await tester.tap(find.text('Home').last); + await tester.tap(find.byKey(const Key('drawer-discover'))); await tester.pumpAndSettle(); - expect(find.text('Recommended'), findsOneWidget); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect( + Navigator.of( + tester.element(find.byKey(const Key('home-all-dramas-grid'))), + ).canPop(), + isFalse, + ); }, ); - testWidgets('bottom tabs stay on one route and preserve Discover search', ( + testWidgets('should_change_interface_language_when_locale_is_picked', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -265,84 +189,249 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); + await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.text('Discover')); + expect(find.byTooltip('Captions'), findsNothing); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Vengeance'); + expect(find.text('Vicky sheng'), findsOneWidget); + expect(find.text('Coins'), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, -700)); await tester.pumpAndSettle(); - expect(find.text('Vengeance Rising'), findsOneWidget); + await tester.tap(find.text('App Language')); + await tester.pumpAndSettle(); + await tester.tap(find.text('简体中文').last); + await tester.pumpAndSettle(); + expect(find.text('我的'), findsWidgets); + await tester.drag(find.byType(ListView), const Offset(0, 700)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + expect(find.text('首页'), findsOneWidget); + await tester.tap(find.text('发现')); + await tester.pumpAndSettle(); + expect(find.text('推荐'), findsOneWidget); + }); + + testWidgets( + 'should_fit_compact_android_viewport_when_home_controls_are_shown', + (tester) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + await tester.drag(find.byType(PageView), const Offset(0, -500)); + await tester.pumpAndSettle(); + expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('home-drama-card-city-lights-demo')), + ); + await tester.pumpAndSettle(); + expect(find.byType(Slider), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'should_fit_compact_android_viewport_and_return_home_when_my_page_is_shown', + (tester) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Vicky sheng'), findsOneWidget); + expect(find.text('Coins'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + }, + ); + + testWidgets('should_fit_compact_dark_viewport_when_evening_theme_is_used', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + expect(find.text('Coins'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'should_show_three_playable_dramas_and_one_pending_poster_when_discover_is_opened', + (tester) async { + tester.view.physicalSize = const Size(360, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.text('Recommended'), findsOneWidget); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-profile')), findsOneWidget); + expect( + find.byKey( + const Key('home-drama-genre-city-lights-demo'), + skipOffstage: false, + ), + findsOneWidget, + ); + expect( + find.byKey( + const Key('home-drama-genre-vengeance-demo'), + skipOffstage: false, + ), + findsOneWidget, + ); + expect(find.byTooltip('Search'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); + await tester.pumpAndSettle(); + expect(find.text('No shows here yet.'), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + }, + ); + + testWidgets('should_stay_on_one_route_when_bottom_tabs_are_used', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byTooltip('Search'), findsOneWidget); expect( - Navigator.of(tester.element(find.byType(TextField))).canPop(), + Navigator.of( + tester.element(find.byKey(const Key('home-all-dramas-grid'))), + ).canPop(), isFalse, ); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - expect(find.text('History'), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, -700)); + await tester.pumpAndSettle(); + expect(find.text('History'), findsWidgets); expect( - Navigator.of(tester.element(find.text('History'))).canPop(), + Navigator.of(tester.element(find.text('History').first)).canPop(), isFalse, ); - await tester.tap(find.text('Watchlist')); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.widgetWithText(TextField, 'Vengeance'), findsOneWidget); - expect(find.text('Vengeance Rising'), findsOneWidget); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byTooltip('Search'), findsOneWidget); expect( - Navigator.of(tester.element(find.byType(TextField))).canPop(), + Navigator.of( + tester.element(find.byKey(const Key('home-all-dramas-grid'))), + ).canPop(), isFalse, ); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); + await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.text('Recommended'), findsOneWidget); + expect(find.text('The Signal'), findsOneWidget); expect( - Navigator.of(tester.element(find.text('Recommended'))).canPop(), + Navigator.of(tester.element(find.text('The Signal'))).canPop(), isFalse, ); }); testWidgets( - 'home swipe changes drama, entered player swipe changes episode', + 'should_change_drama_on_home_swipe_and_episode_on_player_swipe_when_swiping', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - expect(find.textContaining('Episode 1'), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + contains('Episode 1'), + ); - await tester.tap(find.byTooltip('Explore series')); + await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - await tester.tap(find.text('Watch episode 1')); + await tester.tap( + find.byKey(const Key('home-drama-card-city-lights-demo')), + ); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.textContaining('EPISODE 2 / 3'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, ); - testWidgets('home categories switch by video, top row and tap', ( + testWidgets('should_hide_category_navigation_when_shorts_page_is_shown', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -350,193 +439,201 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); + await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.tune_rounded), findsNothing); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.text('Following'), findsNothing); + expect(find.text('Animated'), findsNothing); + expect(find.text('Live Action'), findsNothing); + expect(find.text('Recommended'), findsNothing); expect(find.text('The Signal'), findsOneWidget); - - await tester.drag( - find.byKey(const Key('home-video-swipe')), - const Offset(220, 0), - ); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - expect(find.byKey(const Key('home-category-liveAction')), findsOneWidget); - await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.text('Vengeance Rising'), findsOneWidget); - - await tester.drag( - find.byKey(const Key('home-category-swipe')), - const Offset(190, 0), - ); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - - await tester.drag( - find.byKey(const Key('home-category-swipe')), - const Offset(-190, 0), - ); - await tester.pumpAndSettle(); expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - - await tester.tap(find.byKey(const Key('home-category-following'))); - await tester.pumpAndSettle(); - expect(find.text('No dramas in this category yet.'), findsOneWidget); - await tester.tap(find.byKey(const Key('home-category-recommended'))); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); expect(tester.takeException(), isNull); }); - testWidgets('right rail saves, comments, likes and invokes share in order', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - String? sharedText; - const channel = MethodChannel('com.stormg.lumadrama/share'); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - sharedText = (call.arguments as Map)['text'] as String; - return null; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); - - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); - await tester.pumpAndSettle(); - final keys = [ - const Key('rail-save'), - const Key('rail-comments'), - const Key('rail-like'), - const Key('rail-share'), - ]; - final tops = keys - .map((key) => tester.getTopLeft(find.byKey(key)).dy) - .toList(); - expect(tops, orderedEquals(tops.toList()..sort())); - - await tester.tap(find.byKey(keys[0])); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(keys[1])); - await tester.pumpAndSettle(); - expect(find.text('Only visible on this device'), findsOneWidget); - await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); - await tester.tap(find.byKey(const Key('comment-post'))); - await tester.pumpAndSettle(); - expect(find.text('Nice demo'), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(keys[2])); - await tester.tap(find.byKey(keys[3])); - await tester.pumpAndSettle(); - expect(sharedText, contains('The Signal')); - expect(tester.takeException(), isNull); - }); - testWidgets( - 'right swipe from leftmost category opens drawer and root stays', + 'should_save_comment_like_and_share_in_order_when_right_rail_is_used', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), + String? sharedText; + final interactions = FakeInteractionRepository(); + final comments = FakeCommentRepository(pages: [const CommentPage()]); + const channel = MethodChannel('com.stormg.lumadrama/share'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + sharedText = (call.arguments as Map)['text'] as String; + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), ); + + await tester.pumpWidget( + appUnderTest( + commentRepository: comments, + interactionRepository: interactions, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + final keys = [ + const Key('rail-like'), + const Key('rail-comments'), + const Key('rail-share'), + const Key('rail-save'), + ]; + final tops = keys + .map((key) => tester.getTopLeft(find.byKey(key)).dy) + .toList(); + expect(tops, orderedEquals(tops.toList()..sort())); + + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('rail-comments'))); + await tester.pumpAndSettle(); + expect(find.text('No comments yet.'), findsOneWidget); + await tester.enterText( + find.byKey(const Key('comment-input')), + 'Nice demo', + ); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + expect(find.text('Nice demo'), findsOneWidget); + await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - for (var i = 0; i < 3; i++) { - await tester.drag( - find.byKey(const Key('home-category-swipe')), - const Offset(200, 0), - ); - await tester.pumpAndSettle(); - } - expect(find.text('No dramas in this category yet.'), findsOneWidget); - await tester.drag( - find.byKey(const Key('home-empty-swipe')), - const Offset(200, 0), - ); + await tester.tap(find.byKey(const Key('rail-like'))); await tester.pumpAndSettle(); - expect(find.text('Quick access'), findsOneWidget); - await tester.drag(find.byType(Drawer), const Offset(150, 0)); + expect(interactions.likeTargets, contains('DRAMA:the-signal-demo')); + await tester.tap(find.byKey(const Key('rail-share'))); await tester.pumpAndSettle(); - expect(find.text('Quick access'), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + expect(sharedText, contains('The Signal')); + expect(sharedText, contains('A demo series for vertical playback')); + expect(sharedText, contains('FruitShorts')); + expect(tester.takeException(), isNull); }, ); - testWidgets('system back steps categories then opens drawer without exit', ( + testWidgets('should_open_auth_gate_without_liking_when_signed_out', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); + seedSignedOutSession(); + final interactions = FakeInteractionRepository(); + + await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('rail-like'))); await tester.pumpAndSettle(); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('No dramas in this category yet.'), findsOneWidget); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.text('Quick access'), findsOneWidget); - expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + expect(find.text('Sign in to continue'), findsOneWidget); + expect(interactions.likeTargets, isEmpty); }); - testWidgets('My watchlist lists saved dramas across the home feed', ( + testWidgets('should_report_drama_from_detail_more_menu_when_submitted', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - const LumaDramaApp(playerFactory: createWidgetPlayer), - ); + final interactions = FakeInteractionRepository(); + + await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Report drama')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('report-submit'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(0, -550)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('Watchlist')); - await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); - await tester.tap(find.text('The Signal')); - await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); + expect(interactions.reports.single.targetType, InteractionTargetType.drama); + expect(find.text('Report submitted. Thank you.'), findsOneWidget); }); + + testWidgets( + 'should_open_drawer_and_stay_on_home_when_system_back_is_pressed', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Quick access'), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Quick access'), findsNothing); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect( + Navigator.of( + tester.element(find.byKey(const Key('home-tabs-bar'))), + ).canPop(), + isFalse, + ); + }, + ); + + testWidgets( + 'should_list_saved_dramas_across_home_feed_when_watchlist_is_opened', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + // 收藏要求登录态:沿用本文件已有的"假登录"初始化 + seedSignedInSession(); + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); + await tester.pumpAndSettle(); + expect(find.text('The Signal'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + await tester.tap(find.text('The Signal')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + }, + ); }