Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e867561d | ||
|
|
64fa3b1efe | ||
|
|
faaf2f7283 | ||
|
|
070ec78668 | ||
|
|
88a401512d | ||
|
|
a7ac3b7fc7 | ||
|
|
015677d1a8 |
No files matched your search
@@ -45,13 +45,7 @@ app.*.map.json
|
|||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
# Local demo media and internal planning are absent from the public repo.
|
# Local demo media and internal planning are absent from the public repo.
|
||||||
# Launcher/splash source icons are required at build time, so keep them tracked.
|
/assets/
|
||||||
/assets/*
|
|
||||||
!/assets/icon/
|
|
||||||
!/assets/icon/*.png
|
|
||||||
!/assets/fonts/
|
|
||||||
!/assets/fonts/*.ttf
|
|
||||||
!/assets/fonts/OFL-*.txt
|
|
||||||
/server/
|
/server/
|
||||||
/docs/
|
/docs/
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
|
|||||||
@@ -1,155 +1,17 @@
|
|||||||
# FruitShorts(ReelShort)客户端
|
一个使用 Flutter 开发的短剧播放 App 原型,面向海外用户,重点探索沉浸式刷剧、找剧和连续观看的移动端体验
|
||||||
|
|
||||||
FruitShorts 是面向印尼市场的短剧 Flutter 客户端。当前版本已连接
|
## 主要功能
|
||||||
`FruitServer` 的 `/app/**` 接口,支持沉浸播放、找剧、账号、观看记录、收藏、
|
|
||||||
下载授权、评论互动、站内通知和中英印尼三语界面。
|
|
||||||
|
|
||||||
## 技术栈
|
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
|
||||||
|
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
|
||||||
|
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
|
||||||
|
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
|
||||||
|
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
|
||||||
|
|
||||||
- Flutter 3.47.7 / Dart 3.13.5
|
## 技术实现
|
||||||
- Provider 状态管理与依赖注入
|
|
||||||
- dio 网络请求
|
|
||||||
- video_player 播放
|
|
||||||
- shared_preferences 与 `SharedPreferencesAsync` 本地配置
|
|
||||||
- flutter_localizations,支持 `en`、`zh`、`id`
|
|
||||||
- Android 系统画中画
|
|
||||||
|
|
||||||
## 目录结构
|
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
|
||||||
|
|
||||||
```text
|
## 当前说明
|
||||||
lib/
|
|
||||||
core/
|
|
||||||
config/ 地址、语言和运行时配置
|
|
||||||
data/ models、remote API、repository、controller
|
|
||||||
formatting/ 文案与日期格式化
|
|
||||||
platform/ 触觉等平台能力
|
|
||||||
theme/ 颜色与主题
|
|
||||||
widgets/ 跨页面复用组件
|
|
||||||
l10n/ 三语 ARB 与生成代码
|
|
||||||
pages/ 业务页面
|
|
||||||
test/ 单元、组件、页面和联调测试
|
|
||||||
integration_test/ 设备侧集成测试
|
|
||||||
android/ Android 工程
|
|
||||||
ios/ iOS 工程与 SPM 配置
|
|
||||||
assets/ 图标、图片和本地媒体文件
|
|
||||||
```
|
|
||||||
|
|
||||||
## 已接接口
|
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
|
||||||
|
|
||||||
以下接口均在 `lib/core/data/remote/` 中有实际调用,共 26 个不同路径、
|
|
||||||
31 个 HTTP 方法级调用。
|
|
||||||
|
|
||||||
| 分组 | 契约 | 接口 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 账号 | §1.1 | `POST /app/auth/register`、`/login`、`/refresh`、`/logout` |
|
|
||||||
| 内容 | §1.3、§1.4 | `GET /app/home/feed`、`/app/discover/feed`、`/app/categories`、`/app/dramas/{id}`、`/app/episodes/{id}/play` |
|
|
||||||
| 观看记录 | §1.5 | `POST/GET /app/watch/progress`、`GET /app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress` |
|
|
||||||
| 收藏 | §1.5 | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` |
|
|
||||||
| 下载授权 | §1.6 | `GET /app/downloads/policy`、`PUT /app/downloads/settings`、`POST/GET /app/downloads`、`GET /app/downloads/{episodeId}/url`、`POST /app/downloads/{episodeId}/progress`、`DELETE /app/downloads/{episodeId}`、`DELETE /app/downloads` |
|
|
||||||
| 互动 | §1.7 | `GET/POST /app/comments`、`DELETE /app/comments/{id}`、`POST /app/likes/toggle`、`POST /app/reports` |
|
|
||||||
| 公告 | §1.9.2 | `GET /app/notices` |
|
|
||||||
|
|
||||||
客户端没有调用 `/app/config` 或创作者入驻接口。
|
|
||||||
|
|
||||||
## 运行
|
|
||||||
|
|
||||||
### 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
flutter pub get
|
|
||||||
```
|
|
||||||
|
|
||||||
### Android USB
|
|
||||||
|
|
||||||
手机开启 USB 调试并连接电脑后:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
adb devices
|
|
||||||
adb reverse tcp:8081 tcp:8081
|
|
||||||
flutter run -d <device> \
|
|
||||||
--dart-define=API_BASE_URL=http://127.0.0.1:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
`adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要
|
|
||||||
重新执行。
|
|
||||||
|
|
||||||
Android 模拟器访问宿主机时使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
### iOS
|
|
||||||
|
|
||||||
手机与 Mac 需在同一网络,首次启动允许“本地网络”权限:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
flutter run -d <udid> \
|
|
||||||
--dart-define=API_BASE_URL=http://<Mac局域网IP>:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug 运行时地址
|
|
||||||
|
|
||||||
debug 版本可在「我的 → 设置 → 服务器地址」修改后端地址,保存后重启 App 生效。
|
|
||||||
地址优先级:
|
|
||||||
|
|
||||||
```text
|
|
||||||
本地运行时设置 > --dart-define(API_BASE_URL) > 平台默认地址
|
|
||||||
```
|
|
||||||
|
|
||||||
因此不带 `--dart-define` 构建的 debug APK 也可在安装后改成当前电脑 IP。
|
|
||||||
release 版本不显示入口,也不会读取本地覆盖。
|
|
||||||
|
|
||||||
## 环境注意
|
|
||||||
|
|
||||||
### iOS Swift 版本
|
|
||||||
|
|
||||||
`pubspec.yaml` 将 `shared_preferences_foundation` 固定为 `2.5.6`。该版本兼容
|
|
||||||
Xcode 16.2 / Swift 6.0.3;`2.6.0` 的生成代码使用 Swift 6.1 才支持的 trailing
|
|
||||||
comma。不要手工升级该依赖,`flutter pub get` 后应显示
|
|
||||||
`shared_preferences_foundation 2.5.6 (overridden)`。
|
|
||||||
|
|
||||||
### iOS SPM 包缺失
|
|
||||||
|
|
||||||
如果 Xcode 报 `Missing package product FlutterGeneratedPluginSwiftPackage`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm -rf ~/Library/Developer/Xcode/DerivedData/Runner-*
|
|
||||||
flutter clean
|
|
||||||
flutter pub get
|
|
||||||
xcodebuild -resolvePackageDependencies \
|
|
||||||
-project ios/Runner.xcodeproj \
|
|
||||||
-scheme Runner
|
|
||||||
```
|
|
||||||
|
|
||||||
随后重新打开工程构建。`Runner.xcscheme` 的 PreAction 会先执行
|
|
||||||
`flutter pub get`,再执行 Flutter prepare。
|
|
||||||
|
|
||||||
### Android libdartjni
|
|
||||||
|
|
||||||
若启动时报 `libdartjni.so` 加载失败,通常是旧安装中的原生库残留:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
adb uninstall com.stormg.lumadrama
|
|
||||||
flutter run -d <device> \
|
|
||||||
--dart-define=API_BASE_URL=http://127.0.0.1:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
## 门禁
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dart analyze lib test
|
|
||||||
flutter test
|
|
||||||
flutter build apk --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
测试文件按模块放在 `test/` 下,用例名使用
|
|
||||||
`should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。
|
|
||||||
|
|
||||||
## 当前边界
|
|
||||||
|
|
||||||
- 下载仅管理服务端授权,不落盘、不续传、不支持离线播放。
|
|
||||||
- 没有独立搜索接口,搜索能力尚未形成完整闭环。
|
|
||||||
- 站内通知没有已读/未读和红点状态。
|
|
||||||
- 支付、金币和会员权益尚未形成客户端闭环。
|
|
||||||
- Android 支持系统画中画;iOS 画中画尚未接入。
|
|
||||||
@@ -32,6 +32,9 @@ android {
|
|||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
// 反编译保护(法律红线配套):开启 R8 混淆 + 资源压缩,发布前替换正式签名。
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,4 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
|
||||||
<application android:usesCleartextTraffic="true"/>
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
<!-- Author: fruit.com.cn -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
<application
|
<application
|
||||||
android:label="FruitShorts"
|
android:label="Nontonki"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher">
|
||||||
android:usesCleartextTraffic="true">
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Author: fruit.com.cn -->
|
|
||||||
<!-- Android 12 already shows the icon in the system splash. This layer stays
|
|
||||||
the same color so the icon is not drawn a second time. -->
|
|
||||||
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item android:drawable="@color/splash_background" />
|
|
||||||
</layer-list>
|
|
||||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -1,4 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground>
|
<foreground>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 772 B After Width: | Height: | Size: 813 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -15,6 +15,6 @@
|
|||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">@color/splash_background</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
<!-- Author: fruit.com.cn -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
<resources>
|
<resources>
|
||||||
<color name="splash_background">#FF100C0A</color>
|
<color name="splash_background">#FF000000</color>
|
||||||
<color name="ic_launcher_background">#C4322A</color>
|
<color name="ic_launcher_background">#D74A7D</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -15,6 +15,6 @@
|
|||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">@color/splash_background</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -6,6 +6,4 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
|
||||||
<application android:usesCleartextTraffic="true"/>
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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.
|
|
||||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1,99 +0,0 @@
|
|||||||
# 客户端文件说明
|
|
||||||
|
|
||||||
## 目录树
|
|
||||||
|
|
||||||
```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 包管理工具维护,应提交但不手工编辑。
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 客户端任务清单
|
|
||||||
|
|
||||||
状态按当前 `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` 代码和自动化测试为准。
|
|
||||||
- “需核对”表示产品范围、接口或外部凭据尚未确认,不能按已完成处理。
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:fruitshorts/app.dart';
|
|
||||||
import 'package:fruitshorts/core/config/app_config.dart';
|
|
||||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
|
||||||
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
|
|
||||||
import 'package:integration_test/integration_test.dart';
|
|
||||||
|
|
||||||
/// 设备侧集成测试:**在手机进程里**真实请求开发机上的 app-api。
|
|
||||||
///
|
|
||||||
/// 跑法(红线第 10 条要求的"设备侧取证"):
|
|
||||||
/// ```
|
|
||||||
/// IP=$(ipconfig getifaddr en0)
|
|
||||||
/// flutter test integration_test -d <device-id> --dart-define=API_BASE_URL=http://$IP:8081
|
|
||||||
/// ```
|
|
||||||
/// 断言失败时会把实际使用的地址一起打出来,便于区分"地址不对"还是"设备连不上"。
|
|
||||||
void main() {
|
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
final baseUrl = AppConfig.apiBaseUrl;
|
|
||||||
|
|
||||||
testWidgets('should_fetch_home_feed_and_drama_detail_when_device_reaches_backend', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
debugPrint('[device] API_BASE_URL=$baseUrl');
|
|
||||||
final client = ApiClient(baseUrl: baseUrl);
|
|
||||||
addTearDown(client.close);
|
|
||||||
final api = CatalogApi(client);
|
|
||||||
|
|
||||||
final feed = await api.homeFeed();
|
|
||||||
expect(
|
|
||||||
feed.dramas,
|
|
||||||
isNotEmpty,
|
|
||||||
reason: '设备侧拿不到首页列表(API_BASE_URL=$baseUrl)',
|
|
||||||
);
|
|
||||||
debugPrint('[device] 首页分区=${feed.sections.length} 剧目=${feed.dramas.length}');
|
|
||||||
|
|
||||||
final card = feed.dramas.first;
|
|
||||||
expect(card.id, isNotEmpty, reason: '设备侧首页卡片 id 为空');
|
|
||||||
|
|
||||||
final detail = await api.dramaDetail(card.id);
|
|
||||||
expect(detail.id, card.id, reason: '详情返回的 id 与卡片不一致');
|
|
||||||
expect(
|
|
||||||
detail.episodes,
|
|
||||||
isNotEmpty,
|
|
||||||
reason: '设备侧拿到的详情没有剧集(dramaId=${card.id})',
|
|
||||||
);
|
|
||||||
debugPrint('[device] 详情 id=${detail.id} 剧集=${detail.episodes.length}');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should_render_home_drama_list_when_app_boots_on_device', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
// 用真 App(真网络、真目录控制器):首页底部标签栏出现即代表"剧目列表加载成功",
|
|
||||||
// 也就是用户在真机上遇到的那个「剧目列表加载失败」已经消失。
|
|
||||||
await tester.pumpWidget(const LumaDramaApp());
|
|
||||||
|
|
||||||
var loaded = false;
|
|
||||||
for (var attempt = 0; attempt < 60 && !loaded; attempt++) {
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
loaded = find.byKey(const Key('home-tabs-bar')).evaluate().isNotEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(
|
|
||||||
loaded,
|
|
||||||
isTrue,
|
|
||||||
reason: '首页 30 秒内没渲染出剧目列表(API_BASE_URL=$baseUrl)',
|
|
||||||
);
|
|
||||||
debugPrint('[device] 首页剧目列表已渲染:API_BASE_URL=$baseUrl');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Uncomment this line to define a global platform for your project
|
||||||
|
# platform :ios, '13.0'
|
||||||
|
|
||||||
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|
||||||
|
project 'Runner', {
|
||||||
|
'Debug' => :debug,
|
||||||
|
'Profile' => :release,
|
||||||
|
'Release' => :release,
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutter_root
|
||||||
|
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||||
|
unless File.exist?(generated_xcode_build_settings_path)
|
||||||
|
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||||
|
end
|
||||||
|
|
||||||
|
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||||
|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||||
|
return matches[1].strip if matches
|
||||||
|
end
|
||||||
|
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||||
|
end
|
||||||
|
|
||||||
|
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||||
|
|
||||||
|
flutter_ios_podfile_setup
|
||||||
|
|
||||||
|
target 'Runner' do
|
||||||
|
use_frameworks!
|
||||||
|
|
||||||
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
|
target 'RunnerTests' do
|
||||||
|
inherit! :search_paths
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
flutter_additional_ios_build_settings(target)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -487,7 +487,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -538,7 +538,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|||||||
@@ -6,27 +6,11 @@
|
|||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
<PreActions>
|
<PreActions>
|
||||||
<ExecutionAction
|
|
||||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
|
||||||
<ActionContent
|
|
||||||
title = "Bootstrap Flutter SPM Package"
|
|
||||||
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 if [ -z "${SRCROOT:-}" ]; then echo "error: SRCROOT is not set; cannot locate the Flutter project." >&2 exit 1 fi cd "$SRCROOT/.." "$FLUTTER" pub get ">
|
|
||||||
<EnvironmentBuildable>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
|
||||||
BuildableName = "Runner.app"
|
|
||||||
BlueprintName = "Runner"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</EnvironmentBuildable>
|
|
||||||
</ActionContent>
|
|
||||||
</ExecutionAction>
|
|
||||||
<ExecutionAction
|
<ExecutionAction
|
||||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
<ActionContent
|
<ActionContent
|
||||||
title = "Run Prepare Flutter Framework Script"
|
title = "Run Prepare Flutter Framework Script"
|
||||||
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 ">
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
<EnvironmentBuildable>
|
<EnvironmentBuildable>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ final class RunnerFlutterViewController: FlutterViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = UIColor(red: 16.0 / 255.0, green: 12.0 / 255.0, blue: 10.0 / 255.0, alpha: 1)
|
view.backgroundColor = .black
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 340 B |
|
Before Width: | Height: | Size: 594 B After Width: | Height: | Size: 644 B |
|
Before Width: | Height: | Size: 939 B After Width: | Height: | Size: 1001 B |
|
Before Width: | Height: | Size: 436 B After Width: | Height: | Size: 499 B |
|
Before Width: | Height: | Size: 902 B After Width: | Height: | Size: 958 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 594 B After Width: | Height: | Size: 644 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 742 B After Width: | Height: | Size: 822 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 867 B After Width: | Height: | Size: 889 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -15,10 +15,12 @@
|
|||||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="0.06274509803921569" green="0.047058823529411764" blue="0.0392156862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="0" green="0" blue="0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="cx-launch"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="top-launch"/>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="cy-launch"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="bot-launch"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="lead-launch"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="trailing" secondItem="Ze5-6b-2t3" secondAttribute="trailing" id="trail-launch"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
@@ -28,6 +30,6 @@
|
|||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="84" height="84"/>
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>FruitShorts</string>
|
<string>Nontonki</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>FruitShorts</string>
|
<string>Nontonki</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -26,13 +26,6 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAllowsLocalNetworking</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
|
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -1,276 +1,96 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'core/config/app_locale.dart';
|
import 'core/config/locale_controller.dart';
|
||||||
import 'core/data/remote/account_api.dart';
|
import 'core/data/repositories/auth_repository.dart';
|
||||||
import 'core/data/remote/api_client.dart';
|
|
||||||
import 'core/data/remote/auth_api.dart';
|
|
||||||
import 'core/data/remote/catalog_api.dart';
|
|
||||||
import 'core/data/remote/category_api.dart';
|
|
||||||
import 'core/data/remote/comment_api.dart';
|
|
||||||
import 'core/data/remote/commerce_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/catalog_repository.dart';
|
||||||
import 'core/data/repositories/account_repository.dart';
|
import 'core/data/repositories/engagement_repository.dart';
|
||||||
import 'core/data/repositories/category_repository.dart';
|
import 'core/data/repositories/http_catalog_repository.dart';
|
||||||
import 'core/data/repositories/comment_repository.dart';
|
import 'core/data/services/api_client.dart';
|
||||||
import 'core/data/repositories/commerce_repository.dart';
|
import 'core/data/services/app_catalog_controller.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/search_repository.dart';
|
|
||||||
import 'core/data/repositories/store_repository.dart';
|
|
||||||
import 'core/data/repositories/watch_history_repository.dart';
|
|
||||||
import 'core/data/services/auth_controller.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/catalog_controller.dart';
|
||||||
import 'core/data/services/category_controller.dart';
|
import 'core/data/services/engagement_controller.dart';
|
||||||
import 'core/data/services/download_controller.dart';
|
import 'core/data/services/event_tracker.dart';
|
||||||
import 'core/data/services/favorite_controller.dart';
|
|
||||||
import 'core/data/services/interaction_controller.dart';
|
|
||||||
import 'core/data/services/locale_controller.dart';
|
|
||||||
import 'core/data/services/playback_session.dart';
|
import 'core/data/services/playback_session.dart';
|
||||||
|
import 'core/data/services/token_storage.dart';
|
||||||
import 'core/data/services/video_player_port.dart';
|
import 'core/data/services/video_player_port.dart';
|
||||||
import 'core/data/services/wallet_controller.dart';
|
|
||||||
import 'core/platform/haptics.dart';
|
|
||||||
import 'core/theme/app_theme.dart';
|
|
||||||
import 'core/theme/app_colors.dart';
|
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'pages/home/home_view.dart';
|
import 'pages/login/login_view.dart';
|
||||||
|
|
||||||
class LumaDramaApp extends StatefulWidget {
|
class LumaDramaApp extends StatelessWidget {
|
||||||
const LumaDramaApp({
|
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
|
||||||
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.commerceRepository,
|
|
||||||
this.storeRepository,
|
|
||||||
this.searchRepository,
|
|
||||||
this.haptics = const FlutterHaptics(),
|
|
||||||
});
|
|
||||||
|
|
||||||
final PlayerFactory playerFactory;
|
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 CommerceRepository? commerceRepository;
|
|
||||||
final StoreRepository? storeRepository;
|
|
||||||
final SearchRepository? searchRepository;
|
|
||||||
final Haptics haptics;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LumaDramaApp> createState() => _LumaDramaAppState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LumaDramaAppState extends State<LumaDramaApp> {
|
|
||||||
late final AuthSessionStore _sessionStore = AuthSessionStore();
|
|
||||||
|
|
||||||
/// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求
|
|
||||||
/// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。
|
|
||||||
late final ApiClient _apiClient = ApiClient(
|
|
||||||
accessTokenReader: () => _auth.accessToken,
|
|
||||||
tokenRefresher: () => _auth.refresh(),
|
|
||||||
onUnauthorized: () => _auth.clear(),
|
|
||||||
deviceIdReader: _sessionStore.deviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
late final AuthController _auth = AuthController(
|
|
||||||
AuthApi(_apiClient),
|
|
||||||
_sessionStore,
|
|
||||||
);
|
|
||||||
|
|
||||||
late final CatalogRepository _catalogRepository =
|
|
||||||
widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient));
|
|
||||||
|
|
||||||
late final CategoryRepository _categoryRepository =
|
|
||||||
widget.categoryRepository ??
|
|
||||||
HttpCategoryRepository(CategoryApi(_apiClient), _catalogRepository);
|
|
||||||
|
|
||||||
late final WatchHistoryRepository _watchHistory =
|
|
||||||
widget.watchHistoryRepository ??
|
|
||||||
HttpWatchHistoryRepository(
|
|
||||||
WatchApi(_apiClient),
|
|
||||||
isSignedIn: () => _auth.isSignedIn,
|
|
||||||
);
|
|
||||||
|
|
||||||
late final FavoriteRepository _favorites =
|
|
||||||
widget.favoriteRepository ??
|
|
||||||
HttpFavoriteRepository(
|
|
||||||
LibraryApi(_apiClient),
|
|
||||||
isSignedIn: () => _auth.isSignedIn,
|
|
||||||
);
|
|
||||||
|
|
||||||
late final PlaybackRepository _playback =
|
|
||||||
widget.playbackRepository ??
|
|
||||||
HttpPlaybackRepository(PlaybackApi(_apiClient));
|
|
||||||
|
|
||||||
late final FavoriteController _favoriteController = FavoriteController(
|
|
||||||
_favorites,
|
|
||||||
);
|
|
||||||
|
|
||||||
late final DownloadRepository _downloadRepository =
|
|
||||||
widget.downloadRepository ??
|
|
||||||
HttpDownloadRepository(DownloadApi(_apiClient));
|
|
||||||
|
|
||||||
late final DownloadController _downloadController = DownloadController(
|
|
||||||
_downloadRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
late final CommerceRepository _commerceRepository =
|
|
||||||
widget.commerceRepository ??
|
|
||||||
HttpCommerceRepository(CommerceApi(_apiClient));
|
|
||||||
|
|
||||||
late final WalletController _walletController = WalletController(
|
|
||||||
_commerceRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
late final SearchRepository _searchRepository =
|
|
||||||
widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient));
|
|
||||||
|
|
||||||
late final StoreRepository _storeRepository =
|
|
||||||
widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient));
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
late final LocaleController _localeController = LocaleController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
|
|
||||||
_auth.load();
|
|
||||||
unawaited(_localeController.load());
|
|
||||||
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
|
|
||||||
_auth.addListener(_onAuthChanged);
|
|
||||||
if (_auth.isSignedIn) unawaited(_favoriteController.load());
|
|
||||||
if (_auth.isSignedIn) unawaited(_walletController.load());
|
|
||||||
unawaited(_downloadController.restore());
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _wasSignedIn = false;
|
|
||||||
|
|
||||||
void _onAuthChanged() {
|
|
||||||
if (_auth.isSignedIn == _wasSignedIn) return;
|
|
||||||
_wasSignedIn = _auth.isSignedIn;
|
|
||||||
_interactionController.clear();
|
|
||||||
if (_auth.isSignedIn) {
|
|
||||||
unawaited(_favoriteController.load());
|
|
||||||
unawaited(_walletController.load());
|
|
||||||
} else {
|
|
||||||
_favoriteController.clear();
|
|
||||||
_downloadController.reset();
|
|
||||||
_walletController.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_auth.removeListener(_onAuthChanged);
|
|
||||||
_favoriteController.dispose();
|
|
||||||
_downloadController.dispose();
|
|
||||||
_walletController.dispose();
|
|
||||||
_interactionController.dispose();
|
|
||||||
_localeController.dispose();
|
|
||||||
_apiClient.close();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
Provider(create: (_) => PlaybackCoordinator()),
|
Provider(create: (_) => PlaybackCoordinator()),
|
||||||
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
|
Provider<PlayerFactory>(create: (_) => playerFactory),
|
||||||
Provider<CatalogRepository>.value(value: _catalogRepository),
|
Provider(create: (_) => ApiClient()),
|
||||||
Provider<PlaybackRepository>.value(value: _playback),
|
Provider(create: (ctx) => EventTracker(ctx.read<ApiClient>())),
|
||||||
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
Provider(create: (_) => TokenStorage()),
|
||||||
Provider<FavoriteRepository>.value(value: _favorites),
|
Provider(
|
||||||
Provider<CommentRepository>.value(value: _commentRepository),
|
create: (ctx) => AuthRepository(
|
||||||
Provider<InteractionRepository>.value(value: _interactionRepository),
|
ctx.read<ApiClient>(),
|
||||||
Provider<NoticeRepository>.value(value: _noticeRepository),
|
ctx.read<TokenStorage>(),
|
||||||
Provider<AccountRepository>.value(value: _accountRepository),
|
),
|
||||||
Provider<CommerceRepository>.value(value: _commerceRepository),
|
|
||||||
Provider<StoreRepository>.value(value: _storeRepository),
|
|
||||||
Provider<SearchRepository>.value(value: _searchRepository),
|
|
||||||
Provider<Haptics>.value(value: widget.haptics),
|
|
||||||
ChangeNotifierProvider(
|
|
||||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CategoryController(_categoryRepository)..load(),
|
create: (ctx) => AuthController(ctx.read<AuthRepository>()),
|
||||||
|
),
|
||||||
|
Provider(
|
||||||
|
create: (ctx) => HttpCatalogRepository(ctx.read<ApiClient>()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (ctx) => AppCatalogController(ctx.read<HttpCatalogRepository>()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) => CatalogController(DemoCatalogRepository())..load(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) =>
|
||||||
|
EngagementController(SharedPreferencesEngagementRepository())
|
||||||
|
..load(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider.value(value: _favoriteController),
|
|
||||||
ChangeNotifierProvider.value(value: _downloadController),
|
|
||||||
ChangeNotifierProvider.value(value: _walletController),
|
|
||||||
ChangeNotifierProvider.value(value: _interactionController),
|
|
||||||
ChangeNotifierProvider.value(value: _localeController),
|
|
||||||
ChangeNotifierProvider.value(value: _auth),
|
|
||||||
],
|
],
|
||||||
child: Consumer<LocaleController>(
|
child: Consumer<LocaleController>(
|
||||||
builder: (context, localeController, _) => MaterialApp(
|
builder: (context, locale, _) => MaterialApp(
|
||||||
locale: localeController.locale,
|
|
||||||
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
|
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
|
||||||
|
locale: locale.locale,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
localeResolutionCallback: (deviceLocale, _) =>
|
localeResolutionCallback: (deviceLocale, supported) {
|
||||||
resolveAppLocale(deviceLocale),
|
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;
|
||||||
|
},
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
themeMode: ThemeMode.light,
|
themeMode: ThemeMode.dark,
|
||||||
theme: AppTheme.light(),
|
theme: ThemeData(
|
||||||
darkTheme: AppTheme.light(),
|
useMaterial3: true,
|
||||||
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
value: SystemUiOverlayStyle.dark.copyWith(
|
seedColor: const Color(0xFFFF5B3D),
|
||||||
statusBarColor: Colors.transparent,
|
|
||||||
systemNavigationBarColor: AppColors.bar,
|
|
||||||
systemNavigationBarIconBrightness: Brightness.dark,
|
|
||||||
),
|
),
|
||||||
child: child ?? const SizedBox.shrink(),
|
scaffoldBackgroundColor: const Color(0xFF0B0C10),
|
||||||
),
|
),
|
||||||
home: const HomePage(),
|
darkTheme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: const Color(0xFFFF5B3D),
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF0B0C10),
|
||||||
|
),
|
||||||
|
home: const LoginPage(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
/// 接口地址与超时配置。
|
||||||
|
class ApiConfig {
|
||||||
|
ApiConfig._();
|
||||||
|
|
||||||
|
/// 客户端 app-api 地址,可用 --dart-define=API_BASE_URL=... 覆盖。
|
||||||
|
static const String baseUrl = String.fromEnvironment(
|
||||||
|
'API_BASE_URL',
|
||||||
|
defaultValue: 'http://127.0.0.1:8081',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const int connectTimeoutSeconds = 10;
|
||||||
|
static const int receiveTimeoutSeconds = 15;
|
||||||
|
}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// 客户端与后端约定的运行时配置。
|
|
||||||
///
|
|
||||||
/// 接口地址优先级:运行时设置 > `--dart-define=API_BASE_URL=...` > 平台默认值。
|
|
||||||
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。
|
|
||||||
class AppConfig {
|
|
||||||
const AppConfig._();
|
|
||||||
|
|
||||||
/// 真机默认使用的开发机局域网地址。
|
|
||||||
///
|
|
||||||
/// 这个地址跟着电脑所在的网络走,换网络后要用
|
|
||||||
/// `ipconfig getifaddr en0` 重新确认。默认值只改这一行。
|
|
||||||
static const String devHostBaseUrl = 'http://192.168.31.187:8081';
|
|
||||||
|
|
||||||
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
|
|
||||||
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
|
|
||||||
static const String lanBaseUrl = 'http://127.0.0.1:8081';
|
|
||||||
|
|
||||||
/// 公网地址(远程/移动网络访问时使用)。
|
|
||||||
static const String publicBaseUrl = '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<void> 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<void>();
|
|
||||||
client.close(force: true);
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
applyRuntimeBaseUrl(lanBaseUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// 内网不可达,回落到公网。
|
|
||||||
}
|
|
||||||
applyRuntimeBaseUrl(
|
|
||||||
compiledBaseUrl.isNotEmpty ? compiledBaseUrl : publicBaseUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 后端返回的是相对路径(/files/...),这里补上服务地址。
|
|
||||||
static String mediaUrl(String path) {
|
|
||||||
if (path.isEmpty) return '';
|
|
||||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
|
||||||
return '$apiBaseUrl$path';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
/// 将系统语言解析为受支持的语言;不支持时回退印尼语。
|
|
||||||
Locale resolveAppLocale(Locale? deviceLocale) {
|
|
||||||
return switch (deviceLocale?.languageCode) {
|
|
||||||
'zh' => const Locale('zh'),
|
|
||||||
'en' => const Locale('en'),
|
|
||||||
_ => const Locale('id'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class LocaleController extends ChangeNotifier {
|
||||||
|
LocaleController({SharedPreferencesAsync? preferences})
|
||||||
|
: _preferences = preferences ?? SharedPreferencesAsync();
|
||||||
|
|
||||||
|
final SharedPreferencesAsync _preferences;
|
||||||
|
Locale? _locale;
|
||||||
|
Locale? get locale => _locale;
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
try {
|
||||||
|
final code = await _preferences.getString('ui.locale');
|
||||||
|
_locale = switch (code) {
|
||||||
|
'en' => const Locale('en'),
|
||||||
|
'zh' => const Locale('zh'),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
notifyListeners();
|
||||||
|
} catch (_) {
|
||||||
|
// The system locale remains usable if preferences are unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> select(String code) async {
|
||||||
|
_locale = switch (code) {
|
||||||
|
'en' => const Locale('en'),
|
||||||
|
'zh' => const Locale('zh'),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
await _preferences.setString('ui.locale', code);
|
||||||
|
} catch (_) {
|
||||||
|
// Preference persistence is best effort for this prototype.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import 'app_config.dart';
|
|
||||||
|
|
||||||
/// Debug-only runtime server endpoint persisted outside build-time defines.
|
|
||||||
abstract final class ServerEndpoint {
|
|
||||||
/// Preference key used for the runtime server address.
|
|
||||||
static const String preferenceKey = 'dev.api_base_url';
|
|
||||||
|
|
||||||
/// Returns a normalized http(s) URL, or null for invalid input.
|
|
||||||
static String? normalize(String? raw) {
|
|
||||||
final value = raw?.trim() ?? '';
|
|
||||||
final uri = Uri.tryParse(value);
|
|
||||||
if (uri == null ||
|
|
||||||
(uri.scheme != 'http' && uri.scheme != 'https') ||
|
|
||||||
uri.host.isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value.replaceFirst(RegExp(r'/+$'), '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves runtime > dart-define > default using normalized valid values.
|
|
||||||
static String resolve({
|
|
||||||
required String? stored,
|
|
||||||
required String compiled,
|
|
||||||
required String fallback,
|
|
||||||
}) {
|
|
||||||
return normalize(stored) ??
|
|
||||||
normalize(compiled) ??
|
|
||||||
normalize(fallback) ??
|
|
||||||
fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads the persisted override before the app creates its ApiClient.
|
|
||||||
///
|
|
||||||
/// In release builds this returns immediately without reading preferences.
|
|
||||||
static Future<void> load({
|
|
||||||
bool debug = kDebugMode,
|
|
||||||
SharedPreferencesAsync? preferences,
|
|
||||||
}) async {
|
|
||||||
if (!debug) return;
|
|
||||||
try {
|
|
||||||
final prefs = preferences ?? SharedPreferencesAsync();
|
|
||||||
final stored = await prefs.getString(preferenceKey);
|
|
||||||
AppConfig.applyRuntimeBaseUrl(
|
|
||||||
resolve(
|
|
||||||
stored: stored,
|
|
||||||
compiled: AppConfig.compiledBaseUrl,
|
|
||||||
fallback: AppConfig.defaultBaseUrl,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
AppConfig.clearRuntimeBaseUrl();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persists a normalized runtime endpoint.
|
|
||||||
static Future<bool> save(
|
|
||||||
String raw, {
|
|
||||||
SharedPreferencesAsync? preferences,
|
|
||||||
}) async {
|
|
||||||
final normalized = normalize(raw);
|
|
||||||
if (normalized == null) return false;
|
|
||||||
try {
|
|
||||||
await (preferences ?? SharedPreferencesAsync()).setString(
|
|
||||||
preferenceKey,
|
|
||||||
normalized,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes the persisted runtime endpoint.
|
|
||||||
static Future<void> clear({SharedPreferencesAsync? preferences}) async {
|
|
||||||
try {
|
|
||||||
await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey);
|
|
||||||
} catch (_) {
|
|
||||||
// Clearing an optional developer override is best effort.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// 登录设备列表项;只保留页面展示需要的字段。
|
|
||||||
class AccountDevice {
|
|
||||||
const AccountDevice({
|
|
||||||
required this.id,
|
|
||||||
required this.deviceId,
|
|
||||||
required this.platform,
|
|
||||||
required this.deviceModel,
|
|
||||||
required this.status,
|
|
||||||
this.firstSeenAt,
|
|
||||||
this.lastSeenAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String deviceId;
|
|
||||||
final String platform;
|
|
||||||
final String deviceModel;
|
|
||||||
final String status;
|
|
||||||
final DateTime? firstSeenAt;
|
|
||||||
final DateTime? lastSeenAt;
|
|
||||||
|
|
||||||
/// 兼容 Long 字符串与数字;userId/deleted 不解析、不展示。
|
|
||||||
factory AccountDevice.fromJson(Map<String, dynamic> json) {
|
|
||||||
return AccountDevice(
|
|
||||||
id: jsonToId(json['id']),
|
|
||||||
deviceId: json['deviceId']?.toString() ?? '',
|
|
||||||
platform: json['platform']?.toString() ?? '',
|
|
||||||
deviceModel: json['deviceModel']?.toString() ?? '',
|
|
||||||
status: json['status']?.toString() ?? '',
|
|
||||||
firstSeenAt: _date(json['firstSeenAt']),
|
|
||||||
lastSeenAt: _date(json['lastSeenAt']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static DateTime? _date(dynamic value) =>
|
|
||||||
DateTime.tryParse(value?.toString() ?? '');
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'account_device.dart';
|
|
||||||
import 'account_profile.dart';
|
|
||||||
|
|
||||||
/// 导出中的登录凭据摘要。
|
|
||||||
class AccountCredential {
|
|
||||||
const AccountCredential({
|
|
||||||
required this.type,
|
|
||||||
required this.masked,
|
|
||||||
required this.verified,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String type;
|
|
||||||
final String masked;
|
|
||||||
final bool verified;
|
|
||||||
|
|
||||||
factory AccountCredential.fromJson(Map<String, dynamic> json) {
|
|
||||||
return AccountCredential(
|
|
||||||
type: json['type']?.toString() ?? '',
|
|
||||||
masked: json['masked']?.toString() ?? '',
|
|
||||||
verified: json['verified'] == true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 账号导出数据;`rawJson` 用于复制后端下发的完整内容。
|
|
||||||
class AccountExport {
|
|
||||||
const AccountExport({
|
|
||||||
required this.profile,
|
|
||||||
required this.credentials,
|
|
||||||
required this.consentCount,
|
|
||||||
required this.devices,
|
|
||||||
required this.raw,
|
|
||||||
});
|
|
||||||
|
|
||||||
final AccountProfile profile;
|
|
||||||
final List<AccountCredential> credentials;
|
|
||||||
final int consentCount;
|
|
||||||
final List<AccountDevice> devices;
|
|
||||||
final Map<String, dynamic> raw;
|
|
||||||
|
|
||||||
int get deviceCount => devices.length;
|
|
||||||
String get rawJson => const JsonEncoder.withIndent(' ').convert(raw);
|
|
||||||
|
|
||||||
factory AccountExport.fromJson(Map<String, dynamic> json) {
|
|
||||||
final profile = _map(json['profile']);
|
|
||||||
final credentials = _list(json['credentials']);
|
|
||||||
final consents = _list(json['consents']);
|
|
||||||
final devices = _list(json['devices']);
|
|
||||||
return AccountExport(
|
|
||||||
profile: AccountProfile.fromJson(profile),
|
|
||||||
credentials: [
|
|
||||||
for (final item in credentials) AccountCredential.fromJson(item),
|
|
||||||
],
|
|
||||||
consentCount: consents.length,
|
|
||||||
devices: [for (final item in devices) AccountDevice.fromJson(item)],
|
|
||||||
raw: json,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, dynamic> _map(dynamic value) =>
|
|
||||||
value is Map ? value.cast<String, dynamic>() : const {};
|
|
||||||
|
|
||||||
static List<Map<String, dynamic>> _list(dynamic value) {
|
|
||||||
if (value is! List) return const [];
|
|
||||||
return [
|
|
||||||
for (final item in value)
|
|
||||||
if (item is Map) item.cast<String, dynamic>(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'auth_session.dart';
|
|
||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// 当前账号资料;字段与 `GET/PUT /app/me` 的实际响应一致。
|
|
||||||
class AccountProfile {
|
|
||||||
const AccountProfile({
|
|
||||||
required this.uid,
|
|
||||||
required this.nickname,
|
|
||||||
required this.avatarUrl,
|
|
||||||
required this.gender,
|
|
||||||
required this.countryCode,
|
|
||||||
required this.regionCode,
|
|
||||||
required this.language,
|
|
||||||
required this.timezone,
|
|
||||||
required this.currency,
|
|
||||||
required this.status,
|
|
||||||
required this.maskedTarget,
|
|
||||||
required this.vipLevel,
|
|
||||||
this.createdAt,
|
|
||||||
this.lastLoginAt,
|
|
||||||
this.vipExpireAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String uid;
|
|
||||||
final String nickname;
|
|
||||||
final String avatarUrl;
|
|
||||||
final String gender;
|
|
||||||
final String countryCode;
|
|
||||||
final String regionCode;
|
|
||||||
final String language;
|
|
||||||
final String timezone;
|
|
||||||
final String currency;
|
|
||||||
final String status;
|
|
||||||
final String maskedTarget;
|
|
||||||
final int vipLevel;
|
|
||||||
final DateTime? createdAt;
|
|
||||||
final DateTime? lastLoginAt;
|
|
||||||
final DateTime? vipExpireAt;
|
|
||||||
|
|
||||||
/// 解析资料响应;缺失字段按空值处理。
|
|
||||||
factory AccountProfile.fromJson(Map<String, dynamic> json) {
|
|
||||||
return AccountProfile(
|
|
||||||
uid: jsonToId(json['uid']),
|
|
||||||
nickname: json['nickname']?.toString() ?? '',
|
|
||||||
avatarUrl: json['avatarUrl']?.toString() ?? '',
|
|
||||||
gender: json['gender']?.toString() ?? '',
|
|
||||||
countryCode: json['countryCode']?.toString() ?? '',
|
|
||||||
regionCode: json['regionCode']?.toString() ?? '',
|
|
||||||
language: json['language']?.toString() ?? '',
|
|
||||||
timezone: json['timezone']?.toString() ?? '',
|
|
||||||
currency: json['currency']?.toString() ?? '',
|
|
||||||
status: json['status']?.toString() ?? '',
|
|
||||||
maskedTarget: json['maskedTarget']?.toString() ?? '',
|
|
||||||
vipLevel: jsonToInt(json['vipLevel']),
|
|
||||||
createdAt: _date(json['createdAt']),
|
|
||||||
lastLoginAt: _date(json['lastLoginAt']),
|
|
||||||
vipExpireAt: _date(json['vipExpireAt']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static DateTime? _date(dynamic value) =>
|
|
||||||
DateTime.tryParse(value?.toString() ?? '');
|
|
||||||
|
|
||||||
/// 转为本地登录态摘要;只同步登录态实际使用的三项。
|
|
||||||
AuthUser toAuthUser() =>
|
|
||||||
AuthUser(uid: uid, nickname: nickname, avatarUrl: avatarUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 资料更新请求;null 字段不会进入请求体。
|
|
||||||
class AccountProfileUpdate {
|
|
||||||
const AccountProfileUpdate({
|
|
||||||
this.nickname,
|
|
||||||
this.avatarUrl,
|
|
||||||
this.gender,
|
|
||||||
this.birthday,
|
|
||||||
this.language,
|
|
||||||
this.timezone,
|
|
||||||
this.regionCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String? nickname;
|
|
||||||
final String? avatarUrl;
|
|
||||||
final String? gender;
|
|
||||||
final String? birthday;
|
|
||||||
final String? language;
|
|
||||||
final String? timezone;
|
|
||||||
final String? regionCode;
|
|
||||||
|
|
||||||
/// 只发送本次有值的字段。
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
if (nickname != null) 'nickname': nickname,
|
|
||||||
if (avatarUrl != null) 'avatarUrl': avatarUrl,
|
|
||||||
if (gender != null) 'gender': gender,
|
|
||||||
if (birthday != null) 'birthday': birthday,
|
|
||||||
if (language != null) 'language': language,
|
|
||||||
if (timezone != null) 'timezone': timezone,
|
|
||||||
if (regionCode != null) 'regionCode': regionCode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
// 与后端 /app/** 内容接口对齐的数据模型。
|
||||||
|
|
||||||
|
class AppEpisode {
|
||||||
|
AppEpisode({
|
||||||
|
required this.id,
|
||||||
|
required this.number,
|
||||||
|
required this.title,
|
||||||
|
required this.durationSec,
|
||||||
|
required this.locked,
|
||||||
|
required this.unlockCoin,
|
||||||
|
this.playUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final int number;
|
||||||
|
final String title;
|
||||||
|
final int durationSec;
|
||||||
|
final bool locked;
|
||||||
|
final int unlockCoin;
|
||||||
|
final String? playUrl;
|
||||||
|
|
||||||
|
factory AppEpisode.fromJson(Map<String, dynamic> json) => AppEpisode(
|
||||||
|
id: json['id'] as int,
|
||||||
|
number: json['number'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
durationSec: json['durationSec'] as int? ?? 0,
|
||||||
|
locked: json['locked'] as bool? ?? false,
|
||||||
|
unlockCoin: json['unlockCoin'] as int? ?? 0,
|
||||||
|
playUrl: json['playUrl'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppDramaCard {
|
||||||
|
AppDramaCard({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.cover,
|
||||||
|
required this.synopsis,
|
||||||
|
required this.genre,
|
||||||
|
required this.category,
|
||||||
|
required this.totalEpisodes,
|
||||||
|
required this.isHot,
|
||||||
|
required this.viewCount,
|
||||||
|
this.preview,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String cover;
|
||||||
|
final String synopsis;
|
||||||
|
final String genre;
|
||||||
|
final String category;
|
||||||
|
final int totalEpisodes;
|
||||||
|
final bool isHot;
|
||||||
|
final int viewCount;
|
||||||
|
final AppEpisode? preview;
|
||||||
|
|
||||||
|
factory AppDramaCard.fromJson(Map<String, dynamic> json) => AppDramaCard(
|
||||||
|
id: json['id'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
cover: json['cover'] as String? ?? '',
|
||||||
|
synopsis: json['synopsis'] as String? ?? '',
|
||||||
|
genre: json['genre'] as String? ?? '',
|
||||||
|
category: json['category'] as String? ?? '',
|
||||||
|
totalEpisodes: json['totalEpisodes'] as int? ?? 0,
|
||||||
|
isHot: json['isHot'] as bool? ?? false,
|
||||||
|
viewCount: json['viewCount'] as int? ?? 0,
|
||||||
|
preview: json['preview'] == null
|
||||||
|
? null
|
||||||
|
: AppEpisode.fromJson(json['preview'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppDramaDetail {
|
||||||
|
AppDramaDetail({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.synopsis,
|
||||||
|
required this.cover,
|
||||||
|
required this.genre,
|
||||||
|
required this.category,
|
||||||
|
required this.totalEpisodes,
|
||||||
|
required this.freeEpisodes,
|
||||||
|
required this.unlockCoin,
|
||||||
|
required this.isHot,
|
||||||
|
required this.viewCount,
|
||||||
|
required this.episodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String synopsis;
|
||||||
|
final String cover;
|
||||||
|
final String genre;
|
||||||
|
final String category;
|
||||||
|
final int totalEpisodes;
|
||||||
|
final int freeEpisodes;
|
||||||
|
final int unlockCoin;
|
||||||
|
final bool isHot;
|
||||||
|
final int viewCount;
|
||||||
|
final List<AppEpisode> episodes;
|
||||||
|
|
||||||
|
factory AppDramaDetail.fromJson(Map<String, dynamic> json) => AppDramaDetail(
|
||||||
|
id: json['id'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
synopsis: json['synopsis'] as String? ?? '',
|
||||||
|
cover: json['cover'] as String? ?? '',
|
||||||
|
genre: json['genre'] as String? ?? '',
|
||||||
|
category: json['category'] as String? ?? '',
|
||||||
|
totalEpisodes: json['totalEpisodes'] as int? ?? 0,
|
||||||
|
freeEpisodes: json['freeEpisodes'] as int? ?? 0,
|
||||||
|
unlockCoin: json['unlockCoin'] as int? ?? 0,
|
||||||
|
isHot: json['isHot'] as bool? ?? false,
|
||||||
|
viewCount: json['viewCount'] as int? ?? 0,
|
||||||
|
episodes: (json['episodes'] as List? ?? const [])
|
||||||
|
.map((e) => AppEpisode.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppBanner {
|
||||||
|
AppBanner({
|
||||||
|
required this.id,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.linkType,
|
||||||
|
required this.linkValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String imageUrl;
|
||||||
|
final String linkType;
|
||||||
|
final String linkValue;
|
||||||
|
|
||||||
|
factory AppBanner.fromJson(Map<String, dynamic> json) => AppBanner(
|
||||||
|
id: json['id'] as int,
|
||||||
|
imageUrl: json['imageUrl'] as String? ?? '',
|
||||||
|
linkType: json['linkType'] as String? ?? '',
|
||||||
|
linkValue: json['linkValue'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppCategory {
|
||||||
|
AppCategory({required this.id, required this.name});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
factory AppCategory.fromJson(Map<String, dynamic> json) =>
|
||||||
|
AppCategory(id: json['id'] as int, name: json['name'] as String? ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppSection {
|
||||||
|
AppSection({
|
||||||
|
required this.code,
|
||||||
|
required this.title,
|
||||||
|
required this.dramas,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String code;
|
||||||
|
final String title;
|
||||||
|
final List<AppDramaCard> dramas;
|
||||||
|
|
||||||
|
factory AppSection.fromJson(Map<String, dynamic> json) => AppSection(
|
||||||
|
code: json['code'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
dramas: (json['dramas'] as List? ?? const [])
|
||||||
|
.map((e) => AppDramaCard.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppFeed {
|
||||||
|
AppFeed({required this.banners, required this.sections});
|
||||||
|
|
||||||
|
final List<AppBanner> banners;
|
||||||
|
final List<AppSection> sections;
|
||||||
|
|
||||||
|
factory AppFeed.fromJson(Map<String, dynamic> json) => AppFeed(
|
||||||
|
banners: (json['banners'] as List? ?? const [])
|
||||||
|
.map((e) => AppBanner.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
sections: (json['sections'] as List? ?? const [])
|
||||||
|
.map((e) => AppSection.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// 登录态:服务端下发的令牌与用户摘要。
|
|
||||||
///
|
|
||||||
/// 只保留客户端真正要用的字段(uid / 昵称 / 头像),不做整份 AppUserRespVO 的映射 ——
|
|
||||||
/// 首页卡片、个人页需要的都在这里,避免为用不到的字段维护一份平行模型。
|
|
||||||
class AuthUser {
|
|
||||||
const AuthUser({required this.uid, required this.nickname, this.avatarUrl = ''});
|
|
||||||
|
|
||||||
final String uid;
|
|
||||||
final String nickname;
|
|
||||||
final String avatarUrl;
|
|
||||||
|
|
||||||
factory AuthUser.fromJson(Map<String, dynamic> json) => AuthUser(
|
|
||||||
uid: json['uid'] as String? ?? '',
|
|
||||||
nickname: json['nickname'] as String? ?? '',
|
|
||||||
avatarUrl: json['avatarUrl'] as String? ?? '',
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'uid': uid,
|
|
||||||
'nickname': nickname,
|
|
||||||
'avatarUrl': avatarUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthSession {
|
|
||||||
const AuthSession({
|
|
||||||
required this.accessToken,
|
|
||||||
required this.refreshToken,
|
|
||||||
this.expiresIn = 0,
|
|
||||||
required this.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String accessToken;
|
|
||||||
final String refreshToken;
|
|
||||||
final int expiresIn;
|
|
||||||
final AuthUser user;
|
|
||||||
|
|
||||||
factory AuthSession.fromJson(Map<String, dynamic> json) => AuthSession(
|
|
||||||
accessToken: json['accessToken'] as String? ?? '',
|
|
||||||
refreshToken: json['refreshToken'] as String? ?? '',
|
|
||||||
// expiresIn 后端是 long,按字符串下发(见 json_numbers.dart)
|
|
||||||
expiresIn: jsonToInt(json['expiresIn']),
|
|
||||||
user: AuthUser.fromJson((json['user'] as Map?)?.cast<String, dynamic>() ?? const {}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'accessToken': accessToken,
|
|
||||||
'refreshToken': refreshToken,
|
|
||||||
'expiresIn': expiresIn,
|
|
||||||
'user': user.toJson(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// 评论游标分页大小,契约 §1.7 使用游标而非页码。
|
|
||||||
const int commentPageSize = 20;
|
|
||||||
|
|
||||||
/// 评论列表页。
|
|
||||||
class CommentPage {
|
|
||||||
const CommentPage({
|
|
||||||
this.comments = const [],
|
|
||||||
this.nextCursor,
|
|
||||||
this.hasMore = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<DramaComment> comments;
|
|
||||||
final String? nextCursor;
|
|
||||||
final bool hasMore;
|
|
||||||
|
|
||||||
/// 解析后端 `CursorResult{list,nextCursor,hasMore}`。
|
|
||||||
factory CommentPage.fromJson(Map<String, dynamic> json) {
|
|
||||||
final rawList = json['list'];
|
|
||||||
return CommentPage(
|
|
||||||
comments: [
|
|
||||||
if (rawList is List)
|
|
||||||
for (final item in rawList)
|
|
||||||
if (item is Map<String, dynamic>) DramaComment.fromJson(item),
|
|
||||||
],
|
|
||||||
nextCursor: json['nextCursor'] == null
|
|
||||||
? null
|
|
||||||
: jsonToId(json['nextCursor']),
|
|
||||||
hasMore: json['hasMore'] == true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 服务端评论视图,字段对应 `CommentRespVO`。
|
|
||||||
class DramaComment {
|
|
||||||
const DramaComment({
|
|
||||||
required this.id,
|
|
||||||
required this.dramaId,
|
|
||||||
required this.episodeId,
|
|
||||||
required this.parentId,
|
|
||||||
required this.rootId,
|
|
||||||
required this.userId,
|
|
||||||
required this.uid,
|
|
||||||
required this.nickname,
|
|
||||||
required this.avatarUrl,
|
|
||||||
required this.content,
|
|
||||||
required this.status,
|
|
||||||
required this.auditReason,
|
|
||||||
required this.likeCount,
|
|
||||||
required this.replyCount,
|
|
||||||
required this.reportCount,
|
|
||||||
required this.createdAt,
|
|
||||||
required this.mine,
|
|
||||||
required this.liked,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String dramaId;
|
|
||||||
final String episodeId;
|
|
||||||
final String parentId;
|
|
||||||
final String rootId;
|
|
||||||
final String userId;
|
|
||||||
final String uid;
|
|
||||||
final String nickname;
|
|
||||||
final String avatarUrl;
|
|
||||||
final String content;
|
|
||||||
final String status;
|
|
||||||
final String auditReason;
|
|
||||||
final int likeCount;
|
|
||||||
final int replyCount;
|
|
||||||
final int reportCount;
|
|
||||||
final DateTime? createdAt;
|
|
||||||
final bool mine;
|
|
||||||
final bool liked;
|
|
||||||
|
|
||||||
/// 待人工审核的评论仅本人可见。
|
|
||||||
bool get isPending => status == 'pending';
|
|
||||||
|
|
||||||
/// 顶层评论没有父评论。
|
|
||||||
bool get isTopLevel => parentId.isEmpty || parentId == '0';
|
|
||||||
|
|
||||||
/// 解析 `CommentRespVO`,所有 ID 字段只走字符串解析。
|
|
||||||
factory DramaComment.fromJson(Map<String, dynamic> json) => DramaComment(
|
|
||||||
id: jsonToId(json['id']),
|
|
||||||
dramaId: jsonToId(json['dramaId']),
|
|
||||||
episodeId: jsonToId(json['episodeId'], fallback: '0'),
|
|
||||||
parentId: jsonToId(json['parentId'], fallback: '0'),
|
|
||||||
rootId: jsonToId(json['rootId'], fallback: '0'),
|
|
||||||
userId: jsonToId(json['userId']),
|
|
||||||
uid: json['uid']?.toString() ?? '',
|
|
||||||
nickname: json['nickname']?.toString() ?? '',
|
|
||||||
avatarUrl: json['avatarUrl']?.toString() ?? '',
|
|
||||||
content: json['content']?.toString() ?? '',
|
|
||||||
status: json['status']?.toString() ?? '',
|
|
||||||
auditReason: json['auditReason']?.toString() ?? '',
|
|
||||||
likeCount: jsonToInt(json['likeCount']),
|
|
||||||
replyCount: jsonToInt(json['replyCount']),
|
|
||||||
reportCount: jsonToInt(json['reportCount']),
|
|
||||||
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
|
|
||||||
mine: json['mine'] == true,
|
|
||||||
liked: json['liked'] == true,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// 局部更新点赞或回复数,列表中的其他字段保持不变。
|
|
||||||
DramaComment copyWith({
|
|
||||||
int? likeCount,
|
|
||||||
int? replyCount,
|
|
||||||
bool? liked,
|
|
||||||
}) => DramaComment(
|
|
||||||
id: id,
|
|
||||||
dramaId: dramaId,
|
|
||||||
episodeId: episodeId,
|
|
||||||
parentId: parentId,
|
|
||||||
rootId: rootId,
|
|
||||||
userId: userId,
|
|
||||||
uid: uid,
|
|
||||||
nickname: nickname,
|
|
||||||
avatarUrl: avatarUrl,
|
|
||||||
content: content,
|
|
||||||
status: status,
|
|
||||||
auditReason: auditReason,
|
|
||||||
likeCount: likeCount ?? this.likeCount,
|
|
||||||
replyCount: replyCount ?? this.replyCount,
|
|
||||||
reportCount: reportCount,
|
|
||||||
createdAt: createdAt,
|
|
||||||
mine: mine,
|
|
||||||
liked: liked ?? this.liked,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。
|
|
||||||
class ContentCategory {
|
|
||||||
const ContentCategory({required this.id, required this.name});
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
/// 解析 `GET /app/categories` 的单项。
|
|
||||||
factory ContentCategory.fromJson(Map<String, dynamic> json) {
|
|
||||||
return ContentCategory(
|
|
||||||
id: (json['id'] ?? '').toString(),
|
|
||||||
name: json['name'] as String? ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// 下载授权与任务进度合并视图,对应 `GET /app/downloads` 的列表项。
|
|
||||||
class DownloadLicense {
|
|
||||||
const DownloadLicense({
|
|
||||||
required this.licenseId,
|
|
||||||
required this.dramaId,
|
|
||||||
required this.dramaTitle,
|
|
||||||
required this.episodeId,
|
|
||||||
required this.episodeNo,
|
|
||||||
required this.deviceId,
|
|
||||||
required this.quality,
|
|
||||||
required this.playUrl,
|
|
||||||
required this.licenseKey,
|
|
||||||
required this.status,
|
|
||||||
required this.grantedAt,
|
|
||||||
required this.expireAt,
|
|
||||||
required this.taskStatus,
|
|
||||||
required this.progressPercent,
|
|
||||||
required this.downloadedBytes,
|
|
||||||
required this.totalBytes,
|
|
||||||
required this.errorMsg,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String licenseId;
|
|
||||||
final String dramaId;
|
|
||||||
final String dramaTitle;
|
|
||||||
final String episodeId;
|
|
||||||
final int episodeNo;
|
|
||||||
final String deviceId;
|
|
||||||
final String quality;
|
|
||||||
final String playUrl;
|
|
||||||
final String licenseKey;
|
|
||||||
final String status;
|
|
||||||
final DateTime? grantedAt;
|
|
||||||
final DateTime? expireAt;
|
|
||||||
final String taskStatus;
|
|
||||||
final int progressPercent;
|
|
||||||
final int downloadedBytes;
|
|
||||||
final int totalBytes;
|
|
||||||
final String errorMsg;
|
|
||||||
|
|
||||||
/// 解析授权响应;所有 ID 与 Long 字段兼容字符串下发。
|
|
||||||
factory DownloadLicense.fromJson(Map<String, dynamic> json) {
|
|
||||||
return DownloadLicense(
|
|
||||||
licenseId: (json['licenseId'] ?? '').toString(),
|
|
||||||
dramaId: (json['dramaId'] ?? '').toString(),
|
|
||||||
dramaTitle: json['dramaTitle'] as String? ?? '',
|
|
||||||
episodeId: (json['episodeId'] ?? '').toString(),
|
|
||||||
episodeNo: jsonToInt(json['episodeNo']),
|
|
||||||
deviceId: json['deviceId'] as String? ?? '',
|
|
||||||
quality: json['quality'] as String? ?? '',
|
|
||||||
playUrl: json['playUrl'] as String? ?? '',
|
|
||||||
licenseKey: json['licenseKey'] as String? ?? '',
|
|
||||||
status: json['status'] as String? ?? '',
|
|
||||||
grantedAt: DateTime.tryParse(json['grantedAt'] as String? ?? ''),
|
|
||||||
expireAt: DateTime.tryParse(json['expireAt'] as String? ?? ''),
|
|
||||||
taskStatus: json['taskStatus'] as String? ?? '',
|
|
||||||
progressPercent: jsonToInt(json['progressPercent']),
|
|
||||||
downloadedBytes: jsonToInt(json['downloadedBytes']),
|
|
||||||
totalBytes: jsonToInt(json['totalBytes']),
|
|
||||||
errorMsg: json['errorMsg'] as String? ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// 下载策略与当前设备配额,对应 `GET /app/downloads/policy`。
|
|
||||||
class DownloadPolicy {
|
|
||||||
const DownloadPolicy({
|
|
||||||
required this.maxPerDevice,
|
|
||||||
required this.licenseDays,
|
|
||||||
required this.wifiOnly,
|
|
||||||
required this.maxQuality,
|
|
||||||
required this.maxStorageBytes,
|
|
||||||
required this.used,
|
|
||||||
required this.remaining,
|
|
||||||
required this.expirePolicyText,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int maxPerDevice;
|
|
||||||
final int licenseDays;
|
|
||||||
final bool wifiOnly;
|
|
||||||
final String maxQuality;
|
|
||||||
final int maxStorageBytes;
|
|
||||||
final int used;
|
|
||||||
final int remaining;
|
|
||||||
final String expirePolicyText;
|
|
||||||
|
|
||||||
/// 设置更新期间用于本地乐观展示。
|
|
||||||
DownloadPolicy copyWith({bool? wifiOnly, String? maxQuality}) {
|
|
||||||
return DownloadPolicy(
|
|
||||||
maxPerDevice: maxPerDevice,
|
|
||||||
licenseDays: licenseDays,
|
|
||||||
wifiOnly: wifiOnly ?? this.wifiOnly,
|
|
||||||
maxQuality: maxQuality ?? this.maxQuality,
|
|
||||||
maxStorageBytes: maxStorageBytes,
|
|
||||||
used: used,
|
|
||||||
remaining: remaining,
|
|
||||||
expirePolicyText: expirePolicyText,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析策略响应;Long 字段兼容字符串。
|
|
||||||
factory DownloadPolicy.fromJson(Map<String, dynamic> json) {
|
|
||||||
return DownloadPolicy(
|
|
||||||
maxPerDevice: jsonToInt(json['maxPerDevice']),
|
|
||||||
licenseDays: jsonToInt(json['licenseDays']),
|
|
||||||
wifiOnly: json['wifiOnly'] == true,
|
|
||||||
maxQuality: json['maxQuality'] as String? ?? '',
|
|
||||||
maxStorageBytes: jsonToInt(json['maxStorageBytes']),
|
|
||||||
used: jsonToInt(json['used']),
|
|
||||||
remaining: jsonToInt(json['remaining']),
|
|
||||||
expirePolicyText: json['expirePolicyText'] as String? ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +1,21 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'json_numbers.dart';
|
enum DramaFormat { animated, liveAction }
|
||||||
|
|
||||||
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
|
|
||||||
///
|
|
||||||
/// 封面与播放地址都是后端返回的相对路径(`/files/...`),
|
|
||||||
/// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。
|
|
||||||
class Episode {
|
class Episode {
|
||||||
const Episode({
|
const Episode({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.number,
|
required this.number,
|
||||||
required this.title,
|
required this.title,
|
||||||
|
required this.assetPath,
|
||||||
required this.isFree,
|
required this.isFree,
|
||||||
this.videoUrl = '',
|
|
||||||
this.durationSec = 0,
|
|
||||||
this.locked = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final int number;
|
final int number;
|
||||||
final String title;
|
final String title;
|
||||||
|
final String assetPath;
|
||||||
final bool isFree;
|
final bool isFree;
|
||||||
|
|
||||||
/// 播放地址:详情接口不下发,由 `/app/episodes/{id}/play` 返回后写入。
|
|
||||||
final String videoUrl;
|
|
||||||
final int durationSec;
|
|
||||||
|
|
||||||
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
|
|
||||||
final bool locked;
|
|
||||||
|
|
||||||
Episode withVideoUrl(String url) => Episode(
|
|
||||||
id: id,
|
|
||||||
number: number,
|
|
||||||
title: title,
|
|
||||||
isFree: isFree,
|
|
||||||
videoUrl: url,
|
|
||||||
durationSec: durationSec,
|
|
||||||
locked: locked,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory Episode.fromJson(Map<String, dynamic> json) {
|
|
||||||
return Episode(
|
|
||||||
id: (json['id'] ?? '').toString(),
|
|
||||||
number: (json['number'] as num?)?.toInt() ?? 0,
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
isFree: json['isFree'] == true,
|
|
||||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
|
||||||
locked: json['locked'] == true,
|
|
||||||
videoUrl: json['playUrl'] as String? ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Drama {
|
class Drama {
|
||||||
@@ -57,96 +23,15 @@ class Drama {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.synopsis,
|
required this.synopsis,
|
||||||
required this.posterUrl,
|
required this.posterAsset,
|
||||||
this.episodes = const [],
|
required this.episodes,
|
||||||
this.category = '',
|
this.format = DramaFormat.liveAction,
|
||||||
this.genre = '',
|
|
||||||
this.totalEpisodes = 0,
|
|
||||||
this.freeEpisodes = 0,
|
|
||||||
this.isHot = false,
|
|
||||||
this.viewCount = 0,
|
|
||||||
this.likeCount,
|
|
||||||
this.favoriteCount,
|
|
||||||
this.commentCount,
|
|
||||||
this.language = '',
|
|
||||||
this.detailLoaded = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String title;
|
final String title;
|
||||||
final String synopsis;
|
final String synopsis;
|
||||||
final String posterUrl;
|
final String posterAsset;
|
||||||
final List<Episode> episodes;
|
final List<Episode> episodes;
|
||||||
final String category;
|
final DramaFormat format;
|
||||||
final String genre;
|
|
||||||
final int totalEpisodes;
|
|
||||||
final int freeEpisodes;
|
|
||||||
final bool isHot;
|
|
||||||
final int viewCount;
|
|
||||||
|
|
||||||
/// 详情下发的互动数量;卡片或旧接口没有这些字段时为 null(客户端不显示数字)。
|
|
||||||
final int? likeCount;
|
|
||||||
final int? favoriteCount;
|
|
||||||
final int? commentCount;
|
|
||||||
|
|
||||||
/// 卡片语种(如 zh-CN / id),用于剧场语言筛选。
|
|
||||||
final String language;
|
|
||||||
|
|
||||||
/// 详情接口是否已经取过完整剧集列表。
|
|
||||||
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
|
||||||
final bool detailLoaded;
|
|
||||||
|
|
||||||
/// 卡片只带一集预览,集数以后端给的总集数为准。
|
|
||||||
int get episodeCount => totalEpisodes > 0 ? totalEpisodes : episodes.length;
|
|
||||||
|
|
||||||
/// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。
|
|
||||||
factory Drama.fromCardJson(Map<String, dynamic> json) {
|
|
||||||
final preview = json['preview'];
|
|
||||||
return Drama(
|
|
||||||
id: (json['id'] ?? '').toString(),
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
synopsis: json['synopsis'] as String? ?? '',
|
|
||||||
posterUrl: json['cover'] as String? ?? '',
|
|
||||||
episodes: preview is Map<String, dynamic>
|
|
||||||
? [Episode.fromJson(preview)]
|
|
||||||
: const [],
|
|
||||||
category: json['category'] as String? ?? '',
|
|
||||||
genre: json['genre'] as String? ?? '',
|
|
||||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
|
||||||
isHot: json['isHot'] == true,
|
|
||||||
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
|
||||||
viewCount: jsonToInt(json['viewCount']),
|
|
||||||
likeCount: _optionalInt(json['likeCount']),
|
|
||||||
favoriteCount: _optionalInt(json['favoriteCount']),
|
|
||||||
commentCount: _optionalInt(json['commentCount']),
|
|
||||||
language: json['language'] as String? ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 详情:带剧集列表。
|
|
||||||
factory Drama.fromDetailJson(Map<String, dynamic> json) {
|
|
||||||
return Drama(
|
|
||||||
id: (json['id'] ?? '').toString(),
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
synopsis: json['synopsis'] as String? ?? '',
|
|
||||||
posterUrl: json['cover'] as String? ?? '',
|
|
||||||
category: json['category'] as String? ?? '',
|
|
||||||
genre: json['genre'] as String? ?? '',
|
|
||||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
|
||||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
|
||||||
isHot: json['isHot'] == true,
|
|
||||||
viewCount: jsonToInt(json['viewCount']),
|
|
||||||
likeCount: _optionalInt(json['likeCount']),
|
|
||||||
favoriteCount: _optionalInt(json['favoriteCount']),
|
|
||||||
commentCount: _optionalInt(json['commentCount']),
|
|
||||||
language: json['language'] as String? ?? '',
|
|
||||||
detailLoaded: true,
|
|
||||||
episodes: (json['episodes'] as List? ?? const [])
|
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map(Episode.fromJson)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value);
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
class EngagementSnapshot {
|
||||||
|
const EngagementSnapshot({
|
||||||
|
this.savedDramaIds = const {},
|
||||||
|
this.likedDramaIds = const {},
|
||||||
|
this.commentsByDrama = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
final Set<String> savedDramaIds;
|
||||||
|
final Set<String> likedDramaIds;
|
||||||
|
final Map<String, List<String>> commentsByDrama;
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import 'drama.dart';
|
|
||||||
|
|
||||||
/// 首页/发现页聚合数据,一次请求拿到轮播与分区。
|
|
||||||
class HomeFeed {
|
|
||||||
const HomeFeed({this.banners = const [], this.sections = const []});
|
|
||||||
|
|
||||||
final List<FeedBanner> banners;
|
|
||||||
final List<FeedSection> sections;
|
|
||||||
|
|
||||||
bool get isEmpty => sections.every((section) => section.dramas.isEmpty);
|
|
||||||
|
|
||||||
/// 分区打平后的短剧列表,供现有 UI 的横滑/竖滑复用;按 id 去重。
|
|
||||||
List<Drama> get dramas {
|
|
||||||
final seen = <String>{};
|
|
||||||
final result = <Drama>[];
|
|
||||||
for (final section in sections) {
|
|
||||||
for (final drama in section.dramas) {
|
|
||||||
if (seen.add(drama.id)) result.add(drama);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
factory HomeFeed.fromJson(Map<String, dynamic> json) {
|
|
||||||
final banners = (json['banners'] as List? ?? const [])
|
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map(FeedBanner.fromJson)
|
|
||||||
.toList();
|
|
||||||
final sections = (json['sections'] as List? ?? const [])
|
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map(FeedSection.fromJson)
|
|
||||||
.toList();
|
|
||||||
return HomeFeed(banners: banners, sections: sections);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FeedSection {
|
|
||||||
const FeedSection({
|
|
||||||
required this.code,
|
|
||||||
required this.title,
|
|
||||||
this.dramas = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
final String code;
|
|
||||||
final String title;
|
|
||||||
final List<Drama> dramas;
|
|
||||||
|
|
||||||
factory FeedSection.fromJson(Map<String, dynamic> json) {
|
|
||||||
return FeedSection(
|
|
||||||
code: json['code'] as String? ?? '',
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
dramas: (json['dramas'] as List? ?? const [])
|
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map(Drama.fromCardJson)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FeedBanner {
|
|
||||||
const FeedBanner({
|
|
||||||
required this.id,
|
|
||||||
required this.imageUrl,
|
|
||||||
this.linkType = 'NONE',
|
|
||||||
this.linkValue = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String imageUrl;
|
|
||||||
final String linkType;
|
|
||||||
final String linkValue;
|
|
||||||
|
|
||||||
/// 点击轮播要打开的短剧 id,非短剧跳转返回 null。
|
|
||||||
String? get dramaId => linkType == 'DRAMA' && linkValue.isNotEmpty
|
|
||||||
? linkValue
|
|
||||||
: null;
|
|
||||||
|
|
||||||
factory FeedBanner.fromJson(Map<String, dynamic> json) {
|
|
||||||
return FeedBanner(
|
|
||||||
id: (json['id'] ?? '').toString(),
|
|
||||||
imageUrl: json['imageUrl'] as String? ?? '',
|
|
||||||
linkType: json['linkType'] as String? ?? 'NONE',
|
|
||||||
linkValue: json['linkValue'] as String? ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// 互动目标类型,契约只允许短剧与评论。
|
|
||||||
enum InteractionTargetType {
|
|
||||||
drama('DRAMA'),
|
|
||||||
comment('COMMENT');
|
|
||||||
|
|
||||||
const InteractionTargetType(this.apiValue);
|
|
||||||
|
|
||||||
final String apiValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 客户端固定举报原因,后端当前未限制枚举值。
|
|
||||||
enum ReportReason {
|
|
||||||
spam('SPAM'),
|
|
||||||
abuse('ABUSE'),
|
|
||||||
porn('PORN'),
|
|
||||||
ad('AD'),
|
|
||||||
other('OTHER');
|
|
||||||
|
|
||||||
const ReportReason(this.apiValue);
|
|
||||||
|
|
||||||
final String apiValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一次性互动请求,供仓库和测试断言使用。
|
|
||||||
class ReportRequest {
|
|
||||||
const ReportRequest({
|
|
||||||
required this.targetType,
|
|
||||||
required this.targetId,
|
|
||||||
required this.reasonCode,
|
|
||||||
this.description = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
final InteractionTargetType targetType;
|
|
||||||
final String targetId;
|
|
||||||
final String reasonCode;
|
|
||||||
final String description;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 点赞切换后的服务端状态。
|
|
||||||
class LikeToggleResult {
|
|
||||||
const LikeToggleResult({required this.liked, required this.count});
|
|
||||||
|
|
||||||
final bool liked;
|
|
||||||
final int count;
|
|
||||||
|
|
||||||
/// 计数按 Long 字符串兼容解析。
|
|
||||||
factory LikeToggleResult.fromJson(Map<String, dynamic> json) =>
|
|
||||||
LikeToggleResult(
|
|
||||||
liked: json['liked'] == true,
|
|
||||||
count: jsonToInt(json['count']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
|
|
||||||
class OperationResult<T> {
|
|
||||||
const OperationResult._(this.data, this.errorMessage);
|
|
||||||
|
|
||||||
const OperationResult.success([T? data]) : this._(data, null);
|
|
||||||
|
|
||||||
const OperationResult.failure(this.errorMessage) : data = null;
|
|
||||||
|
|
||||||
final T? data;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
bool get isSuccess => errorMessage == null;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// Server notice list page.
|
|
||||||
class NoticePage {
|
|
||||||
const NoticePage({required this.total, this.notices = const []});
|
|
||||||
|
|
||||||
final int total;
|
|
||||||
final List<AppNotice> notices;
|
|
||||||
|
|
||||||
/// Parses the anonymous `/app/notices` page shell.
|
|
||||||
factory NoticePage.fromJson(Map<String, dynamic> json) {
|
|
||||||
final rawList = json['list'];
|
|
||||||
return NoticePage(
|
|
||||||
total: jsonToInt(json['total']),
|
|
||||||
notices: [
|
|
||||||
if (rawList is List)
|
|
||||||
for (final item in rawList)
|
|
||||||
if (item is Map<String, dynamic>) AppNotice.fromJson(item),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// System notice returned by `/app/notices`.
|
|
||||||
class AppNotice {
|
|
||||||
const AppNotice({
|
|
||||||
required this.id,
|
|
||||||
required this.title,
|
|
||||||
required this.content,
|
|
||||||
required this.type,
|
|
||||||
required this.publishAt,
|
|
||||||
this.expireAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String title;
|
|
||||||
final String content;
|
|
||||||
final String type;
|
|
||||||
final DateTime? publishAt;
|
|
||||||
final DateTime? expireAt;
|
|
||||||
|
|
||||||
/// Parses string IDs without narrowing them through `int`.
|
|
||||||
factory AppNotice.fromJson(Map<String, dynamic> json) => AppNotice(
|
|
||||||
id: jsonToId(json['id']),
|
|
||||||
title: json['title']?.toString() ?? '',
|
|
||||||
content: json['content']?.toString() ?? '',
|
|
||||||
type: json['type']?.toString() ?? '',
|
|
||||||
publishAt: DateTime.tryParse(json['publishAt']?.toString() ?? ''),
|
|
||||||
expireAt: DateTime.tryParse(json['expireAt']?.toString() ?? ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'json_numbers.dart';
|
|
||||||
|
|
||||||
/// 充值订单:`POST /app/orders` 创建的 `CREATED` 订单,等待支付。
|
|
||||||
class AppOrder {
|
|
||||||
const AppOrder({
|
|
||||||
required this.id,
|
|
||||||
required this.orderNo,
|
|
||||||
required this.productName,
|
|
||||||
required this.coinAmount,
|
|
||||||
required this.status,
|
|
||||||
this.productId = '',
|
|
||||||
this.type = '',
|
|
||||||
this.amount = '',
|
|
||||||
this.currency = '',
|
|
||||||
this.createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const String created = 'CREATED';
|
|
||||||
|
|
||||||
final String id;
|
|
||||||
final String orderNo;
|
|
||||||
final String productId;
|
|
||||||
final String productName;
|
|
||||||
final String type;
|
|
||||||
final int coinAmount;
|
|
||||||
final String amount;
|
|
||||||
final String currency;
|
|
||||||
final String status;
|
|
||||||
final DateTime? createdAt;
|
|
||||||
|
|
||||||
bool get isCreated => status == created;
|
|
||||||
|
|
||||||
factory AppOrder.fromJson(Map<String, dynamic> json) {
|
|
||||||
return AppOrder(
|
|
||||||
id: (json['id'] ?? '').toString(),
|
|
||||||
orderNo: json['orderNo'] as String? ?? '',
|
|
||||||
productId: (json['productId'] ?? '').toString(),
|
|
||||||
productName: json['productName'] as String? ?? '',
|
|
||||||
type: json['type'] as String? ?? '',
|
|
||||||
coinAmount: jsonToInt(json['coinAmount']),
|
|
||||||
amount: (json['amount'] ?? '').toString(),
|
|
||||||
currency: json['currency'] as String? ?? '',
|
|
||||||
status: json['status'] as String? ?? '',
|
|
||||||
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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,
|
|
||||||
this.qualities = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
|
|
||||||
final List<String> qualities;
|
|
||||||
|
|
||||||
bool get needsUnlock =>
|
|
||||||
accessType == coinRequired || accessType == vipRequired;
|
|
||||||
|
|
||||||
factory PlayTicket.fromJson(Map<String, dynamic> json) {
|
|
||||||
return PlayTicket(
|
|
||||||
episodeId: (json['episodeId'] ?? '').toString(),
|
|
||||||
dramaId: (json['dramaId'] ?? '').toString(),
|
|
||||||
number: jsonToInt(json['number']),
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
accessible: json['accessible'] == true,
|
|
||||||
accessType: json['accessType'] as String? ?? noMedia,
|
|
||||||
playUrl: json['playUrl'] as String? ?? '',
|
|
||||||
durationSec: jsonToInt(json['durationSec']),
|
|
||||||
unlockCoin: jsonToInt(json['unlockCoin']),
|
|
||||||
previewSeconds: jsonToInt(json['previewSeconds']),
|
|
||||||
qualities: (json['qualities'] as List? ?? const [])
|
|
||||||
.whereType<String>()
|
|
||||||
.where((quality) => quality.isNotEmpty)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||