新增:首页新增图一图功能
This commit is contained in:
parent
bde8db3673
commit
62f5c9a19b
16
docs/extConfig.md
Normal file
16
docs/extConfig.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
// 下面的三个字段说明:A面都是 false,B面都是 true
|
||||||
|
"need_wait": false, // 是否展示 Video 菜单
|
||||||
|
"safe_area": false, // 是否防止截屏
|
||||||
|
"lucky": false, // 是否显示第三方支付
|
||||||
|
"privacy": "https://www.petsheroai.xyz/privacy.html",
|
||||||
|
"agreement": "https://www.petsheroai.xyz/terms.html",
|
||||||
|
"items": [ // 图片列表
|
||||||
|
{
|
||||||
|
"image": "https://cdn.magieveryai.xyz/cdn/temp/20260305/2029445184939401216.jpg", // 图片URL
|
||||||
|
"cost": 1, // 需要的积分
|
||||||
|
"title": "BananaTask", // taskType 任务类型,生图的时候需要的字段
|
||||||
|
"detail": "parallel_cyberpunk_city_heroes" // ext 生图的时候需要的字段
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -102,12 +102,46 @@
|
|||||||
|
|
||||||
### 5.2 典型用法
|
### 5.2 典型用法
|
||||||
|
|
||||||
- **应用启动时**:调用 `getUnacknowledgedPurchases()`,若有未核销订单,可逐条上报 `POST /v1/payment/googlepay`(federation 可用该笔的 `orderId`,若服务端支持;否则需先 createPayment 拿到 federation 再回调),上报成功后对该笔购买调用 `InAppPurchase.instance.completePurchase(purchase)` 完成核销。
|
- **应用启动时**:调用 `getUnacknowledgedPurchases()`,若有未核销订单,补单流程会使用本地保存的「创建订单时的 federation」逐笔回调;无保存 federation 的订单会跳过。
|
||||||
- **注意**:`queryPastPurchases` 不包含已消耗(consumed)的商品;未确认的消耗型商品会一直在列表中直到被确认或消耗。
|
- **注意**:`queryPastPurchases` 不包含已消耗(consumed)的商品;未确认的消耗型商品会一直在列表中直到被确认或消耗。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 代码位置速查
|
## 6. 补单流程(自动)
|
||||||
|
|
||||||
|
客户端已实现完整补单:拉取未核销订单 → 用本地保存的**创建订单时的 federation** 逐笔回调 googlepay → 服务端返回 `line == 'SUCCESS'` 则 `completePurchase` 并刷新用户信息。补单必须使用创建订单时的订单 id,不能使用 Google 的 orderId。
|
||||||
|
|
||||||
|
### 6.1 创建订单 id 的持久化
|
||||||
|
|
||||||
|
- 每次调起内购前都会先 **createPayment** 拿到 **federation**(服务端订单 id)。
|
||||||
|
- 在发起 `POST /v1/payment/googlepay` 前,将 **Google orderId → federation** 写入本地(SharedPreferences),供补单时使用。
|
||||||
|
- 回调成功或补单成功后删除对应映射。
|
||||||
|
|
||||||
|
### 6.2 入口与触发时机
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 方法 | `GooglePlayPurchaseService.runOrderRecovery()` |
|
||||||
|
| 触发时机 | ① 应用启动:`main()` 中 `AuthService.loginComplete.then((_) => runOrderRecovery())`<br>② 进入充值页:`RechargeScreen.initState` 中调用 |
|
||||||
|
| 前置条件 | 仅 Android;已登录(`UserState.userId` 非空) |
|
||||||
|
|
||||||
|
### 6.3 流程步骤
|
||||||
|
|
||||||
|
1. 若非 Android 或未登录,直接返回。
|
||||||
|
2. 调用 `getUnacknowledgedPurchases()` 获取未核销列表。
|
||||||
|
3. 对每笔订单:用 **Google orderId** 查本地保存的 **federation**(创建订单时的订单 id);若无则跳过该笔并打日志。
|
||||||
|
4. 用查到的 federation 调用 `POST /v1/payment/googlepay`。
|
||||||
|
5. 若响应成功且 `line == 'SUCCESS'`:`completePurchase`、删除该笔映射、标记需要刷新。
|
||||||
|
6. 若有任意一笔补单成功,最后调用 `refreshAccount()`。
|
||||||
|
|
||||||
|
### 6.4 服务端约定
|
||||||
|
|
||||||
|
- 补单请求的 **federation** 与正常回调一致,均为 **createPayment 返回的订单 id**;服务端按订单 id 落单/去重。
|
||||||
|
- 校验逻辑与正常回调一致(sample=签名、merchant=购买凭据),成功时返回 `line: 'SUCCESS'`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 代码位置速查
|
||||||
|
|
||||||
| 步骤 | 位置 |
|
| 步骤 | 位置 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@ -117,12 +151,14 @@
|
|||||||
| 回调 googlepay | `PaymentApi.googlepay(...)`,在 `_createOrderAndOpenUrl` 内、内购成功后调用 |
|
| 回调 googlepay | `PaymentApi.googlepay(...)`,在 `_createOrderAndOpenUrl` 内、内购成功后调用 |
|
||||||
| 凭据数据结构 | Android:`GooglePlayPurchaseDetails.billingClientPurchase`(orderId, originalJson, signature) |
|
| 凭据数据结构 | Android:`GooglePlayPurchaseDetails.billingClientPurchase`(orderId, originalJson, signature) |
|
||||||
| 获取未核销订单 | `GooglePlayPurchaseService.getUnacknowledgedPurchases()`,见第 5 节 |
|
| 获取未核销订单 | `GooglePlayPurchaseService.getUnacknowledgedPurchases()`,见第 5 节 |
|
||||||
|
| 补单流程 | `GooglePlayPurchaseService.runOrderRecovery()`,见第 6 节 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 小结
|
## 8. 小结
|
||||||
|
|
||||||
1. **创建订单**:仅在选择「Google Pay」且第三方支付开启时调用 createPayment;拿到 **federation** 后才会调起谷歌支付并回调 googlepay。
|
1. **创建订单**:仅在选择「Google Pay」且第三方支付开启时调用 createPayment;拿到 **federation** 后才会调起谷歌支付并回调 googlepay。
|
||||||
2. **调起谷歌支付**:productId 固定为当前商品的 **code(helm)**,与 Play 后台产品 ID 一致。
|
2. **调起谷歌支付**:productId 固定为当前商品的 **code(helm)**,与 Play 后台产品 ID 一致。
|
||||||
3. **回调 googlepay**:body 为四字段 **sample**(signature)、**merchant**(purchaseData/originalJson)、**federation**(订单 id)、**asset**(userId);federation 为空则不回调,可按策略重试创建订单或提示失败。
|
3. **回调 googlepay**:body 为四字段 **sample**(signature)、**merchant**(purchaseData/originalJson)、**federation**(订单 id)、**asset**(userId);federation 为空则不回调,可按策略重试创建订单或提示失败。
|
||||||
4. **未核销订单**:通过 `getUnacknowledgedPurchases()` 获取 `isAcknowledged == false` 的购买,可用于启动时补发回调或展示待处理订单。
|
4. **未核销订单**:通过 `getUnacknowledgedPurchases()` 获取 `isAcknowledged == false` 的购买;每项含 `purchaseDetails` 用于补单成功后 `completePurchase`。
|
||||||
|
5. **补单**:`runOrderRecovery()` 在应用启动(登录完成后)与进入充值页时执行;补单使用的 **federation** 为创建订单时的订单 id(内购前会持久化 Google orderId→federation,补单时按 Google orderId 取回);无保存 federation 的未核销订单会跳过。
|
||||||
|
|||||||
28
docs/home.md
Normal file
28
docs/home.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# 主页 UI 显示逻辑
|
||||||
|
|
||||||
|
依赖接口:`GET /v1/user/common_info` 返回的 `data.surge`(JSON 字符串)解析为 extConfig,结构见 [extConfig.md](extConfig.md)。
|
||||||
|
|
||||||
|
## extConfig 与顶部分类栏
|
||||||
|
|
||||||
|
- **need_wait**(是否展示 Video 菜单)
|
||||||
|
- 解析自 `surge.need_wait`,写入 `UserState.needShowVideoMenu`。
|
||||||
|
- **仅 need_wait === true**:展示顶部分类栏(对应 Pencil 设计中的 tabRow,节点 bK6o6),行为见下。
|
||||||
|
- **其他情况**(need_wait === false、未下发或未解析):不展示顶部分类栏,列表只展示 **extConfig.items** 的图片列表。
|
||||||
|
|
||||||
|
## need_wait === true 时的分类与列表
|
||||||
|
|
||||||
|
1. **分类栏**
|
||||||
|
使用图转视频分类接口数据,在分类列表**末尾**增加一个固定分类「pets」。
|
||||||
|
2. **列表内容**
|
||||||
|
- 选中**固定分类 pets**:列表展示 **extConfig.items** 的图片列表(不请求视频任务接口)。
|
||||||
|
- 选中**其他分类**:按原逻辑请求对应分类的视频任务接口,列表展示接口返回的视频任务。
|
||||||
|
|
||||||
|
## 数据流简述
|
||||||
|
|
||||||
|
1. 登录后请求 `common_info`,在 `AuthService._saveCommonInfoToState` 中解析 `data.surge`:
|
||||||
|
- 写入 `enable_third_party_payment` 等;
|
||||||
|
- 解析 `need_wait`、`items`,通过 `UserState.setExtConfig(needShowVideoMenuValue: needWait, items: items)` 写入。
|
||||||
|
2. 主页 `HomeScreen` 监听 `UserState.needShowVideoMenu`、`UserState.extConfigItems`,据此决定:
|
||||||
|
- 是否渲染顶部分类栏;
|
||||||
|
- 当前列表是来自 extConfig.items 还是来自视频任务接口。
|
||||||
|
3. extConfig 的 **items** 单项字段:`image`、`cost`、`title`、`detail`,用于展示卡片并作为生图参数(taskType / ext)。
|
||||||
@ -1297,7 +1297,7 @@ V2 完整响应体 (解密后):
|
|||||||
{
|
{
|
||||||
"sentinel": "", // app — 应用标识(必填)
|
"sentinel": "", // app — 应用标识(必填)
|
||||||
"asset": "", // userId — 用户ID
|
"asset": "", // userId — 用户ID
|
||||||
"accolade": "", // type — 类型
|
"accolade": "android_adjust", // type — 类型,传 android_adjust
|
||||||
"portal": "" // pkg — 应用包名(必填)
|
"portal": "" // pkg — 应用包名(必填)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
|--------|----------------|------|
|
|--------|----------------|------|
|
||||||
| Query | sentinel | 应用标识(必填) |
|
| Query | sentinel | 应用标识(必填) |
|
||||||
| Query | asset | 用户 ID(userId) |
|
| Query | asset | 用户 ID(userId) |
|
||||||
| Query | accolade | 类型(可选) |
|
| Query | accolade | 类型(可选),传 `android_adjust` |
|
||||||
| Query | portal | 应用包名(必填) |
|
| Query | portal | 应用包名(必填) |
|
||||||
| Body | digest | **归因信息,从 Adjust 获取** |
|
| Body | digest | **归因信息,从 Adjust 获取** |
|
||||||
| Body | origin | 设备 ID(deviceId) |
|
| Body | origin | 设备 ID(deviceId) |
|
||||||
|
|||||||
@ -15,8 +15,7 @@ abstract final class ApiConfig {
|
|||||||
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
|
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
|
||||||
|
|
||||||
/// 生产环境域名
|
/// 生产环境域名
|
||||||
static const String prodBaseUrl =
|
static const String prodBaseUrl = 'https://ai.petsheroai.xyz';
|
||||||
'https://pre-ai.petsheroai.xyz'; //https://ai.petsheroai.xyz
|
|
||||||
|
|
||||||
/// 代理入口路径
|
/// 代理入口路径
|
||||||
static const String proxyPath = '/quester/defender/summoner';
|
static const String proxyPath = '/quester/defender/summoner';
|
||||||
|
|||||||
@ -62,7 +62,8 @@ void _logLong(String text) {
|
|||||||
int chunkIndex = 0;
|
int chunkIndex = 0;
|
||||||
for (final line in lines) {
|
for (final line in lines) {
|
||||||
final lineWithNewline = buffer.isEmpty ? line : '\n$line';
|
final lineWithNewline = buffer.isEmpty ? line : '\n$line';
|
||||||
if (buffer.length + lineWithNewline.length > _maxLogChunk && buffer.isNotEmpty) {
|
if (buffer.length + lineWithNewline.length > _maxLogChunk &&
|
||||||
|
buffer.isNotEmpty) {
|
||||||
chunkIndex++;
|
chunkIndex++;
|
||||||
_proxyLog.d('(part $chunkIndex)\n$buffer');
|
_proxyLog.d('(part $chunkIndex)\n$buffer');
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
@ -74,7 +75,8 @@ void _logLong(String text) {
|
|||||||
}
|
}
|
||||||
if (buffer.isNotEmpty) {
|
if (buffer.isNotEmpty) {
|
||||||
chunkIndex++;
|
chunkIndex++;
|
||||||
_proxyLog.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString());
|
_proxyLog
|
||||||
|
.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,13 +254,9 @@ class ProxyClient {
|
|||||||
final paramsEncoded = jsonEncode(paramsMap);
|
final paramsEncoded = jsonEncode(paramsMap);
|
||||||
final v2BodyEncoded = jsonEncode(v2Body);
|
final v2BodyEncoded = jsonEncode(v2Body);
|
||||||
|
|
||||||
_log('========== 原始入参 ==========');
|
final logStr =
|
||||||
_log('path: $path');
|
'========== 原始入参 ===========\npath: $path\nmethod: $method\nqueryParams: $paramsEncoded\nbody(sanctum): ${jsonEncode(sanctum)}';
|
||||||
_log('method: $method');
|
_log(logStr);
|
||||||
_log('headers: $headersEncoded');
|
|
||||||
_log('queryParams: $paramsEncoded');
|
|
||||||
_log('body(sanctum): ${jsonEncode(sanctum)}');
|
|
||||||
_log('v2Body: $v2BodyEncoded');
|
|
||||||
|
|
||||||
final petSpeciesEnc = ApiCrypto.encrypt(path);
|
final petSpeciesEnc = ApiCrypto.encrypt(path);
|
||||||
final powerLevelEnc = ApiCrypto.encrypt(method);
|
final powerLevelEnc = ApiCrypto.encrypt(method);
|
||||||
@ -266,13 +264,6 @@ class ProxyClient {
|
|||||||
final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded);
|
final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded);
|
||||||
final loyaltyIndexEnc = ApiCrypto.encrypt(v2BodyEncoded);
|
final loyaltyIndexEnc = ApiCrypto.encrypt(v2BodyEncoded);
|
||||||
|
|
||||||
_log('========== 加密后 ==========');
|
|
||||||
_log('pet_species: $petSpeciesEnc');
|
|
||||||
_log('power_level: $powerLevelEnc');
|
|
||||||
_log('quest_rank: $questRankEnc');
|
|
||||||
_log('battle_score: $battleScoreEnc');
|
|
||||||
_log('loyalty_index: $loyaltyIndexEnc');
|
|
||||||
|
|
||||||
final proxyBody = {
|
final proxyBody = {
|
||||||
ProxyKeys.heroClass: ApiConfig.appId,
|
ProxyKeys.heroClass: ApiConfig.appId,
|
||||||
ProxyKeys.petSpecies: petSpeciesEnc,
|
ProxyKeys.petSpecies: petSpeciesEnc,
|
||||||
@ -289,8 +280,6 @@ class ProxyClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
final url = '$_baseUrl${ApiConfig.proxyPath}';
|
final url = '$_baseUrl${ApiConfig.proxyPath}';
|
||||||
_log('========== 请求 URL ==========');
|
|
||||||
_log('$url');
|
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
Uri.parse(url),
|
Uri.parse(url),
|
||||||
@ -298,19 +287,17 @@ class ProxyClient {
|
|||||||
body: jsonEncode(proxyBody),
|
body: jsonEncode(proxyBody),
|
||||||
);
|
);
|
||||||
|
|
||||||
_log('========== 响应 ==========');
|
|
||||||
_log('statusCode: ${response.statusCode}');
|
|
||||||
_log('body: ${response.body}');
|
|
||||||
|
|
||||||
return _parseResponse(response);
|
return _parseResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse _parseResponse(http.Response response) {
|
ApiResponse _parseResponse(http.Response response) {
|
||||||
try {
|
try {
|
||||||
// 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串
|
// 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串
|
||||||
|
var responseLogStr = '========== 响应 ===========';
|
||||||
final decrypted = ApiCrypto.decrypt(response.body);
|
final decrypted = ApiCrypto.decrypt(response.body);
|
||||||
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||||
_log('json: $json');
|
responseLogStr += jsonEncode(json);
|
||||||
|
_log(responseLogStr);
|
||||||
// 解析 helm=code, rampart=msg, sidekick=data
|
// 解析 helm=code, rampart=msg, sidekick=data
|
||||||
final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum'];
|
final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum'];
|
||||||
if (sanctum is Map<String, dynamic>) {
|
if (sanctum is Map<String, dynamic>) {
|
||||||
|
|||||||
@ -70,6 +70,13 @@ class AuthService {
|
|||||||
if (surge != null) {
|
if (surge != null) {
|
||||||
final enable = surge['enable_third_party_payment'] as bool?;
|
final enable = surge['enable_third_party_payment'] as bool?;
|
||||||
UserState.setEnableThirdPartyPayment(enable);
|
UserState.setEnableThirdPartyPayment(enable);
|
||||||
|
// extConfig:need_wait = 是否展示 Video 菜单,items = 图片列表(见 docs/extConfig.md)
|
||||||
|
final needWait = surge['need_wait'] as bool?;
|
||||||
|
final items = surge['items'] as List<dynamic>?;
|
||||||
|
UserState.setExtConfig(
|
||||||
|
needShowVideoMenuValue: needWait,
|
||||||
|
items: items,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logMsg('surge JSON 解析失败: $e');
|
_logMsg('surge JSON 解析失败: $e');
|
||||||
@ -177,6 +184,7 @@ class AuthService {
|
|||||||
asset: uid!,
|
asset: uid!,
|
||||||
digest: crest ?? '',
|
digest: crest ?? '',
|
||||||
origin: deviceId,
|
origin: deviceId,
|
||||||
|
accolade: 'android_adjust',
|
||||||
);
|
);
|
||||||
if (referrerRes.isSuccess) {
|
if (referrerRes.isSuccess) {
|
||||||
_logMsg('referrer 上报成功');
|
_logMsg('referrer 上报成功');
|
||||||
|
|||||||
@ -14,6 +14,11 @@ class UserState {
|
|||||||
static final ValueNotifier<bool?> enableThirdPartyPayment =
|
static final ValueNotifier<bool?> enableThirdPartyPayment =
|
||||||
ValueNotifier<bool?>(null);
|
ValueNotifier<bool?>(null);
|
||||||
|
|
||||||
|
/// 是否展示 Video 分类栏(来自 common_info surge.need_wait,见 docs/extConfig.md)
|
||||||
|
static final ValueNotifier<bool?> needShowVideoMenu = ValueNotifier<bool?>(null);
|
||||||
|
/// extConfig.items 图片列表(来自 common_info surge.items)
|
||||||
|
static final ValueNotifier<List<dynamic>?> extConfigItems = ValueNotifier<List<dynamic>?>(null);
|
||||||
|
|
||||||
static void setCredits(int? value) {
|
static void setCredits(int? value) {
|
||||||
credits.value = value;
|
credits.value = value;
|
||||||
}
|
}
|
||||||
@ -38,6 +43,11 @@ class UserState {
|
|||||||
enableThirdPartyPayment.value = value;
|
enableThirdPartyPayment.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void setExtConfig({bool? needShowVideoMenuValue, List<dynamic>? items}) {
|
||||||
|
if (needShowVideoMenuValue != null) needShowVideoMenu.value = needShowVideoMenuValue;
|
||||||
|
if (items != null) extConfigItems.value = items;
|
||||||
|
}
|
||||||
|
|
||||||
static String formatCredits(int? value) {
|
static String formatCredits(int? value) {
|
||||||
if (value == null) return '--';
|
if (value == null) return '--';
|
||||||
return value.toString().replaceAllMapped(
|
return value.toString().replaceAllMapped(
|
||||||
|
|||||||
@ -7,11 +7,15 @@ import '../../core/user/user_state.dart';
|
|||||||
import '../../core/theme/app_spacing.dart';
|
import '../../core/theme/app_spacing.dart';
|
||||||
import '../../shared/widgets/top_nav_bar.dart';
|
import '../../shared/widgets/top_nav_bar.dart';
|
||||||
import 'models/category_item.dart';
|
import 'models/category_item.dart';
|
||||||
|
import 'models/ext_config_item.dart';
|
||||||
import 'models/task_item.dart';
|
import 'models/task_item.dart';
|
||||||
import 'widgets/home_tab_row.dart';
|
import 'widgets/home_tab_row.dart';
|
||||||
import 'widgets/video_card.dart';
|
import 'widgets/video_card.dart';
|
||||||
|
|
||||||
/// AI Video App home screen - tab 来自分类接口,Grid 来自任务列表接口
|
/// 固定「pets」分类 id,用于展示 extConfig.items
|
||||||
|
const int kExtCategoryId = -1;
|
||||||
|
|
||||||
|
/// AI Video App home screen - tab 来自分类接口,Grid 来自任务列表或 extConfig.items
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key, this.isActive = true});
|
const HomeScreen({super.key, this.isActive = true});
|
||||||
|
|
||||||
@ -32,10 +36,23 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
UserState.needShowVideoMenu.addListener(_onExtConfigChanged);
|
||||||
|
UserState.extConfigItems.addListener(_onExtConfigChanged);
|
||||||
_loadCategories();
|
_loadCategories();
|
||||||
if (widget.isActive) refreshAccount();
|
if (widget.isActive) refreshAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
UserState.needShowVideoMenu.removeListener(_onExtConfigChanged);
|
||||||
|
UserState.extConfigItems.removeListener(_onExtConfigChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onExtConfigChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant HomeScreen oldWidget) {
|
void didUpdateWidget(covariant HomeScreen oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@ -44,6 +61,45 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅 need_wait === true 时展示 Video 分类栏;其他(false/null/未解析)只显示图片列表
|
||||||
|
bool get _showVideoMenu =>
|
||||||
|
UserState.needShowVideoMenu.value == true;
|
||||||
|
|
||||||
|
List<ExtConfigItem> get _parsedExtItems {
|
||||||
|
final raw = UserState.extConfigItems.value;
|
||||||
|
if (raw == null || raw.isEmpty) return [];
|
||||||
|
return raw
|
||||||
|
.map((e) => e is Map<String, dynamic>
|
||||||
|
? ExtConfigItem.fromJson(e)
|
||||||
|
: ExtConfigItem.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前列表:need_wait false 时用 extConfig.items;true 且选中固定分类时用 extConfig.items;否则用 _tasks
|
||||||
|
List<TaskItem> get _displayTasks {
|
||||||
|
if (!_showVideoMenu) {
|
||||||
|
return _extItemsToTaskItems(_parsedExtItems);
|
||||||
|
}
|
||||||
|
if (_selectedCategory?.id == kExtCategoryId) {
|
||||||
|
return _extItemsToTaskItems(_parsedExtItems);
|
||||||
|
}
|
||||||
|
return _tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<TaskItem> _extItemsToTaskItems(List<ExtConfigItem> items) {
|
||||||
|
return items
|
||||||
|
.map((e) => TaskItem(
|
||||||
|
templateName: e.title,
|
||||||
|
title: e.title,
|
||||||
|
previewImageUrl: e.image,
|
||||||
|
previewVideoUrl: null,
|
||||||
|
taskType: e.title,
|
||||||
|
ext: e.detail,
|
||||||
|
credits480p: e.cost,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadCategories() async {
|
Future<void> _loadCategories() async {
|
||||||
setState(() => _categoriesLoading = true);
|
setState(() => _categoriesLoading = true);
|
||||||
await AuthService.loginComplete;
|
await AuthService.loginComplete;
|
||||||
@ -54,10 +110,20 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
final list = (res.data as List)
|
final list = (res.data as List)
|
||||||
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
if (UserState.needShowVideoMenu.value == true) {
|
||||||
|
list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null));
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_categories = list;
|
_categories = list;
|
||||||
_selectedCategory = list.isNotEmpty ? list.first : null;
|
_selectedCategory = list.isNotEmpty ? list.first : null;
|
||||||
if (_selectedCategory != null) _loadTasks(_selectedCategory!.id);
|
if (_selectedCategory != null) {
|
||||||
|
if (_selectedCategory!.id == kExtCategoryId) {
|
||||||
|
_tasks = [];
|
||||||
|
_tasksLoading = false;
|
||||||
|
} else {
|
||||||
|
_loadTasks(_selectedCategory!.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() => _categories = []);
|
setState(() => _categories = []);
|
||||||
@ -87,8 +153,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
void _onTabChanged(CategoryItem c) {
|
void _onTabChanged(CategoryItem c) {
|
||||||
setState(() => _selectedCategory = c);
|
setState(() => _selectedCategory = c);
|
||||||
|
if (c.id == kExtCategoryId) {
|
||||||
|
setState(() {
|
||||||
|
_tasks = [];
|
||||||
|
_tasksLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
_loadTasks(c.id);
|
_loadTasks(c.id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static const _placeholderImage =
|
static const _placeholderImage =
|
||||||
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400';
|
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400';
|
||||||
@ -107,6 +180,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// 仅 need_wait == true 时展示顶部分类栏(Pencil: tabRow bK6o6)
|
||||||
|
if (_showVideoMenu)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.screenPadding,
|
horizontal: AppSpacing.screenPadding,
|
||||||
@ -123,10 +198,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _tasksLoading
|
child: _showVideoMenu &&
|
||||||
|
_selectedCategory?.id != kExtCategoryId &&
|
||||||
|
_tasksLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: LayoutBuilder(
|
: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
final tasks = _displayTasks;
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 390),
|
constraints: const BoxConstraints(maxWidth: 390),
|
||||||
@ -144,9 +222,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
mainAxisSpacing: AppSpacing.xl,
|
mainAxisSpacing: AppSpacing.xl,
|
||||||
crossAxisSpacing: AppSpacing.xl,
|
crossAxisSpacing: AppSpacing.xl,
|
||||||
),
|
),
|
||||||
itemCount: _tasks.length,
|
itemCount: tasks.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final task = _tasks[index];
|
final task = tasks[index];
|
||||||
final credits = task.credits480p != null
|
final credits = task.credits480p != null
|
||||||
? task.credits480p.toString()
|
? task.credits480p.toString()
|
||||||
: '50';
|
: '50';
|
||||||
|
|||||||
27
lib/features/home/models/ext_config_item.dart
Normal file
27
lib/features/home/models/ext_config_item.dart
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/// extConfig.items 单项,来自 common_info surge 解析,见 docs/extConfig.md
|
||||||
|
class ExtConfigItem {
|
||||||
|
const ExtConfigItem({
|
||||||
|
required this.image,
|
||||||
|
required this.cost,
|
||||||
|
required this.title,
|
||||||
|
required this.detail,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String image;
|
||||||
|
final int cost;
|
||||||
|
final String title;
|
||||||
|
final String detail;
|
||||||
|
|
||||||
|
factory ExtConfigItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExtConfigItem(
|
||||||
|
image: json['image'] as String? ?? '',
|
||||||
|
cost: (json['cost'] is int)
|
||||||
|
? json['cost'] as int
|
||||||
|
: (json['cost'] is num)
|
||||||
|
? (json['cost'] as num).toInt()
|
||||||
|
: 0,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
detail: json['detail'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import 'core/auth/auth_service.dart';
|
|||||||
import 'core/log/app_logger.dart';
|
import 'core/log/app_logger.dart';
|
||||||
import 'core/referrer/referrer_service.dart';
|
import 'core/referrer/referrer_service.dart';
|
||||||
import 'core/theme/app_colors.dart';
|
import 'core/theme/app_colors.dart';
|
||||||
|
import 'features/recharge/google_play_purchase_service.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@ -25,6 +26,12 @@ void main() async {
|
|||||||
runApp(const App());
|
runApp(const App());
|
||||||
// APP 打开时后台执行快速登录
|
// APP 打开时后台执行快速登录
|
||||||
AuthService.init();
|
AuthService.init();
|
||||||
|
// 尽早订阅 purchaseStream,否则未确认订单不会出现在 queryPastPurchases 中,补单会为空
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
GooglePlayPurchaseService.startPendingPurchaseListener();
|
||||||
|
}
|
||||||
|
// 登录完成后执行谷歌支付补单(未核销订单上报服务端并 completePurchase)
|
||||||
|
AuthService.loginComplete.then((_) => GooglePlayPurchaseService.runOrderRecovery());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initAdjust() {
|
void _initAdjust() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user