新增:首页新增图一图功能
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 典型用法
|
||||
|
||||
- **应用启动时**:调用 `getUnacknowledgedPurchases()`,若有未核销订单,可逐条上报 `POST /v1/payment/googlepay`(federation 可用该笔的 `orderId`,若服务端支持;否则需先 createPayment 拿到 federation 再回调),上报成功后对该笔购买调用 `InAppPurchase.instance.completePurchase(purchase)` 完成核销。
|
||||
- **应用启动时**:调用 `getUnacknowledgedPurchases()`,若有未核销订单,补单流程会使用本地保存的「创建订单时的 federation」逐笔回调;无保存 federation 的订单会跳过。
|
||||
- **注意**:`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` 内、内购成功后调用 |
|
||||
| 凭据数据结构 | Android:`GooglePlayPurchaseDetails.billingClientPurchase`(orderId, originalJson, signature) |
|
||||
| 获取未核销订单 | `GooglePlayPurchaseService.getUnacknowledgedPurchases()`,见第 5 节 |
|
||||
| 补单流程 | `GooglePlayPurchaseService.runOrderRecovery()`,见第 6 节 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 小结
|
||||
## 8. 小结
|
||||
|
||||
1. **创建订单**:仅在选择「Google Pay」且第三方支付开启时调用 createPayment;拿到 **federation** 后才会调起谷歌支付并回调 googlepay。
|
||||
2. **调起谷歌支付**:productId 固定为当前商品的 **code(helm)**,与 Play 后台产品 ID 一致。
|
||||
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 — 应用标识(必填)
|
||||
"asset": "", // userId — 用户ID
|
||||
"accolade": "", // type — 类型
|
||||
"accolade": "android_adjust", // type — 类型,传 android_adjust
|
||||
"portal": "" // pkg — 应用包名(必填)
|
||||
}
|
||||
```
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
|--------|----------------|------|
|
||||
| Query | sentinel | 应用标识(必填) |
|
||||
| Query | asset | 用户 ID(userId) |
|
||||
| Query | accolade | 类型(可选) |
|
||||
| Query | accolade | 类型(可选),传 `android_adjust` |
|
||||
| Query | portal | 应用包名(必填) |
|
||||
| Body | digest | **归因信息,从 Adjust 获取** |
|
||||
| Body | origin | 设备 ID(deviceId) |
|
||||
|
||||
@ -15,8 +15,7 @@ abstract final class ApiConfig {
|
||||
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
|
||||
|
||||
/// 生产环境域名
|
||||
static const String prodBaseUrl =
|
||||
'https://pre-ai.petsheroai.xyz'; //https://ai.petsheroai.xyz
|
||||
static const String prodBaseUrl = 'https://ai.petsheroai.xyz';
|
||||
|
||||
/// 代理入口路径
|
||||
static const String proxyPath = '/quester/defender/summoner';
|
||||
|
||||
@ -62,7 +62,8 @@ void _logLong(String text) {
|
||||
int chunkIndex = 0;
|
||||
for (final line in lines) {
|
||||
final lineWithNewline = buffer.isEmpty ? line : '\n$line';
|
||||
if (buffer.length + lineWithNewline.length > _maxLogChunk && buffer.isNotEmpty) {
|
||||
if (buffer.length + lineWithNewline.length > _maxLogChunk &&
|
||||
buffer.isNotEmpty) {
|
||||
chunkIndex++;
|
||||
_proxyLog.d('(part $chunkIndex)\n$buffer');
|
||||
buffer.clear();
|
||||
@ -74,7 +75,8 @@ void _logLong(String text) {
|
||||
}
|
||||
if (buffer.isNotEmpty) {
|
||||
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 v2BodyEncoded = jsonEncode(v2Body);
|
||||
|
||||
_log('========== 原始入参 ==========');
|
||||
_log('path: $path');
|
||||
_log('method: $method');
|
||||
_log('headers: $headersEncoded');
|
||||
_log('queryParams: $paramsEncoded');
|
||||
_log('body(sanctum): ${jsonEncode(sanctum)}');
|
||||
_log('v2Body: $v2BodyEncoded');
|
||||
final logStr =
|
||||
'========== 原始入参 ===========\npath: $path\nmethod: $method\nqueryParams: $paramsEncoded\nbody(sanctum): ${jsonEncode(sanctum)}';
|
||||
_log(logStr);
|
||||
|
||||
final petSpeciesEnc = ApiCrypto.encrypt(path);
|
||||
final powerLevelEnc = ApiCrypto.encrypt(method);
|
||||
@ -266,13 +264,6 @@ class ProxyClient {
|
||||
final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded);
|
||||
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 = {
|
||||
ProxyKeys.heroClass: ApiConfig.appId,
|
||||
ProxyKeys.petSpecies: petSpeciesEnc,
|
||||
@ -289,8 +280,6 @@ class ProxyClient {
|
||||
};
|
||||
|
||||
final url = '$_baseUrl${ApiConfig.proxyPath}';
|
||||
_log('========== 请求 URL ==========');
|
||||
_log('$url');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(url),
|
||||
@ -298,19 +287,17 @@ class ProxyClient {
|
||||
body: jsonEncode(proxyBody),
|
||||
);
|
||||
|
||||
_log('========== 响应 ==========');
|
||||
_log('statusCode: ${response.statusCode}');
|
||||
_log('body: ${response.body}');
|
||||
|
||||
return _parseResponse(response);
|
||||
}
|
||||
|
||||
ApiResponse _parseResponse(http.Response response) {
|
||||
try {
|
||||
// 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串
|
||||
var responseLogStr = '========== 响应 ===========';
|
||||
final decrypted = ApiCrypto.decrypt(response.body);
|
||||
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||
_log('json: $json');
|
||||
responseLogStr += jsonEncode(json);
|
||||
_log(responseLogStr);
|
||||
// 解析 helm=code, rampart=msg, sidekick=data
|
||||
final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum'];
|
||||
if (sanctum is Map<String, dynamic>) {
|
||||
|
||||
@ -70,6 +70,13 @@ class AuthService {
|
||||
if (surge != null) {
|
||||
final enable = surge['enable_third_party_payment'] as bool?;
|
||||
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) {
|
||||
_logMsg('surge JSON 解析失败: $e');
|
||||
@ -177,6 +184,7 @@ class AuthService {
|
||||
asset: uid!,
|
||||
digest: crest ?? '',
|
||||
origin: deviceId,
|
||||
accolade: 'android_adjust',
|
||||
);
|
||||
if (referrerRes.isSuccess) {
|
||||
_logMsg('referrer 上报成功');
|
||||
|
||||
@ -14,6 +14,11 @@ class UserState {
|
||||
static final ValueNotifier<bool?> enableThirdPartyPayment =
|
||||
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) {
|
||||
credits.value = value;
|
||||
}
|
||||
@ -38,6 +43,11 @@ class UserState {
|
||||
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) {
|
||||
if (value == null) return '--';
|
||||
return value.toString().replaceAllMapped(
|
||||
|
||||
@ -7,11 +7,15 @@ import '../../core/user/user_state.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
import 'models/category_item.dart';
|
||||
import 'models/ext_config_item.dart';
|
||||
import 'models/task_item.dart';
|
||||
import 'widgets/home_tab_row.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 {
|
||||
const HomeScreen({super.key, this.isActive = true});
|
||||
|
||||
@ -32,10 +36,23 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
UserState.needShowVideoMenu.addListener(_onExtConfigChanged);
|
||||
UserState.extConfigItems.addListener(_onExtConfigChanged);
|
||||
_loadCategories();
|
||||
if (widget.isActive) refreshAccount();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
UserState.needShowVideoMenu.removeListener(_onExtConfigChanged);
|
||||
UserState.extConfigItems.removeListener(_onExtConfigChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onExtConfigChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HomeScreen 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 {
|
||||
setState(() => _categoriesLoading = true);
|
||||
await AuthService.loginComplete;
|
||||
@ -54,10 +110,20 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final list = (res.data as List)
|
||||
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
if (UserState.needShowVideoMenu.value == true) {
|
||||
list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null));
|
||||
}
|
||||
setState(() {
|
||||
_categories = list;
|
||||
_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 {
|
||||
setState(() => _categories = []);
|
||||
@ -87,7 +153,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
void _onTabChanged(CategoryItem c) {
|
||||
setState(() => _selectedCategory = c);
|
||||
_loadTasks(c.id);
|
||||
if (c.id == kExtCategoryId) {
|
||||
setState(() {
|
||||
_tasks = [];
|
||||
_tasksLoading = false;
|
||||
});
|
||||
} else {
|
||||
_loadTasks(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
static const _placeholderImage =
|
||||
@ -107,26 +180,31 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: AppSpacing.xs,
|
||||
// 仅 need_wait == true 时展示顶部分类栏(Pencil: tabRow bK6o6)
|
||||
if (_showVideoMenu)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
child: _categoriesLoading
|
||||
? const SizedBox(
|
||||
height: 40,
|
||||
child: Center(child: CircularProgressIndicator()))
|
||||
: HomeTabRow(
|
||||
categories: _categories,
|
||||
selectedId: _selectedCategory?.id ?? -1,
|
||||
onTabChanged: _onTabChanged,
|
||||
),
|
||||
),
|
||||
child: _categoriesLoading
|
||||
? const SizedBox(
|
||||
height: 40,
|
||||
child: Center(child: CircularProgressIndicator()))
|
||||
: HomeTabRow(
|
||||
categories: _categories,
|
||||
selectedId: _selectedCategory?.id ?? -1,
|
||||
onTabChanged: _onTabChanged,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _tasksLoading
|
||||
child: _showVideoMenu &&
|
||||
_selectedCategory?.id != kExtCategoryId &&
|
||||
_tasksLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tasks = _displayTasks;
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 390),
|
||||
@ -144,9 +222,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
mainAxisSpacing: AppSpacing.xl,
|
||||
crossAxisSpacing: AppSpacing.xl,
|
||||
),
|
||||
itemCount: _tasks.length,
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = _tasks[index];
|
||||
final task = tasks[index];
|
||||
final credits = task.credits480p != null
|
||||
? task.credits480p.toString()
|
||||
: '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/referrer/referrer_service.dart';
|
||||
import 'core/theme/app_colors.dart';
|
||||
import 'features/recharge/google_play_purchase_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@ -25,6 +26,12 @@ void main() async {
|
||||
runApp(const App());
|
||||
// APP 打开时后台执行快速登录
|
||||
AuthService.init();
|
||||
// 尽早订阅 purchaseStream,否则未确认订单不会出现在 queryPastPurchases 中,补单会为空
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
GooglePlayPurchaseService.startPendingPurchaseListener();
|
||||
}
|
||||
// 登录完成后执行谷歌支付补单(未核销订单上报服务端并 completePurchase)
|
||||
AuthService.loginComplete.then((_) => GooglePlayPurchaseService.runOrderRecovery());
|
||||
}
|
||||
|
||||
void _initAdjust() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user