新增:支付对接
This commit is contained in:
parent
d9da82bbdc
commit
b464400b6b
@ -28,6 +28,12 @@ if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force 'com.android.billingclient:billing:7.1.1'
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.petsheroai.app"
|
||||
compileSdk 36
|
||||
@ -79,6 +85,8 @@ dependencies {
|
||||
implementation 'com.google.android.gms:play-services-ads-identifier:18.1.0'
|
||||
implementation 'com.android.installreferrer:installreferrer:2.2'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
// Play Billing Library 6.0.1+ (use 7.1.1 for in_app_purchase plugin compatibility)
|
||||
implementation 'com.android.billingclient:billing:7.1.1'
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@ -2691,13 +2691,40 @@
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Xvh9R",
|
||||
"fill": "#71717A",
|
||||
"content": "$5.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
"type": "frame",
|
||||
"id": "iVYPz",
|
||||
"name": "priceLine",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "BO2Is",
|
||||
"fill": "#71717A",
|
||||
"content": "$5.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "sgYV2",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "$6.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "1bb2Y",
|
||||
"fill": "#8B5CF6",
|
||||
"content": "+10 pts",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -2788,13 +2815,40 @@
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Rg4EJ",
|
||||
"fill": "#71717A",
|
||||
"content": "$9.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
"type": "frame",
|
||||
"id": "IAiZI",
|
||||
"name": "priceLine",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "oFR6Q",
|
||||
"fill": "#71717A",
|
||||
"content": "$9.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "bZrc4",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "$12.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "2oQd4",
|
||||
"fill": "#8B5CF6",
|
||||
"content": "+25 pts",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -2933,13 +2987,40 @@
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "BxrWP",
|
||||
"fill": "#71717A",
|
||||
"content": "$19.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
"type": "frame",
|
||||
"id": "gSnzm",
|
||||
"name": "priceLine",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "g3vvN",
|
||||
"fill": "#71717A",
|
||||
"content": "$19.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "9DcxP",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "$24.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "SCF0u",
|
||||
"fill": "#8B5CF6",
|
||||
"content": "+80 pts",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -3061,13 +3142,40 @@
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "lydr2",
|
||||
"fill": "#71717A",
|
||||
"content": "$49.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
"type": "frame",
|
||||
"id": "QcmBX",
|
||||
"name": "priceLine",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "QbQa2",
|
||||
"fill": "#71717A",
|
||||
"content": "$49.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "bvZ8D",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "$59.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "xKMOi",
|
||||
"fill": "#8B5CF6",
|
||||
"content": "+200 pts",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -3141,13 +3249,40 @@
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "nG15k",
|
||||
"fill": "#71717A",
|
||||
"content": "$99.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
"type": "frame",
|
||||
"id": "xMHYZ",
|
||||
"name": "priceLine",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wLYzH",
|
||||
"fill": "#71717A",
|
||||
"content": "$99.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ch71f",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "$119.99",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "H6lDP",
|
||||
"fill": "#8B5CF6",
|
||||
"content": "+500 pts",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -3321,6 +3456,7 @@
|
||||
"type": "frame",
|
||||
"id": "BNNL5",
|
||||
"name": "contentArea",
|
||||
"context": "Content area: creditsCard + videoPreviewArea scrollable; resolutionRow + generateBtn fixed at bottom.",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"layout": "vertical",
|
||||
@ -3400,6 +3536,7 @@
|
||||
"type": "frame",
|
||||
"id": "vA8QJ",
|
||||
"name": "videoPreviewArea",
|
||||
"context": "Video preview: video URL from card. Auto-load and play. Width: fill container; height: adapt by video aspect ratio. Fit: contain (no crop). Loading animation until ready.",
|
||||
"width": "fill_container",
|
||||
"height": 280,
|
||||
"fill": {
|
||||
@ -3413,80 +3550,13 @@
|
||||
"thickness": 1,
|
||||
"fill": "#E4E4E7"
|
||||
},
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "1VkSk",
|
||||
"x": 151,
|
||||
"y": 93,
|
||||
"enabled": false,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"iconFontName": "image-plus",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#A1A1AA"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "flPeY",
|
||||
"x": 35,
|
||||
"y": 105.5,
|
||||
"enabled": false,
|
||||
"fill": "#FFFFFF",
|
||||
"effect": {
|
||||
"type": "shadow",
|
||||
"shadowType": "outer",
|
||||
"color": "#00000080",
|
||||
"offset": {
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
"blur": 4
|
||||
},
|
||||
"textGrowth": "fixed-width",
|
||||
"width": 280,
|
||||
"content": "Video Preview",
|
||||
"textAlign": "center",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "5y1EY",
|
||||
"x": 12,
|
||||
"y": 208,
|
||||
"name": "iconWrap",
|
||||
"width": 60,
|
||||
"height": 60,
|
||||
"fill": "#FFFFFF80",
|
||||
"cornerRadius": 30,
|
||||
"stroke": {
|
||||
"thickness": 2,
|
||||
"fill": "#18181B"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "yMNqH",
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
"iconFontName": "image-plus",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#18181B"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"layout": "none"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "oYwsm",
|
||||
"name": "resolutionRow",
|
||||
"context": "Resolution options: 480p/720p. Fixed at screen bottom. When switching, update Generate Video credits (RGKvY).",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"gap": 12,
|
||||
@ -3583,6 +3653,7 @@
|
||||
"type": "frame",
|
||||
"id": "yunXY",
|
||||
"name": "generateBtn",
|
||||
"context": "Generate Video button. Fixed at screen bottom. Click flow: image picker -> API. See docs/generate_video.md.",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "#8B5CF6",
|
||||
@ -3614,6 +3685,7 @@
|
||||
"type": "frame",
|
||||
"id": "RGKvY",
|
||||
"name": "creditsCost",
|
||||
"context": "Credits cost for current resolution. Updates when resolution (oYwsm) changes.",
|
||||
"fill": "#FFFFFF30",
|
||||
"cornerRadius": 8,
|
||||
"gap": 4,
|
||||
@ -3803,6 +3875,7 @@
|
||||
"type": "frame",
|
||||
"id": "rSN3T",
|
||||
"name": "videoDisplay",
|
||||
"context": "Video area: display uploaded image with crop (fill/cover). Matches progress screen.",
|
||||
"clip": true,
|
||||
"width": "fill_container",
|
||||
"height": 360,
|
||||
@ -3842,6 +3915,7 @@
|
||||
"type": "text",
|
||||
"id": "Eghqc",
|
||||
"name": "progressLabel",
|
||||
"context": "Progress label: 1=队列中 2=处理中 3=完成 4=超时 5=错误 6=中止. Updated from /v1/image/progress every 1s.",
|
||||
"fill": "#18181B",
|
||||
"content": "Generating...",
|
||||
"fontFamily": "Inter",
|
||||
|
||||
@ -1,146 +1,173 @@
|
||||
# petsHeroAI 接口调用流程说明
|
||||
|
||||
## 一、整体流程
|
||||
本文档说明客户端请求的加解密与代理流程,以及**按业务场景**的接口调用顺序。具体字段以 **petsHeroAI_client_guide.md** 为准。
|
||||
|
||||
---
|
||||
|
||||
## 一、请求流程(所有接口统一)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 客户端请求流程 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
业务参数(原始字段)
|
||||
业务参数(V2 字段名)
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 字段名映射 │ body / params / headers 中的原始字段 → V2 字段
|
||||
│ V2 包装 │ body → arsenal/vault/tome/codex/grimoire/sanctum
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ V2 包装 │ 将 body 包装为 arsenal/vault/tome/codex/grimoire/sanctum 结构
|
||||
│ JSON 序列化 │ headers / queryParams / body 分别序列化
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ JSON 序列化 │
|
||||
│ AES+Base64 │ 各字段 AES-128-ECB、PKCS5 填充后 Base64
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ AES+Base64 │ AES-128-ECB, PKCS5Padding 加密
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 构造代理请求 │ 填入 hero_class, pet_species, power_level 等参数
|
||||
│ 构造代理请求 │ hero_class, pet_species(path), power_level(method),
|
||||
│ │ quest_rank(headers), battle_score(queryParams),
|
||||
│ │ loyalty_index(body) + 噪音字段
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
POST {baseUrl}/quester/defender/summoner
|
||||
```
|
||||
|
||||
- **path**(如 `/v1/user/fast_login`)加密后放在 **pet_species**。
|
||||
- **method**(GET/POST)加密后放在 **power_level**。
|
||||
- 登录后 **knight**(userToken)由 ProxyClient 自动写入请求头并参与加密。
|
||||
|
||||
---
|
||||
|
||||
## 二、响应处理流程
|
||||
|
||||
```
|
||||
POST 代理入口响应
|
||||
POST 代理入口响应(密文 body)
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 提取密文 │ 从响应中获取加密字段
|
||||
└─────────────┘
|
||||
Base64 解码 → AES 解密 → JSON 解析
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Base64 解码 │
|
||||
└─────────────┘
|
||||
取 vault.tome.codex.grimoire.sanctum
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ AES 解密 │ AES-128-ECB 解密
|
||||
└─────────────┘
|
||||
helm → code, rampart → msg, sidekick → data
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ JSON 解析 │
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 字段逆映射 │ V2 字段 → 原始字段 (便于业务使用)
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
业务数据 (code/msg/data)
|
||||
ApiResponse(code, msg, data)
|
||||
```
|
||||
|
||||
## 三、接口分类与调用顺序
|
||||
---
|
||||
|
||||
### 3.1 登录与用户
|
||||
## 三、按业务场景的接口调用顺序
|
||||
|
||||
### 3.1 应用启动与登录(AuthService.init)
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/user/fast_login` | POST | 设备快速登录,获取 userToken |
|
||||
| 2 | `/v1/user/common_info` | GET | 获取用户通用信息(含积分、头像等) |
|
||||
| 3 | `/v1/user/account` | GET | 获取用户账户信息 |
|
||||
| 4 | `/v1/user/referrer` | POST | 归因上报 |
|
||||
| 5 | `/v1/user/delete` | GET | 注销账户 |
|
||||
| 1 | `/v1/user/fast_login` | POST | 设备快速登录;body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 |
|
||||
| 2 | (保存 token、用户信息到 UserState;首次登录打点 register) | — | — |
|
||||
| 3 | `/v1/user/referrer` | POST | 归因上报;query: sentinel, asset(uid), portal;body: digest, origin。 |
|
||||
| 4 | `/v1/user/common_info` | GET | 获取用户通用信息;query: sentinel, asset(uid)。解析 data 写入 UserState,并解析 surge 中 enable_third_party_payment 等。 |
|
||||
|
||||
### 3.2 支付
|
||||
**调用处**:`lib/core/auth/auth_service.dart`(init,登录成功后顺序执行)。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 充值 / 支付
|
||||
|
||||
| 场景 | 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| 进入充值页 | 1 | `/v1/payment/getGooglePayActivities` 或 `/v1/payment/getApplePayActivities` | GET | 按平台获取商品列表;data.summon 为列表,单项含 helm(商品ID)、warrior(activityId)、guardian、curriculum、forge 等。 |
|
||||
| 用户点 Buy(第三方支付开) | 2 | `/v1/payment/get-payment-methods` | POST | body: warrior(activityId), vambrace(国家可选)。返回 data.renew 支付方式列表。 |
|
||||
| 用户选支付方式后 | 3 | `/v1/payment/createPayment` | POST | query: sentinel, asset(userId);body: sentinel, asset, warrior, resource, ceremony。返回 federation(订单ID)、convert(payUrl)。 |
|
||||
| 若选中 Google Pay | 4 | 调起 Google Play 内购,成功后 | — | 使用 serverVerificationData 作为 merchant。 |
|
||||
| | 5 | `/v1/payment/googlepay` | POST | body: merchant(凭据), federation(订单ID), asset(userId)。 |
|
||||
| 若选其他支付方式 | — | 打开 createPayment 返回的 convert(payUrl) | — | 在外部浏览器完成支付。 |
|
||||
| 用户点 Buy(第三方支付关) | — | 不调 2、3,直接调起 Google Play 内购(商品 ID = helm) | — | 无 createPayment / googlepay。 |
|
||||
|
||||
**调用处**:`lib/features/recharge/recharge_screen.dart`、`PaymentApi`、`GooglePlayPurchaseService`。详见 **docs/payment_flow.md**。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 生成视频(图生视频)
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/payment/getGooglePayActivities` | GET | 获取 Google 商品列表 |
|
||||
| 2 | `/v1/payment/getApplePayActivities` | GET | 获取 Apple 商品列表 |
|
||||
| 3 | `/v1/payment/createPayment` | POST | 创建支付订单 |
|
||||
| 4 | `/v1/payment/googlepay` | POST | Google 支付结果回调 |
|
||||
| 5 | `/v1/payment/applepay` | POST | Apple 支付结果回调 |
|
||||
| 6 | `/v1/payment/getPaymentDetailList` | GET | 获取支付订单列表 |
|
||||
| 1 | `/v1/image/upload-presigned-url` | POST | 获取上传 URL;body: gateway(fileName1), action(fileName2), pauldron(contentType), stronghold(expectedSize)。返回的 URL 用于**直接 PUT 上传图片**(不经过代理)。 |
|
||||
| 2 | (客户端 PUT 上传图片到 presigned URL) | PUT | 不经过代理,请求头按接口要求设置。 |
|
||||
| 3 | `/v1/image/create-task` | POST | query: asset;body: commission(srcImg1Url 等)、guild(分辨率) 等。返回任务信息。 |
|
||||
| 4 | `/v1/image/progress` | GET | query: sentinel, tree(taskId), asset。进度页**轮询**(如每 1s),直到 state 为 3(完成)/4/5/6。 |
|
||||
| 5 | (生成完成后)`/v1/user/account` | GET | 刷新用户积分。 |
|
||||
|
||||
### 3.3 图片生成
|
||||
**调用处**:`lib/features/generate_video/generate_video_screen.dart`(1→2→3)、`generate_progress_screen.dart`(4、5)。
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
---
|
||||
|
||||
### 3.4 首页与图库
|
||||
|
||||
| 场景 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/image/prompt/recomends` | GET | 获取推荐提示词 |
|
||||
| 2 | `/v1/image/txt2img_tags` | GET | 获取文生图标签 |
|
||||
| 3 | `/v1/image/txt2img_prompts` | POST | 获取文生图提示词模板 |
|
||||
| 4 | `/v1/image/txt2img_create` | POST | 创建文生图任务 |
|
||||
| 5 | `/v1/image/progress` | GET | 查询图片生成进度 |
|
||||
| 首页分类/列表 | `/v1/image/img2video/categories` | GET | 获取图转视频分类。 |
|
||||
| | `/v1/image/img2video/tasks` | GET | 获取图转视频任务列表;可选 query: insignia(categoryId)。 |
|
||||
| 图库「我的」 | `/v1/image/my-tasks` | GET | 获取我的任务列表;query: sentinel, trophy/heatmap/platoon 等。 |
|
||||
|
||||
### 3.4 图转视频
|
||||
**调用处**:`lib/features/home/home_screen.dart`、`lib/features/gallery/gallery_screen.dart`。
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/image/img2Video_pose_template` | GET | 获取图转视频姿态模板 |
|
||||
| 2 | `/v1/image/img2video_pose_task` | POST | 创建图转视频姿态任务 |
|
||||
| 3 | `/v1/image/progress` | GET | 查询任务进度 |
|
||||
---
|
||||
|
||||
### 3.5 换衣 / 换脸
|
||||
|
||||
| 顺序 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/v1/image/clothes_template` | GET | 获取换衣模板 |
|
||||
| 2 | `/v1/image/clothes_swap_ex` | POST | 创建换衣任务 |
|
||||
| 3 | `/v1/image/faceswap_task` | POST | 创建换脸任务 |
|
||||
| 4 | `/v1/image/video_facewap_task` | POST | 创建视频换脸任务 |
|
||||
|
||||
### 3.6 其他
|
||||
### 3.5 其他
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/v1/image/category-list` | GET | 获取分类列表 |
|
||||
| `/v1/log/appevent` | POST | App 事件打点上报 |
|
||||
| `/v1/image/getCreditsPageInfo` | GET | 获取积分页面信息 |
|
||||
| `/v1/log/uploadUrl` | POST | 获取预签名上传 URL |
|
||||
| `/v1/user/account` | GET | 获取账户信息(积分等);个人页、生成视频完成后刷新积分时调用。 |
|
||||
| `/v1/image/getCreditsPageInfo` | GET | 获取积分页信息。 |
|
||||
| `/v1/image/prompt/recomends` | GET | 获取推荐提示词。 |
|
||||
| `/v1/image/txt2img_create` | POST | 创建文生图任务。 |
|
||||
| `/v1/image/img2Video_pose_template` | GET | 获取图转视频姿态模板。 |
|
||||
| `/v1/image/img2video_pose_task` | POST | 创建图转视频姿态任务。 |
|
||||
|
||||
## 四、通用请求头
|
||||
---
|
||||
|
||||
登录后所有请求需携带:
|
||||
## 四、接口路径与客户端封装对照
|
||||
|
||||
| 接口路径 | 方法 | 客户端封装(lib/core/api/services/) |
|
||||
|----------|------|--------------------------------------|
|
||||
| /v1/user/fast_login | POST | UserApi.fastLogin |
|
||||
| /v1/user/referrer | POST | UserApi.referrer |
|
||||
| /v1/user/common_info | GET | UserApi.getCommonInfo |
|
||||
| /v1/user/account | GET | UserApi.getAccount |
|
||||
| /v1/payment/getGooglePayActivities | GET | PaymentApi.getGooglePayActivities |
|
||||
| /v1/payment/getApplePayActivities | GET | PaymentApi.getApplePayActivities |
|
||||
| /v1/payment/get-payment-methods | POST | PaymentApi.getPaymentMethods |
|
||||
| /v1/payment/createPayment | POST | PaymentApi.createPayment |
|
||||
| /v1/payment/googlepay | POST | PaymentApi.googlepay |
|
||||
| /v1/image/img2video/categories | GET | ImageApi.getCategoryList |
|
||||
| /v1/image/img2video/tasks | GET | ImageApi.getImg2VideoTasks |
|
||||
| /v1/image/my-tasks | GET | ImageApi.getMyTasks |
|
||||
| /v1/image/upload-presigned-url | POST | ImageApi.getUploadPresignedUrl |
|
||||
| /v1/image/create-task | POST | ImageApi.createTask |
|
||||
| /v1/image/progress | GET | ImageApi.getProgress |
|
||||
| /v1/image/getCreditsPageInfo | GET | ImageApi.getCreditsPageInfo |
|
||||
| /v1/image/prompt/recomends | GET | ImageApi.getPromptRecommends |
|
||||
| /v1/image/txt2img_create | POST | ImageApi.createTxt2Img |
|
||||
| /v1/image/img2Video_pose_template | GET | ImageApi.getImg2VideoPoseTemplates |
|
||||
| /v1/image/img2video_pose_task | POST | ImageApi.createImg2VideoPose |
|
||||
|
||||
---
|
||||
|
||||
## 五、通用请求头(登录后)
|
||||
|
||||
| 原始字段 | V2 字段 | 说明 |
|
||||
|----------|---------|------|
|
||||
| pkg | portal | 应用包名,必填,如 `com.petsheroai.app` |
|
||||
| User_token | knight | 用户登录 token |
|
||||
| pkg | portal | 应用包名,必填;ProxyClient 自动带。 |
|
||||
| User_token | knight | 用户 token;登录成功后 ProxyClient 自动带。 |
|
||||
|
||||
## 五、通用响应结构
|
||||
---
|
||||
|
||||
## 六、通用响应结构(解密后业务层)
|
||||
|
||||
```json
|
||||
{
|
||||
@ -150,7 +177,9 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 六、错误码
|
||||
---
|
||||
|
||||
## 七、错误码
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# UI 开发流程
|
||||
当点击中间upload an image as the base for generation 区域的时候,调用手机相册功能选择一张图片,一次只允许一张。用户选完图片后图片显示在 upload an image as the base for generation区域,并隐藏提示信息。
|
||||
当点击Generate Video的时候如果用户没有选择图片择提示用户选择图片,如果已经选择了图片则开始按下面的步骤调用接口
|
||||
# UI逻辑
|
||||
1.点击Generate Video的时候先进入选择图片的流程,可以是拍照或相册选择。
|
||||
2.选择完图片后进入接口调用步骤
|
||||
# 接口调用
|
||||
## 第一步
|
||||
通过/v1/image/upload-presigned-url接口获取文件上传地址,通常是uploadUrl1或者uploadUrl2,以及对应的filePath1和filePath2用于生成视频接口的入参
|
||||
|
||||
76
docs/google_pay_product_not_found.md
Normal file
76
docs/google_pay_product_not_found.md
Normal file
@ -0,0 +1,76 @@
|
||||
# 谷歌支付「系统无法找到您要购买的商品」排查
|
||||
|
||||
应用内购时提示「系统无法找到您要购买的商品」,而 Play 后台已配置商品,常见原因如下。
|
||||
|
||||
---
|
||||
|
||||
## 1. 商品 ID 不一致(最常见)
|
||||
|
||||
- **来源**:客户端请求的商品 ID 来自接口 **getGooglePayActivities** 返回的 **helm**(产品代码),即 `ActivityItem.code`。
|
||||
- **要求**:该 ID 必须与 Google Play Console 里「创收」→「应用内商品」中配置的 **产品 ID** 完全一致(区分大小写、无多余空格)。
|
||||
- **排查**:
|
||||
- 看日志:`[GooglePlayPurchase] 谷歌支付请求商品 ID(helm): "xxx"` 和 `notFoundIDs=xxx`。
|
||||
- 在 Play Console 打开对应应用 → 创收 → 应用内商品,对照「产品 ID」是否与日志里的 `productId` 一字不差。
|
||||
- **处理**:要么改后台/接口返回的 **helm** 与 Play 产品 ID 一致,要么在 Play 后台新建/修改产品,使产品 ID 与 helm 一致。
|
||||
|
||||
---
|
||||
|
||||
## 2. 应用未上架到任意版本轨道
|
||||
|
||||
- 应用必须至少发布到 **内部测试 / 封闭测试 / 开放测试 / 生产** 中的一条轨道,应用内商品才会对设备可见。
|
||||
- **排查**:Play Console → 发布 → 查看是否有版本在任一轨道上。
|
||||
- **处理**:上传 AAB 并发布到至少「内部测试」轨道;用于测试的账号需加入该轨道的测试人员名单。
|
||||
|
||||
---
|
||||
|
||||
## 3. 应用签名与 Play 不一致
|
||||
|
||||
- 设备上的应用必须用与 Play 登记一致的签名(或已加入「应用签名」的上传密钥)签名,否则 Play 不会返回该应用的商品。
|
||||
- **排查**:
|
||||
- 正式包:是否用 Play 后台「应用签名」里显示的签名(或已登记的上传密钥)签名。
|
||||
- 调试包:若用 debug 签名测试,需在 Play Console 把 **SHA-1(或 SHA-256)** 加入该应用的「许可证测试」或使用同一签名。
|
||||
- **处理**:使用与 Play 一致的 keystore 打 release 包;调试时确保测试设备上的签名已被 Play 接受(或使用内部测试并加入测试账号)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 商品未激活或未保存
|
||||
|
||||
- Play 后台新建的应用内商品需 **激活** 并 **保存**,否则不会出现在查询结果中。
|
||||
- **排查**:创收 → 应用内商品 → 对应商品状态是否为「已激活」。
|
||||
- **处理**:保存并激活商品,等待一段时间(通常几分钟到几小时)再试。
|
||||
|
||||
---
|
||||
|
||||
## 5. 地区 / 账号
|
||||
|
||||
- 商品可能仅在某些国家/地区提供;设备上的 Google 账号所在地区可能不在支持范围内。
|
||||
- 测试账号需有权限:内部测试等需将账号加入测试人员列表。
|
||||
- **排查**:设备 Google 账号地区、是否在测试名单内。
|
||||
- **处理**:在 Play Console 为商品勾选对应国家/地区;用已加入测试的账号登录设备。
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端返回的 helm 与 Play 产品 ID 的映射
|
||||
|
||||
- 若后端 **getGooglePayActivities** 返回的 **helm** 是内部编码(例如活动 ID),而不是 Play 的「产品 ID」,就会导致查不到商品。
|
||||
- **处理**:确保 **helm** 字段就是该商品在 Play Console 里配置的「产品 ID」;如有映射表,需在服务端把活动/内部 ID 映射成真实的 Play 产品 ID 再填入 helm。
|
||||
|
||||
---
|
||||
|
||||
## 快速核对清单
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|--------|------|
|
||||
| 产品 ID 一致 | 日志中的 productId 与 Play「应用内商品」→「产品 ID」完全一致 |
|
||||
| 应用已发布 | 至少一条轨道(如内部测试)有版本 |
|
||||
| 签名一致 | 安装包签名与 Play 应用签名/上传密钥一致 |
|
||||
| 商品已激活 | 应用内商品已保存并激活 |
|
||||
| 测试账号 | 使用已加入测试的 Google 账号、账号地区在商品支持范围内 |
|
||||
|
||||
---
|
||||
|
||||
## 本应用中的日志
|
||||
|
||||
- 发起购买时会打日志:`[GooglePlayPurchase] 谷歌支付请求商品 ID(helm): "xxx"`。
|
||||
- 若商品未找到会打:`商品未找到: 请求的 productId="xxx", notFoundIDs=[xxx]`。
|
||||
- 根据上述 productId 与 notFoundIDs 到 Play 后台逐项对照「产品 ID」即可定位是否 ID 不一致。
|
||||
34
docs/googlepay.md
Normal file
34
docs/googlepay.md
Normal file
@ -0,0 +1,34 @@
|
||||
# 支付界面
|
||||
recharge_screen.dart
|
||||
```
|
||||
"extrapolate": "", // note (string) 备注
|
||||
"helm": "", // code (string) 产品代码
|
||||
"forge": 0, // bonus (int) 赠送积分
|
||||
"guardian": "", // actualAmount (string) 实际金额
|
||||
"lead": "", // discountOff (string) 折扣
|
||||
"glossary": "", // title (string) 标题
|
||||
"curriculum": "", // originAmount (string) 原价
|
||||
"warrior": "", // activityId (string) 活动ID
|
||||
"distribute": "", // subscriptionPeriod (string) 订阅周期
|
||||
"greaves": 0, // credits (int) 积分数
|
||||
"shield": "", // client (string) 客户端
|
||||
"species": 0, // days (int) 天数
|
||||
"familiar": "", // currency (string) 货币
|
||||
"subtract": 0 // productType (int) 产品类型
|
||||
```
|
||||
其中helm 为google pay的商品ID
|
||||
## 界面要展示
|
||||
1.价格需显示$符号 字段guardian
|
||||
2.原价需显示中划线 字段curriculum
|
||||
3.赠送积分 字段forge
|
||||
|
||||
# 购买
|
||||
从/v1/user/common_info接口里面取到 enable_third_party_payment字段如果这个字段为true则调用第三方支付
|
||||
## 三方支付流程
|
||||
获取商品列表/v1/payment/getGooglePayActivities ->
|
||||
传activityId获取支付列表/v1/payment/get-payment-methods ->
|
||||
创建订单的时候把选择的支付方式的字段填充,创建订单/v1/payment/createPayment
|
||||
创建订单传app、userId、activityId、paymentMethod、subPaymentMethod(有的话),其他那些先不管
|
||||
|
||||
# 谷歌支付回调接口
|
||||
/v1/payment/googlepay
|
||||
135
docs/payment_flow.md
Normal file
135
docs/payment_flow.md
Normal file
@ -0,0 +1,135 @@
|
||||
# 支付流程(当前实现)
|
||||
|
||||
本文档描述充值页「Buy」点击后的完整支付流程,与 `recharge_screen.dart`、`PaymentApi`、`GooglePlayPurchaseService` 实现一致。
|
||||
|
||||
---
|
||||
|
||||
## 1. 流程总览
|
||||
|
||||
```
|
||||
用户点击 Buy
|
||||
│
|
||||
├─ enable_third_party_payment === true 且已登录
|
||||
│ │
|
||||
│ ├─ getPaymentMethods(activityId)
|
||||
│ ├─ 弹窗选择支付方式(_PaymentMethodDialog)
|
||||
│ ├─ createPayment(activityId, productId, paymentMethod, subPaymentMethod)
|
||||
│ │
|
||||
│ ├─ 若选中的是 Google Pay(resource/ceremony == "GooglePay")
|
||||
│ │ ├─ 调起 Google Play 内购(productId = item.code)
|
||||
│ │ ├─ 拿到 serverVerificationData
|
||||
│ │ └─ POST /v1/payment/googlepay(merchant, federation, asset)
|
||||
│ │
|
||||
│ └─ 否则(其他支付方式)
|
||||
│ └─ 打开 createPayment 返回的 payUrl(convert)在外部浏览器完成支付
|
||||
│
|
||||
└─ enable_third_party_payment !== true 或未登录
|
||||
└─ 仅 Android:直接调起 Google Play 内购(productId = item.code),无 createPayment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 支付分支依据
|
||||
|
||||
- **数据来源**:`/v1/user/common_info` 响应中的 **surge**(JSON 字符串),解析得到 **enable_third_party_payment**。
|
||||
- **客户端状态**:`UserState.enableThirdPartyPayment`(登录后由 AuthService 从 common_info 写入)。
|
||||
- **分支**:
|
||||
- **第三方支付**:`enable_third_party_payment == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。
|
||||
- **直接谷歌支付**:否则(未开三方或未登录)→ 仅 Android 下直接调起 Google Play 内购,不调 getPaymentMethods / createPayment。
|
||||
|
||||
---
|
||||
|
||||
## 3. 支付界面与商品展示
|
||||
|
||||
- **界面**:`recharge_screen.dart`。
|
||||
- **商品来源**:`GET /v1/payment/getGooglePayActivities`(Android)或 getApplePayActivities(iOS),列表为 data.summon(activitys)。
|
||||
- **单条商品字段(V2 映射)**:
|
||||
|
||||
| 字段(映射后) | 说明 |
|
||||
|----------------|------|
|
||||
| helm | 产品代码,即 **Google Pay 商品 ID**(与 Play 后台「产品 ID」必须一致) |
|
||||
| warrior | 活动 ID(activityId),getPaymentMethods / createPayment 必传 |
|
||||
| guardian | 实际金额,界面带 **$** 显示 |
|
||||
| curriculum | 原价,界面**中划线**显示 |
|
||||
| forge | 赠送积分,界面展示 |
|
||||
| glossary | 标题;greaves=积分数,familiar=货币等 |
|
||||
|
||||
- **界面规则**:价格 / 原价 / 赠送积分同一行展示;仅当前点击的 item 的 Buy 按钮显示 loading(_loadingProductId)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 第三方支付流程(enable_third_party_payment === true)
|
||||
|
||||
### 4.1 步骤顺序
|
||||
|
||||
| 步骤 | 说明 |
|
||||
|------|------|
|
||||
| 1 | 用户点击某商品的 Buy,得到该商品的 **activityId**(warrior)、**productId**(helm/code)。 |
|
||||
| 2 | **POST /v1/payment/get-payment-methods**,body:warrior=activityId,vambrace=国家(可选)。 |
|
||||
| 3 | 弹窗 **支付方式列表**(renew),用户选择一项 → 得到 resource(paymentMethod)、ceremony(subPaymentMethod)。 |
|
||||
| 4 | **POST /v1/payment/createPayment**,body:sentinel, asset(userId), warrior(activityId), resource, ceremony;得到 federation(订单 ID)、convert(payUrl)等。 |
|
||||
| 5a | 若 **resource 或 ceremony 为 "GooglePay"**(不区分大小写):调起 Google Play 内购(productId=item.code),成功后用 serverVerificationData 作为 merchant 调用 **POST /v1/payment/googlepay**(merchant, federation, asset),不打开 payUrl。 |
|
||||
| 5b | 否则:若有 **convert**(payUrl)则用 url_launcher 在外部浏览器打开;无则仅提示订单已创建。 |
|
||||
|
||||
### 4.2 支付方式弹窗
|
||||
|
||||
- 在 get-payment-methods 成功后,展示 **Select payment method** 弹窗(_PaymentMethodDialog),列表项来自 **renew**(PaymentMethodItem:resource, ceremony, name, icon, recommend 等)。
|
||||
- 用户选择一项后关闭弹窗,用选中的 resource、ceremony 调用 createPayment;取消弹窗则不创建订单并清除 loading。
|
||||
|
||||
### 4.3 选中 Google Pay 时的子流程
|
||||
|
||||
- 条件:`_isGooglePay(paymentMethod, subPaymentMethod)` 为 true(即 paymentMethod 或 subPaymentMethod 转为小写后等于 `"googlepay"`)。
|
||||
- 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。
|
||||
- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**:
|
||||
- productId 为当前商品的 **code**(helm)。
|
||||
- 成功返回 **serverVerificationData**(merchant),失败/取消返回 null。
|
||||
- 若有 merchant 且订单 ID(federation)存在,调用 **PaymentApi.googlepay(merchant, federation, asset)**;根据返回提示成功或失败并打点(AdjustEvents)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 直接谷歌支付(enable_third_party_payment !== true)
|
||||
|
||||
- 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchase(item.code)**,不请求 getPaymentMethods / createPayment。
|
||||
- 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。
|
||||
- 非 Android:提示 "Google Pay is only available on Android"。
|
||||
|
||||
---
|
||||
|
||||
## 6. 接口与字段速查
|
||||
|
||||
- **GET /v1/payment/getGooglePayActivities**
|
||||
Query:sentinel, portal;响应 data.summon / data.cleanse,单项含 helm, warrior, guardian, curriculum, forge 等。
|
||||
|
||||
- **POST /v1/payment/get-payment-methods**
|
||||
Body:warrior(activityId), vambrace(可选)。响应 data.renew:每项 resource, ceremony, brigade, greylist, deny 等。
|
||||
|
||||
- **POST /v1/payment/createPayment**
|
||||
Query:sentinel, asset(userId)。Body:sentinel, asset, warrior, resource, ceremony(可选)。
|
||||
响应 data:federation(订单ID), convert(payUrl), destroy(状态), handshake, transplant 等。
|
||||
|
||||
- **POST /v1/payment/googlepay**
|
||||
Query:sentinel, asset(userId)。Body:merchant(购买凭据 serverVerificationData), federation(订单ID), asset, sample(签名可选)。
|
||||
响应 data:federation, line(状态), awaken(是否加积分) 等。
|
||||
|
||||
- 请求/响应 V2 加解密与字段映射以 **petsHeroAI_client_guide.md** 为准。
|
||||
|
||||
---
|
||||
|
||||
## 7. 代码与文档对应
|
||||
|
||||
| 功能 | 位置 |
|
||||
|------|------|
|
||||
| 支付分支与 Buy 入口 | recharge_screen.dart:_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay |
|
||||
| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPayment:PaymentApi.getPaymentMethods → _PaymentMethodDialog |
|
||||
| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrl:createPayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) |
|
||||
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase |
|
||||
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dart:launchPurchaseAndReturnData / launchPurchase;PaymentApi.googlepay |
|
||||
| 商品未找到排查 | docs/google_pay_product_not_found.md |
|
||||
|
||||
---
|
||||
|
||||
## 8. 小结
|
||||
|
||||
- **三方开**:getPaymentMethods → 弹窗选支付方式 → createPayment → 选 Google Pay 则内购 + googlepay 回调,否则打开 payUrl。
|
||||
- **三方关**:仅 Android 直接内购(item.code),无 createPayment。
|
||||
- **商品 ID**:始终使用接口返回的 **helm**(item.code),须与 Google Play 后台「产品 ID」完全一致,否则会出现「系统无法找到您要购买的商品」。
|
||||
124
docs/user_login.md
Normal file
124
docs/user_login.md
Normal file
@ -0,0 +1,124 @@
|
||||
# 用户登录流程
|
||||
|
||||
本文档基于 `docs/petsHeroAI_client_guide.md` 中的登录态接口,整理客户端登录顺序与数据处理要求。
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体顺序
|
||||
|
||||
1. **设备快速登录**:`POST /v1/user/fast_login`,拿到 `userToken` 和用户信息。
|
||||
2. **保存登录态与用户信息**:保存 token、积分、userId、头像、昵称、国家码等;**首次登录**记录为注册日期。
|
||||
3. **归因上报**:`POST /v1/user/referrer`,将归因数据(如从 Adjust 获取的 digest)上报。
|
||||
4. **获取用户通用信息**:`GET /v1/user/common_info`,拉取通用配置并保存(含 `surge` 中 `enable_third_party_payment` 等)。
|
||||
|
||||
登录成功后,后续所有请求需在 Header 中携带 `knight`(即 `User_token` / userToken)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设备快速登录 `POST /v1/user/fast_login`
|
||||
|
||||
- **鉴权**:需要(Header 需带 `portal`,首次无 `knight` 可空或按文档处理)。
|
||||
- **说明**:用设备 ID + 签名做设备登录,返回用户 token 及基础信息。
|
||||
|
||||
### 请求
|
||||
|
||||
| 位置 | 字段(映射后) | 说明 |
|
||||
|--------|----------------|------|
|
||||
| Query | portal | 应用包名(必填) |
|
||||
| Query | crest | 渠道号(可选) |
|
||||
| Query | accolade | 类型(可选) |
|
||||
| Body | digest | 归因信息,如 utm_source=googleplay(可与 Adjust 一致) |
|
||||
| Body | resolution | 签名:**MD5(deviceId) 大写 32 位** |
|
||||
| Body | origin | 设备 ID(deviceId) |
|
||||
|
||||
### 响应(解密后 data / sidekick)
|
||||
|
||||
| 字段(映射后) | 说明 |
|
||||
|----------------|------|
|
||||
| reevaluate | **userToken**,后续请求 Header 的 `knight` |
|
||||
| asset | **userId**,用户 ID |
|
||||
| reveal | **credit**,积分余额 |
|
||||
| realm | 头像 URL |
|
||||
| terminal | 用户名 |
|
||||
| navigate | 国家代码 |
|
||||
| surge | 客户端界面配置(extConfig),JSON 字符串,需按需解析 |
|
||||
| equip | 是否首次注册(firstRegister) |
|
||||
| 其他 | 见接口文档 sidekick 结构 |
|
||||
|
||||
### 客户端必做
|
||||
|
||||
- 保存并设置 **userToken**(如写入请求头 `knight`)。
|
||||
- 保存并更新 **积分**(reveal)、**用户信息**(userId、头像、昵称、国家码等)。
|
||||
- **首次登录**:将该次登录日期记为**注册日期**(用于归因/统计);可本地标记“已注册”避免重复上报。
|
||||
|
||||
---
|
||||
|
||||
## 3. 归因上报 `POST /v1/user/referrer`
|
||||
|
||||
- **鉴权**:需要(Header 带 `portal`、`knight`)。
|
||||
- **说明**:登录成功后调用,上报归因数据;**digest 从 Adjust 取**。
|
||||
|
||||
### 请求
|
||||
|
||||
| 位置 | 字段(映射后) | 说明 |
|
||||
|--------|----------------|------|
|
||||
| Query | sentinel | 应用标识(必填) |
|
||||
| Query | asset | 用户 ID(userId) |
|
||||
| Query | accolade | 类型(可选) |
|
||||
| Query | portal | 应用包名(必填) |
|
||||
| Body | digest | **归因信息,从 Adjust 获取** |
|
||||
| Body | origin | 设备 ID(deviceId) |
|
||||
|
||||
### 响应
|
||||
|
||||
- code=0 表示成功;可解析 msg / data 做提示或日志。
|
||||
- **调用成功后建议增加日志输出**,便于排查归因是否上报成功。
|
||||
|
||||
---
|
||||
|
||||
## 4. 获取用户通用信息 `GET /v1/user/common_info`
|
||||
|
||||
- **鉴权**:需要(Header 带 `portal`、`knight`)。
|
||||
- **说明**:登录成功后调用,拉取通用配置并**保存到全局/本地**。
|
||||
|
||||
### 请求
|
||||
|
||||
| 位置 | 字段(映射后) | 说明 |
|
||||
|--------|----------------|------|
|
||||
| Query | sentinel | 应用标识(必填) |
|
||||
| Query | asset | 用户 ID(userId) |
|
||||
| Query | shield / crest / item / origin / gauntlet / portal | 按需传,见接口文档 |
|
||||
|
||||
### 响应(解密后 data / sidekick)
|
||||
|
||||
- 与 fast_login 的 data 结构类似,含 **surge**(extConfig)、积分、用户信息等。
|
||||
- **surge**:字符串,为 JSON;需 **先 JSON 解析再使用**。
|
||||
- 解析后的对象中,包含 **enable_third_party_payment** 等字段,用于控制第三方支付等能力。
|
||||
- 其他字段(reveal、realm、terminal、navigate 等)按需保存到全局变量或状态。
|
||||
|
||||
### 客户端必做
|
||||
|
||||
- 调用 common_info 并将结果**保存到全局/状态**。
|
||||
- 对 **surge** 做 **JSON decode**,得到对象后读取并保存 **enable_third_party_payment** 等配置。
|
||||
|
||||
---
|
||||
|
||||
## 5. 流程小结
|
||||
|
||||
```
|
||||
APP 启动
|
||||
→ 获取 deviceId,计算 sign = MD5(deviceId) 大写 32 位
|
||||
→ 可选:从 Adjust 获取归因 digest
|
||||
→ POST /v1/user/fast_login(body: digest, resolution, origin;query: portal, crest, accolade)
|
||||
→ 若成功:
|
||||
1. 保存 userToken(knight)、userId(asset)、积分(reveal)、头像/昵称/国家码等
|
||||
2. 若首次登录:记录注册日期并标记已注册
|
||||
3. POST /v1/user/referrer(body: digest 从 Adjust 取, origin;query: sentinel, asset, portal)
|
||||
→ 成功后打日志
|
||||
4. GET /v1/user/common_info(query: sentinel, asset)
|
||||
→ 将结果保存到全局
|
||||
→ 对 data.surge 做 JSON decode,保存 enable_third_party_payment 等
|
||||
→ 之后所有请求 Header 带 knight = userToken
|
||||
```
|
||||
|
||||
接口的 V2 请求体/响应体、加解密与字段映射以 **petsHeroAI_client_guide.md** 为准。
|
||||
@ -4,8 +4,8 @@ FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_TARGET=lib/main.dart
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=1.0.0
|
||||
FLUTTER_BUILD_NUMBER=1
|
||||
FLUTTER_BUILD_NAME=1.0.2
|
||||
FLUTTER_BUILD_NUMBER=3
|
||||
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
||||
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
||||
DART_OBFUSCATION=false
|
||||
|
||||
@ -5,8 +5,8 @@ export "FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_TARGET=lib/main.dart"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||
export "FLUTTER_BUILD_NUMBER=1"
|
||||
export "FLUTTER_BUILD_NAME=1.0.2"
|
||||
export "FLUTTER_BUILD_NUMBER=3"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
|
||||
@ -30,6 +30,12 @@
|
||||
@import image_picker_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<in_app_purchase_storekit/InAppPurchasePlugin.h>)
|
||||
#import <in_app_purchase_storekit/InAppPurchasePlugin.h>
|
||||
#else
|
||||
@import in_app_purchase_storekit;
|
||||
#endif
|
||||
|
||||
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
|
||||
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
|
||||
#else
|
||||
@ -42,6 +48,12 @@
|
||||
@import sqflite_darwin;
|
||||
#endif
|
||||
|
||||
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
|
||||
#import <url_launcher_ios/URLLauncherPlugin.h>
|
||||
#else
|
||||
@import url_launcher_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<video_player_avfoundation/FVPVideoPlayerPlugin.h>)
|
||||
#import <video_player_avfoundation/FVPVideoPlayerPlugin.h>
|
||||
#else
|
||||
@ -54,6 +66,12 @@
|
||||
@import video_thumbnail;
|
||||
#endif
|
||||
|
||||
#if __has_include(<webview_flutter_wkwebview/WebViewFlutterPlugin.h>)
|
||||
#import <webview_flutter_wkwebview/WebViewFlutterPlugin.h>
|
||||
#else
|
||||
@import webview_flutter_wkwebview;
|
||||
#endif
|
||||
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
@ -61,10 +79,13 @@
|
||||
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
||||
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
|
||||
[VideoThumbnailPlugin registerWithRegistrar:[registry registrarForPlugin:@"VideoThumbnailPlugin"]];
|
||||
[WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
51
lib/app.dart
51
lib/app.dart
@ -12,6 +12,7 @@ import 'features/home/models/task_item.dart';
|
||||
import 'features/profile/profile_screen.dart';
|
||||
import 'features/recharge/recharge_screen.dart';
|
||||
import 'shared/widgets/bottom_nav_bar.dart';
|
||||
import 'shared/tab_selector_scope.dart';
|
||||
|
||||
/// Root app widget with navigation
|
||||
class App extends StatefulWidget {
|
||||
@ -27,33 +28,40 @@ class _AppState extends State<App> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UserCreditsScope(
|
||||
child: MaterialApp(
|
||||
title: 'AI Video App',
|
||||
theme: AppTheme.light,
|
||||
debugShowCheckedModeBanner: false,
|
||||
initialRoute: '/',
|
||||
builder: (context, child) {
|
||||
return SafeArea(
|
||||
top: true,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: false,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
routes: {
|
||||
'/': (_) => _MainScaffold(
|
||||
currentTab: _currentTab,
|
||||
onTabSelected: (tab) => setState(() => _currentTab = tab),
|
||||
),
|
||||
child: TabSelectorScope(
|
||||
selectTab: (tab) => setState(() => _currentTab = tab),
|
||||
child: MaterialApp(
|
||||
title: 'AI Video App',
|
||||
theme: AppTheme.light,
|
||||
debugShowCheckedModeBanner: false,
|
||||
initialRoute: '/',
|
||||
builder: (context, child) {
|
||||
return SafeArea(
|
||||
top: true,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: false,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
routes: {
|
||||
'/': (_) => _MainScaffold(
|
||||
currentTab: _currentTab,
|
||||
onTabSelected: (tab) => setState(() => _currentTab = tab),
|
||||
),
|
||||
'/recharge': (_) => const RechargeScreen(),
|
||||
'/generate': (ctx) {
|
||||
final task = ModalRoute.of(ctx)?.settings.arguments as TaskItem?;
|
||||
return GenerateVideoScreen(task: task);
|
||||
},
|
||||
'/progress': (ctx) {
|
||||
final taskId = ModalRoute.of(ctx)?.settings.arguments;
|
||||
return GenerateProgressScreen(taskId: taskId);
|
||||
final args = ModalRoute.of(ctx)?.settings.arguments;
|
||||
final taskId = args is Map ? args['taskId'] : args;
|
||||
final imagePath = args is Map ? args['imagePath'] as String? : null;
|
||||
return GenerateProgressScreen(
|
||||
taskId: taskId,
|
||||
imagePath: imagePath,
|
||||
);
|
||||
},
|
||||
'/result': (ctx) {
|
||||
final mediaItem =
|
||||
@ -61,6 +69,7 @@ class _AppState extends State<App> {
|
||||
return GenerationResultScreen(mediaItem: mediaItem);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,7 +15,8 @@ abstract final class ApiConfig {
|
||||
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
|
||||
|
||||
/// 生产环境域名
|
||||
static const String prodBaseUrl = 'https://ai.petsheroai.xyz';
|
||||
static const String prodBaseUrl =
|
||||
'https://pre-ai.petsheroai.xyz'; //https://ai.petsheroai.xyz
|
||||
|
||||
/// 代理入口路径
|
||||
static const String proxyPath = '/quester/defender/summoner';
|
||||
|
||||
@ -3,13 +3,165 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../log/app_logger.dart';
|
||||
import 'api_config.dart';
|
||||
import 'api_crypto.dart';
|
||||
|
||||
const _logTag = '[ProxyClient]';
|
||||
final _proxyLog = AppLogger('ProxyClient');
|
||||
|
||||
/// 单条日志最大长度;同一 JSON 分多条时按行切分,保持每行完整从而对齐不变
|
||||
const int _maxLogChunk = 1000;
|
||||
|
||||
/// 在 [str] 中从 [start] 起找与 [open] 匹配的括号位置,尊重字符串内不配对。返回匹配的 ] 或 } 的下标,未找到返回 -1。
|
||||
int _findMatchingBracket(String str, int start, String open) {
|
||||
final close = open == '{' ? '}' : ']';
|
||||
int depth = 1;
|
||||
int i = start + 1;
|
||||
while (i < str.length) {
|
||||
final c = str[i];
|
||||
if (c == '"') {
|
||||
i = _skipJsonString(str, i);
|
||||
if (i < 0) return -1;
|
||||
continue;
|
||||
}
|
||||
if (c == open) {
|
||||
depth++;
|
||||
} else if (c == close) {
|
||||
depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// 从 [str] 中 [start] 位置的 " 开始,跳过整个 JSON 字符串(处理 \"),返回结束 " 的下一个下标,失败返回 -1。
|
||||
int _skipJsonString(String str, int start) {
|
||||
if (start >= str.length || str[start] != '"') return -1;
|
||||
int i = start + 1;
|
||||
while (i < str.length) {
|
||||
final c = str[i++];
|
||||
if (c == '\\') {
|
||||
if (i < str.length) i++;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// 长文本按行分块输出,每块不超过 [_maxLogChunk];按行切分保证同一 JSON 分多条时缩进对齐。
|
||||
void _logLong(String text) {
|
||||
if (text.isEmpty) return;
|
||||
final lines = text.split('\n');
|
||||
if (lines.length == 1 && text.length <= _maxLogChunk) {
|
||||
_proxyLog.d(text);
|
||||
return;
|
||||
}
|
||||
final buffer = StringBuffer();
|
||||
int chunkIndex = 0;
|
||||
for (final line in lines) {
|
||||
final lineWithNewline = buffer.isEmpty ? line : '\n$line';
|
||||
if (buffer.length + lineWithNewline.length > _maxLogChunk && buffer.isNotEmpty) {
|
||||
chunkIndex++;
|
||||
_proxyLog.d('(part $chunkIndex)\n$buffer');
|
||||
buffer.clear();
|
||||
buffer.write(line);
|
||||
} else {
|
||||
if (buffer.isNotEmpty) buffer.write('\n');
|
||||
buffer.write(line);
|
||||
}
|
||||
}
|
||||
if (buffer.isNotEmpty) {
|
||||
chunkIndex++;
|
||||
_proxyLog.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 遇到 { 或 [ 视为 JSON 开始,直到与之匹配的 } 或 ] 结束;格式化后单条不超过 1000 字,分条时按行切分保持对齐。
|
||||
void logWithEmbeddedJson(Object? msg) {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
if (msg is! String) {
|
||||
_proxyLog.d(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
final String str = msg.trim();
|
||||
final sb = StringBuffer();
|
||||
|
||||
void write(String line) {
|
||||
sb.writeln(line);
|
||||
}
|
||||
|
||||
void printJson(dynamic value, [int indent = 0]) {
|
||||
final pad = ' ' * indent;
|
||||
final padInner = ' ' * (indent + 1);
|
||||
if (value is Map<String, dynamic>) {
|
||||
write('$pad{');
|
||||
value.forEach((k, v) {
|
||||
if (v is Map || v is List) {
|
||||
write('$padInner"$k":');
|
||||
printJson(v, indent + 1);
|
||||
} else {
|
||||
write('$padInner"$k": ${json.encode(v)}');
|
||||
}
|
||||
});
|
||||
write('$pad}');
|
||||
} else if (value is List) {
|
||||
write('$pad[');
|
||||
for (final item in value) {
|
||||
printJson(item, indent + 1);
|
||||
}
|
||||
write('$pad]');
|
||||
} else {
|
||||
write('$pad${json.encode(value)}');
|
||||
}
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
int lastEnd = 0;
|
||||
|
||||
while (i < str.length) {
|
||||
final c = str[i];
|
||||
if (c == '"') {
|
||||
final next = _skipJsonString(str, i);
|
||||
if (next > 0) {
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == '{' || c == '[') {
|
||||
final end = _findMatchingBracket(str, i, c);
|
||||
if (end >= 0) {
|
||||
final prefix = str.substring(lastEnd, i).trim();
|
||||
if (prefix.isNotEmpty) write(prefix);
|
||||
final jsonStr = str.substring(i, end + 1);
|
||||
try {
|
||||
final parsed = json.decode(jsonStr);
|
||||
printJson(parsed, 0);
|
||||
} catch (e) {
|
||||
write(jsonStr);
|
||||
}
|
||||
lastEnd = end + 1;
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
final trailing = str.substring(lastEnd).trim();
|
||||
if (trailing.isNotEmpty) write(trailing);
|
||||
|
||||
final out = sb.toString().trim();
|
||||
if (out.isNotEmpty) _logLong(out);
|
||||
}
|
||||
|
||||
void _log(String msg) {
|
||||
if (kDebugMode) debugPrint('$_logTag $msg');
|
||||
logWithEmbeddedJson(msg);
|
||||
}
|
||||
|
||||
/// 代理请求体字段名(统一请求参数)
|
||||
|
||||
@ -44,12 +44,28 @@ abstract final class PaymentApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取支付方式列表(传 activityId)
|
||||
static Future<ApiResponse> getPaymentMethods({
|
||||
required String warrior,
|
||||
String? vambrace,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/payment/get-payment-methods',
|
||||
method: 'POST',
|
||||
body: {
|
||||
'warrior': warrior,
|
||||
if (vambrace != null && vambrace.isNotEmpty) 'vambrace': vambrace,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建支付订单
|
||||
static Future<ApiResponse> createPayment({
|
||||
required String sentinel,
|
||||
required String asset,
|
||||
required String warrior,
|
||||
required String resource,
|
||||
String? ceremony,
|
||||
String? lineage,
|
||||
String? armor,
|
||||
}) async {
|
||||
@ -65,9 +81,33 @@ abstract final class PaymentApi {
|
||||
'asset': asset,
|
||||
'warrior': warrior,
|
||||
'resource': resource,
|
||||
if (ceremony != null && ceremony.isNotEmpty) 'ceremony': ceremony,
|
||||
if (lineage != null) 'lineage': lineage,
|
||||
if (armor != null) 'armor': armor,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Google 支付结果回调(凭据、订单 ID、用户 ID)
|
||||
static Future<ApiResponse> googlepay({
|
||||
required String merchant,
|
||||
required String federation,
|
||||
required String asset,
|
||||
String? sample,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/payment/googlepay',
|
||||
method: 'POST',
|
||||
queryParams: {
|
||||
'sentinel': ApiConfig.appId,
|
||||
'asset': asset,
|
||||
},
|
||||
body: {
|
||||
'merchant': merchant,
|
||||
'federation': federation,
|
||||
'asset': asset,
|
||||
if (sample != null && sample.isNotEmpty) 'sample': sample,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,31 @@ abstract final class UserApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// 归因上报(登录成功后调用,digest 建议从 Adjust 获取,暂无则用 install referrer)
|
||||
static Future<ApiResponse> referrer({
|
||||
required String sentinel,
|
||||
required String asset,
|
||||
required String digest,
|
||||
required String origin,
|
||||
String? accolade,
|
||||
String? portal,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/user/referrer',
|
||||
method: 'POST',
|
||||
queryParams: {
|
||||
'sentinel': sentinel,
|
||||
'asset': asset,
|
||||
if (accolade != null) 'accolade': accolade,
|
||||
'portal': portal ?? ApiConfig.packageName,
|
||||
},
|
||||
body: {
|
||||
'digest': digest,
|
||||
'origin': origin,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取用户通用信息
|
||||
static Future<ApiResponse> getCommonInfo({
|
||||
required String sentinel,
|
||||
|
||||
@ -8,8 +8,10 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../adjust/adjust_events.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../api/api_config.dart';
|
||||
import '../api/proxy_client.dart';
|
||||
import '../api/services/user_api.dart';
|
||||
import '../log/app_logger.dart';
|
||||
import '../referrer/referrer_service.dart';
|
||||
import '../user/user_state.dart';
|
||||
|
||||
@ -17,16 +19,15 @@ import '../user/user_state.dart';
|
||||
class AuthService {
|
||||
AuthService._();
|
||||
|
||||
static const _tag = '[AuthService]';
|
||||
static final _log = AppLogger('AuthService');
|
||||
|
||||
static Future<void>? _loginFuture;
|
||||
|
||||
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
||||
static Future<void> get loginComplete =>
|
||||
_loginFuture ?? Future<void>.value();
|
||||
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
||||
|
||||
static void _log(String msg) {
|
||||
debugPrint('$_tag $msg');
|
||||
static void _logMsg(String msg) {
|
||||
_log.d(msg);
|
||||
}
|
||||
|
||||
/// 获取设备 ID(Android: androidId, iOS: identifierForVendor, Web: fallback)
|
||||
@ -51,6 +52,31 @@ class AuthService {
|
||||
return digest.toString().toUpperCase();
|
||||
}
|
||||
|
||||
/// 将 common_info 响应保存到全局,并解析 surge 中的 enable_third_party_payment
|
||||
static void _saveCommonInfoToState(Map<String, dynamic> data) {
|
||||
final reveal = data['reveal'] as int?;
|
||||
if (reveal != null) UserState.setCredits(reveal);
|
||||
final realm = data['realm'] as String?;
|
||||
if (realm != null && realm.isNotEmpty) UserState.setAvatar(realm);
|
||||
final terminal = data['terminal'] as String?;
|
||||
if (terminal != null && terminal.isNotEmpty) UserState.setUserName(terminal);
|
||||
final navigate = data['navigate'] as String?;
|
||||
if (navigate != null) UserState.setNavigate(navigate);
|
||||
|
||||
final surgeStr = data['surge'] as String?;
|
||||
if (surgeStr != null && surgeStr.isNotEmpty) {
|
||||
try {
|
||||
final surge = json.decode(surgeStr) as Map<String, dynamic>?;
|
||||
if (surge != null) {
|
||||
final enable = surge['enable_third_party_payment'] as bool?;
|
||||
UserState.setEnableThirdPartyPayment(enable);
|
||||
}
|
||||
} catch (e) {
|
||||
_logMsg('surge JSON 解析失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// APP 启动时调用快速登录
|
||||
/// 启动时网络可能未就绪,会延迟后重试
|
||||
static Future<void> init() async {
|
||||
@ -58,7 +84,7 @@ class AuthService {
|
||||
final completer = Completer<void>();
|
||||
_loginFuture = completer.future;
|
||||
|
||||
_log('init: 开始快速登录');
|
||||
_logMsg('init: 开始快速登录');
|
||||
const maxRetries = 3;
|
||||
const retryDelay = Duration(seconds: 2);
|
||||
|
||||
@ -67,20 +93,20 @@ class AuthService {
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
|
||||
final deviceId = await _getDeviceId();
|
||||
_log('init: deviceId=$deviceId');
|
||||
_logMsg('init: deviceId=$deviceId');
|
||||
|
||||
final sign = _computeSign(deviceId);
|
||||
_log('init: sign=$sign');
|
||||
_logMsg('init: sign=$sign');
|
||||
|
||||
final crest = await ReferrerService.getReferrer();
|
||||
if (crest != null && crest.isNotEmpty) {
|
||||
_log('init: crest(referrer)=$crest');
|
||||
_logMsg('init: crest(referrer)=$crest');
|
||||
}
|
||||
|
||||
ApiResponse? res;
|
||||
for (var i = 0; i < maxRetries; i++) {
|
||||
if (i > 0) {
|
||||
_log('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
|
||||
_logMsg('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
|
||||
await Future<void>.delayed(retryDelay);
|
||||
}
|
||||
try {
|
||||
@ -92,23 +118,23 @@ class AuthService {
|
||||
);
|
||||
break;
|
||||
} catch (e) {
|
||||
_log('init: 第 ${i + 1} 次请求失败: $e');
|
||||
_logMsg('init: 第 ${i + 1} 次请求失败: $e');
|
||||
if (i == maxRetries - 1) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (res == null) return;
|
||||
|
||||
_log('init: 登录结果 code=${res.code} msg=${res.msg}');
|
||||
_logMsg('init: 登录结果 code=${res.code} msg=${res.msg}');
|
||||
|
||||
if (res.isSuccess && res.data != null) {
|
||||
final data = res.data as Map<String, dynamic>?;
|
||||
final token = data?['reevaluate'] as String?;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
ApiClient.instance.setUserToken(token);
|
||||
_log('init: 已设置 userToken');
|
||||
_logMsg('init: 已设置 userToken');
|
||||
} else {
|
||||
_log('init: 响应中无 reevaluate (userToken)');
|
||||
_logMsg('init: 响应中无 reevaluate (userToken)');
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final hadLoggedIn = prefs.getBool('adjust_has_logged_in') ?? false;
|
||||
@ -119,17 +145,17 @@ class AuthService {
|
||||
'adjust_register_date',
|
||||
DateTime.now().toIso8601String().substring(0, 10),
|
||||
);
|
||||
_log('init: 首次登录,已上报 register');
|
||||
_logMsg('init: 首次登录,已上报 register');
|
||||
}
|
||||
final credits = data?['reveal'] as int?;
|
||||
if (credits != null) {
|
||||
UserState.setCredits(credits);
|
||||
_log('init: 已同步积分 $credits');
|
||||
_logMsg('init: 已同步积分 $credits');
|
||||
}
|
||||
final uid = data?['asset'] as String?;
|
||||
if (uid != null && uid.isNotEmpty) {
|
||||
UserState.setUserId(uid);
|
||||
_log('init: 已设置 userId');
|
||||
_logMsg('init: 已设置 userId');
|
||||
}
|
||||
final avatarUrl = data?['realm'] as String?;
|
||||
if (avatarUrl != null && avatarUrl.isNotEmpty) {
|
||||
@ -143,12 +169,52 @@ class AuthService {
|
||||
if (countryCode != null) {
|
||||
UserState.setNavigate(countryCode);
|
||||
}
|
||||
|
||||
// 3. 归因上报(digest 从 Adjust 取,暂无则用 install referrer)
|
||||
try {
|
||||
final referrerRes = await UserApi.referrer(
|
||||
sentinel: ApiConfig.appId,
|
||||
asset: uid!,
|
||||
digest: crest ?? '',
|
||||
origin: deviceId,
|
||||
);
|
||||
if (referrerRes.isSuccess) {
|
||||
_logMsg('referrer 上报成功');
|
||||
} else {
|
||||
_logMsg(
|
||||
'referrer 上报失败: code=${referrerRes.code} msg=${referrerRes.msg}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logMsg('referrer 请求异常: $e');
|
||||
}
|
||||
|
||||
// 4. 获取用户通用信息,保存到全局并解析 surge
|
||||
try {
|
||||
final commonRes = await UserApi.getCommonInfo(
|
||||
sentinel: ApiConfig.appId,
|
||||
asset: uid,
|
||||
);
|
||||
if (commonRes.isSuccess && commonRes.data != null) {
|
||||
final commonData = commonRes.data as Map<String, dynamic>?;
|
||||
if (commonData != null) {
|
||||
_saveCommonInfoToState(commonData);
|
||||
_logMsg('common_info 已保存到全局');
|
||||
}
|
||||
_logMsg('common_info 响应:');
|
||||
logWithEmbeddedJson(json.encode(commonRes.data));
|
||||
} else {
|
||||
_logMsg(
|
||||
'common_info 失败: code=${commonRes.code} msg=${commonRes.msg}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logMsg('common_info 请求异常: $e');
|
||||
}
|
||||
} else {
|
||||
_log('init: 登录失败');
|
||||
_logMsg('init: 登录失败');
|
||||
}
|
||||
} catch (e, st) {
|
||||
_log('init: 异常 $e');
|
||||
_log('init: 堆栈 $st');
|
||||
_logMsg('init: 异常 $e');
|
||||
_logMsg('init: 堆栈 $st');
|
||||
} finally {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
}
|
||||
|
||||
40
lib/core/log/app_logger.dart
Normal file
40
lib/core/log/app_logger.dart
Normal file
@ -0,0 +1,40 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
/// 统一应用日志,提升可读性:时间戳、级别、标签、格式化输出。
|
||||
/// 在 release 下仅输出 warning/error,避免泄露信息。
|
||||
///
|
||||
/// 使用示例:
|
||||
/// final _log = AppLogger('GenerateVideo');
|
||||
/// _log.d('task: $task');
|
||||
/// _log.e('Generate failed', e, st);
|
||||
class AppLogger {
|
||||
AppLogger([this.tag = 'App']);
|
||||
|
||||
final String tag;
|
||||
|
||||
static Logger? _logger;
|
||||
|
||||
static Logger get _instance {
|
||||
_logger ??= Logger(
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 0,
|
||||
errorMethodCount: 6,
|
||||
lineLength: 80,
|
||||
colors: true,
|
||||
printEmojis: true,
|
||||
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
|
||||
),
|
||||
level: kReleaseMode ? Level.warning : Level.trace,
|
||||
);
|
||||
return _logger!;
|
||||
}
|
||||
|
||||
String _msg(Object? message) => '[$tag] $message';
|
||||
|
||||
void d(Object? message) => _instance.d(_msg(message));
|
||||
void i(Object? message) => _instance.i(_msg(message));
|
||||
void w(Object? message) => _instance.w(_msg(message));
|
||||
void e(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
_instance.e(_msg(message), error: error, stackTrace: stackTrace);
|
||||
}
|
||||
@ -27,4 +27,10 @@ abstract final class AppColors {
|
||||
static const Color shadowLight = Color(0x14000000); // #00000008
|
||||
static const Color shadowMedium = Color(0x0D000000); // #0000000D
|
||||
static const Color shadowSoft = Color(0x0A000000); // #0000000A
|
||||
|
||||
// Card overlays (from Pencil)
|
||||
static const Color playButtonFill = Color(0xE6FFFFFF); // #FFFFFFE6
|
||||
static const Color playButtonShadow = Color(0x20000000); // #00000020
|
||||
static const Color primaryButtonFill = Color(0xCC8B5CF6); // #8B5CF6CC
|
||||
static const Color primaryButtonShadow = Color(0x408B5CF6); // #8B5CF640
|
||||
}
|
||||
|
||||
@ -10,6 +10,9 @@ class UserState {
|
||||
static final ValueNotifier<String?> userName = ValueNotifier<String?>(null);
|
||||
/// 国家码 (navigate / countryCode)
|
||||
static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null);
|
||||
/// 是否启用第三方支付(来自 common_info surge.enable_third_party_payment)
|
||||
static final ValueNotifier<bool?> enableThirdPartyPayment =
|
||||
ValueNotifier<bool?>(null);
|
||||
|
||||
static void setCredits(int? value) {
|
||||
credits.value = value;
|
||||
@ -31,6 +34,10 @@ class UserState {
|
||||
navigate.value = value;
|
||||
}
|
||||
|
||||
static void setEnableThirdPartyPayment(bool? value) {
|
||||
enableThirdPartyPayment.value = value;
|
||||
}
|
||||
|
||||
static String formatCredits(int? value) {
|
||||
if (value == null) return '--';
|
||||
return value.toString().replaceAllMapped(
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
@ -12,28 +13,88 @@ import '../../core/user/user_state.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
import '../../core/api/services/image_api.dart';
|
||||
import '../../core/api/services/user_api.dart';
|
||||
import '../../features/gallery/models/gallery_task_item.dart';
|
||||
import '../../shared/tab_selector_scope.dart';
|
||||
import '../../shared/widgets/bottom_nav_bar.dart';
|
||||
|
||||
/// Generate Video Progress screen - matches Pencil qGs6n
|
||||
/// Progress states: 1=队列中 2=处理中 3=完成 4=超时 5=错误 6=中止
|
||||
/// Progress bar has 3 stages; states 3–6 are stage 3.
|
||||
const _stateLabels = <int, String>{
|
||||
1: 'Queued',
|
||||
2: 'Processing',
|
||||
3: 'Completed',
|
||||
4: 'Timeout',
|
||||
5: 'Error',
|
||||
6: 'Aborted',
|
||||
};
|
||||
|
||||
/// Stage progress: 1 -> 1/3, 2 -> 2/3, 3..6 -> 1.0
|
||||
double _progressForState(int? state) {
|
||||
if (state == null) return 0;
|
||||
if (state == 1) return 1 / 3;
|
||||
if (state == 2) return 2 / 3;
|
||||
return 1.0; // 3, 4, 5, 6
|
||||
}
|
||||
|
||||
/// Build GalleryMediaItem from /v1/image/progress response (data = sidekick).
|
||||
/// curate[].reconfigure = result URL; for img2video use as videoUrl.
|
||||
GalleryMediaItem? _mediaItemFromProgressData(Map<String, dynamic> data) {
|
||||
final curate = data['curate'] as List<dynamic>?;
|
||||
if (curate == null || curate.isEmpty) return null;
|
||||
final first = curate.first;
|
||||
if (first is! Map<String, dynamic>) return null;
|
||||
final reconfigure = first['reconfigure'] as String?;
|
||||
final digitize = first['digitize'] as String?;
|
||||
final videoUrl = reconfigure?.isNotEmpty == true
|
||||
? reconfigure
|
||||
: digitize?.isNotEmpty == true
|
||||
? digitize
|
||||
: null;
|
||||
final imageUrl = digitize?.isNotEmpty == true ? digitize : null;
|
||||
if (videoUrl != null) {
|
||||
return GalleryMediaItem(videoUrl: videoUrl, imageUrl: imageUrl);
|
||||
}
|
||||
if (imageUrl != null) {
|
||||
return GalleryMediaItem(imageUrl: imageUrl);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label)
|
||||
class GenerateProgressScreen extends StatefulWidget {
|
||||
const GenerateProgressScreen({super.key, this.taskId});
|
||||
const GenerateProgressScreen({super.key, this.taskId, this.imagePath});
|
||||
|
||||
final dynamic taskId;
|
||||
final String? imagePath;
|
||||
|
||||
@override
|
||||
State<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
|
||||
}
|
||||
|
||||
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
double _progress = 0;
|
||||
int? _state;
|
||||
Timer? _pollTimer;
|
||||
|
||||
double get _progress => _progressForState(_state);
|
||||
|
||||
void _onBack(BuildContext context) {
|
||||
final completed = _state == 3;
|
||||
if (!completed) {
|
||||
TabSelectorScope.maybeOf(context)?.selectTab(NavTab.gallery);
|
||||
Navigator.of(context).popUntil(ModalRoute.withName('/'));
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.taskId != null) {
|
||||
_startPolling();
|
||||
} else {
|
||||
_progress = 0.45;
|
||||
_state = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,8 +106,10 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
|
||||
Future<void> _startPolling() async {
|
||||
await AuthService.loginComplete;
|
||||
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) => _fetchProgress());
|
||||
_pollTimer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => _fetchProgress(),
|
||||
);
|
||||
_fetchProgress();
|
||||
}
|
||||
|
||||
@ -63,32 +126,53 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
if (!res.isSuccess || res.data == null) return;
|
||||
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
final progress = (data['dice'] as num?)?.toInt() ?? 0;
|
||||
final state = (data['listing'] as num?)?.toInt();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_progress = progress / 100.0;
|
||||
});
|
||||
setState(() => _state = state);
|
||||
|
||||
if (progress >= 100 || state == 2) {
|
||||
_pollTimer?.cancel();
|
||||
Navigator.of(context).pushReplacementNamed('/result', arguments: widget.taskId);
|
||||
} else if (state == 3) {
|
||||
_pollTimer?.cancel();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Generation failed'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
switch (state) {
|
||||
case 3: // 完成
|
||||
_pollTimer?.cancel();
|
||||
// 生成成功后同步更新积分
|
||||
final userId = UserState.userId.value;
|
||||
if (userId != null && userId.isNotEmpty) {
|
||||
try {
|
||||
final accountRes = await UserApi.getAccount(
|
||||
sentinel: ApiConfig.appId,
|
||||
asset: userId,
|
||||
);
|
||||
if (accountRes.isSuccess && accountRes.data != null) {
|
||||
final accountData =
|
||||
accountRes.data as Map<String, dynamic>?;
|
||||
final credits = accountData?['reveal'] as int?;
|
||||
if (credits != null) {
|
||||
UserState.setCredits(credits);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!mounted) return;
|
||||
final mediaItem = _mediaItemFromProgressData(data);
|
||||
Navigator.of(context).pushReplacementNamed(
|
||||
'/result',
|
||||
arguments: mediaItem,
|
||||
);
|
||||
break;
|
||||
case 4:
|
||||
case 5:
|
||||
case 6: // 超时 / 错误 / 中止
|
||||
_pollTimer?.cancel();
|
||||
break;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labelText = _stateLabels[_state] ?? '队列中';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: PreferredSize(
|
||||
@ -96,7 +180,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
child: TopNavBar(
|
||||
title: 'Generating',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
onBack: () => _onBack(context),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
@ -104,7 +188,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_VideoPreview(),
|
||||
_VideoPreview(imagePath: widget.imagePath),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Text(
|
||||
'Video generation may take some time. Please wait patiently.',
|
||||
@ -114,7 +198,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_ProgressSection(progress: _progress),
|
||||
_ProgressSection(progress: _progress, label: labelText),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -122,51 +206,70 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Video area rSN3T: show uploaded image with crop (BoxFit.cover)
|
||||
class _VideoPreview extends StatelessWidget {
|
||||
const _VideoPreview({this.imagePath});
|
||||
|
||||
final String? imagePath;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasImage =
|
||||
imagePath != null && imagePath!.isNotEmpty && File(imagePath!).existsSync();
|
||||
|
||||
return Container(
|
||||
height: 360,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary,
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.film,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Video Preview',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasImage
|
||||
? Image.file(
|
||||
File(imagePath!),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.film,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Video Preview',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress bar: 3 stages. Label Eghqc shows state (队列中|处理中|完成|超时|错误|中止)
|
||||
class _ProgressSection extends StatelessWidget {
|
||||
const _ProgressSection({required this.progress});
|
||||
const _ProgressSection({required this.progress, required this.label});
|
||||
|
||||
final double progress;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percentage = (progress * 100).round();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Generating... $percentage%',
|
||||
label,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../core/log/app_logger.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
@ -23,6 +25,8 @@ import '../../core/api/services/user_api.dart';
|
||||
class GenerateVideoScreen extends StatefulWidget {
|
||||
const GenerateVideoScreen({super.key, this.task});
|
||||
|
||||
static final _log = AppLogger('GenerateVideo');
|
||||
|
||||
final TaskItem? task;
|
||||
|
||||
@override
|
||||
@ -32,7 +36,6 @@ class GenerateVideoScreen extends StatefulWidget {
|
||||
enum _Resolution { p480, p720 }
|
||||
|
||||
class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
File? _selectedImage;
|
||||
_Resolution _selectedResolution = _Resolution.p480;
|
||||
bool _isGenerating = false;
|
||||
|
||||
@ -54,15 +57,15 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
developer.log(
|
||||
'GenerateVideoScreen opened with task: ${widget.task}',
|
||||
name: 'GenerateVideoScreen',
|
||||
);
|
||||
debugPrint('[GenerateVideoScreen] task: ${widget.task}');
|
||||
GenerateVideoScreen._log.d('opened with task: ${widget.task}');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showImageSourcePicker() async {
|
||||
/// Click flow per docs/generate_video.md: tap Generate Video -> image picker
|
||||
/// (camera or gallery) -> after image selected -> proceed to API.
|
||||
Future<void> _onGenerateButtonTap() async {
|
||||
if (_isGenerating) return;
|
||||
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
@ -83,43 +86,25 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (source != null && mounted) {
|
||||
_pickImage(source);
|
||||
}
|
||||
}
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (picked != null && mounted) {
|
||||
setState(() {
|
||||
_selectedImage = File(picked.path);
|
||||
});
|
||||
}
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
final file = File(picked.path);
|
||||
await _runGenerationApi(file);
|
||||
}
|
||||
|
||||
Future<void> _onGenerate() async {
|
||||
if (_selectedImage == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select an image first'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> _runGenerationApi(File file) async {
|
||||
setState(() => _isGenerating = true);
|
||||
|
||||
try {
|
||||
await AuthService.loginComplete;
|
||||
|
||||
final file = _selectedImage!;
|
||||
final size = await file.length();
|
||||
final ext = file.path.split('.').last.toLowerCase();
|
||||
final contentType = ext == 'png'
|
||||
@ -212,11 +197,12 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context)
|
||||
.pushReplacementNamed('/progress', arguments: taskId);
|
||||
Navigator.of(context).pushReplacementNamed(
|
||||
'/progress',
|
||||
arguments: <String, dynamic>{'taskId': taskId, 'imagePath': file.path},
|
||||
);
|
||||
} catch (e, st) {
|
||||
developer.log('Generate failed: $e', name: 'GenerateVideoScreen');
|
||||
debugPrint('[GenerateVideoScreen] error: $e\n$st');
|
||||
GenerateVideoScreen._log.e('Generate failed', e, st);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@ -247,32 +233,61 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_CreditsCard(
|
||||
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_CreditsCard(
|
||||
credits:
|
||||
UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_VideoPreviewArea(
|
||||
videoUrl: widget.task?.previewVideoUrl,
|
||||
imageUrl: widget.task?.previewImageUrl,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_UploadArea(
|
||||
selectedImage: _selectedImage,
|
||||
onUpload: _showImageSourcePicker,
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ResolutionToggle(
|
||||
selected: _selectedResolution,
|
||||
onChanged: (r) =>
|
||||
setState(() => _selectedResolution = r),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_GenerateButton(
|
||||
onGenerate: _onGenerateButtonTap,
|
||||
isLoading: _isGenerating,
|
||||
credits: _currentCredits.toString(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_ResolutionToggle(
|
||||
selected: _selectedResolution,
|
||||
onChanged: (r) => setState(() => _selectedResolution = r),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_GenerateButton(
|
||||
onGenerate: _onGenerate,
|
||||
isLoading: _isGenerating,
|
||||
credits: _currentCredits.toString(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -333,63 +348,176 @@ class _CreditsCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadArea extends StatelessWidget {
|
||||
const _UploadArea({
|
||||
required this.selectedImage,
|
||||
required this.onUpload,
|
||||
/// Video preview area - video URL from card click. Auto-load and play on init.
|
||||
/// Video fit: contain (no crop). Loading animation until ready.
|
||||
class _VideoPreviewArea extends StatefulWidget {
|
||||
const _VideoPreviewArea({
|
||||
this.videoUrl,
|
||||
this.imageUrl,
|
||||
});
|
||||
|
||||
final File? selectedImage;
|
||||
final VoidCallback onUpload;
|
||||
final String? videoUrl;
|
||||
final String? imageUrl;
|
||||
|
||||
@override
|
||||
State<_VideoPreviewArea> createState() => _VideoPreviewAreaState();
|
||||
}
|
||||
|
||||
class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
|
||||
VideoPlayerController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
|
||||
_loadAndPlay();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _VideoPreviewArea oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.videoUrl != widget.videoUrl) {
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
|
||||
_loadAndPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAndPlay() async {
|
||||
final url = widget.videoUrl;
|
||||
if (url == null || url.isEmpty) return;
|
||||
|
||||
setState(() {});
|
||||
|
||||
try {
|
||||
final file = await DefaultCacheManager().getSingleFile(url);
|
||||
if (!mounted) return;
|
||||
final controller = VideoPlayerController.file(file);
|
||||
await controller.initialize();
|
||||
if (!mounted) return;
|
||||
await controller.play();
|
||||
controller.setLooping(true);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_controller = controller;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
GenerateVideoScreen._log.e('Video load failed', e);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onUpload,
|
||||
child: Container(
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.border,
|
||||
width: 2,
|
||||
final isReady =
|
||||
_controller != null && _controller!.value.isInitialized;
|
||||
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
|
||||
final hasImage =
|
||||
widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
|
||||
|
||||
// Aspect ratio: from video when ready, else 16:9 placeholder
|
||||
final aspectRatio = isReady &&
|
||||
_controller!.value.size.width > 0 &&
|
||||
_controller!.value.size.height > 0
|
||||
? _controller!.value.size.width / _controller!.value.size.height
|
||||
: 16 / 9;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = width / aspectRatio;
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.border,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: selectedImage != null
|
||||
? Image.file(
|
||||
selectedImage!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.image_plus,
|
||||
size: 48,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: Text(
|
||||
'Please upload an image as the base for generation',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isReady)
|
||||
SizedBox.expand(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width: _controller!.value.size.width,
|
||||
height: _controller!.value.size.height,
|
||||
child: VideoPlayer(_controller!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (hasImage)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) => _LoadingOverlay(isLoading: true),
|
||||
errorWidget: (_, __, ___) => _LoadingOverlay(isLoading: false),
|
||||
)
|
||||
else
|
||||
_LoadingOverlay(isLoading: false),
|
||||
if (hasVideo && !isReady)
|
||||
Positioned.fill(
|
||||
child: _LoadingOverlay(isLoading: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingOverlay extends StatelessWidget {
|
||||
const _LoadingOverlay({this.isLoading = true});
|
||||
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
alignment: Alignment.center,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
LucideIcons.video,
|
||||
size: 48,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolution row - 1:1 match Pencil oYwsm: label + 480P/720P on same row.
|
||||
/// Button: height 36, cornerRadius 18, padding 8x16, gap 12 label-to-btns, gap 8 between btns.
|
||||
class _ResolutionToggle extends StatelessWidget {
|
||||
const _ResolutionToggle({
|
||||
required this.selected,
|
||||
@ -401,70 +529,82 @@ class _ResolutionToggle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Resolution',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
fontSize: 14,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_ResolutionOption(
|
||||
label: '480P',
|
||||
isSelected: selected == _Resolution.p480,
|
||||
onTap: () => onChanged(_Resolution.p480),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: selected == _Resolution.p480
|
||||
? AppColors.primary
|
||||
: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected == _Resolution.p480
|
||||
? AppColors.primary.withValues(alpha: 0.5)
|
||||
: AppColors.border,
|
||||
width: selected == _Resolution.p480 ? 1 : 2,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'480P',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: selected == _Resolution.p480
|
||||
? AppColors.surface
|
||||
: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
const SizedBox(width: 8),
|
||||
_ResolutionOption(
|
||||
label: '720P',
|
||||
isSelected: selected == _Resolution.p720,
|
||||
onTap: () => onChanged(_Resolution.p720),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: selected == _Resolution.p720
|
||||
? AppColors.primary
|
||||
: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected == _Resolution.p720
|
||||
? AppColors.primary.withValues(alpha: 0.5)
|
||||
: AppColors.border,
|
||||
width: selected == _Resolution.p720 ? 1 : 2,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'720P',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: selected == _Resolution.p720
|
||||
? AppColors.surface
|
||||
: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResolutionOption extends StatelessWidget {
|
||||
const _ResolutionOption({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: isSelected
|
||||
? null
|
||||
: Border.all(color: AppColors.border, width: 1),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.19),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
fontSize: 13,
|
||||
color: isSelected ? AppColors.surface : AppColors.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,7 +85,8 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
||||
|
||||
if (_videoUrl != null) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/gallery_video_${DateTime.now().millisecondsSinceEpoch}.mp4');
|
||||
final file = File(
|
||||
'${tempDir.path}/gallery_video_${DateTime.now().millisecondsSinceEpoch}.mp4');
|
||||
final response = await http.get(Uri.parse(_videoUrl!));
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to download video');
|
||||
@ -95,7 +96,8 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
||||
await file.delete();
|
||||
} else if (_imageUrl != null) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/gallery_image_${DateTime.now().millisecondsSinceEpoch}.jpg');
|
||||
final file = File(
|
||||
'${tempDir.path}/gallery_image_${DateTime.now().millisecondsSinceEpoch}.jpg');
|
||||
final response = await http.get(Uri.parse(_imageUrl!));
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to download image');
|
||||
@ -156,9 +158,7 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
||||
_DownloadButton(
|
||||
onDownload: _saving ? null : _saveToAlbum,
|
||||
saving: _saving,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_ShareButton(onShare: () {}),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -5,7 +5,6 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
|
||||
/// Video card for home grid - 点击播放按钮可在卡片上播放视频
|
||||
/// 同时只能一个卡片处于播放状态
|
||||
@ -35,7 +34,6 @@ class VideoCard extends StatefulWidget {
|
||||
|
||||
class _VideoCardState extends State<VideoCard> {
|
||||
VideoPlayerController? _controller;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -64,9 +62,7 @@ class _VideoCardState extends State<VideoCard> {
|
||||
void _stop() {
|
||||
_controller?.removeListener(_onVideoUpdate);
|
||||
_controller?.pause();
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _disposeController() {
|
||||
@ -85,34 +81,30 @@ class _VideoCardState extends State<VideoCard> {
|
||||
final needSeek = _controller!.value.position >= _controller!.value.duration &&
|
||||
_controller!.value.duration.inMilliseconds > 0;
|
||||
if (needSeek) {
|
||||
setState(() => _isLoading = true);
|
||||
await _controller!.seekTo(Duration.zero);
|
||||
}
|
||||
_controller!.addListener(_onVideoUpdate);
|
||||
await _controller!.play();
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
if (mounted) setState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
setState(() {});
|
||||
|
||||
try {
|
||||
final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!);
|
||||
if (!mounted || !widget.isActive) {
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
if (!mounted || !widget.isActive) return;
|
||||
_controller = VideoPlayerController.file(file);
|
||||
await _controller!.initialize();
|
||||
_controller!.addListener(_onVideoUpdate);
|
||||
if (mounted && widget.isActive) {
|
||||
await _controller!.play();
|
||||
setState(() => _isLoading = false);
|
||||
setState(() {});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_disposeController();
|
||||
setState(() => _isLoading = false);
|
||||
setState(() {});
|
||||
widget.onStopRequested();
|
||||
}
|
||||
}
|
||||
@ -143,7 +135,6 @@ class _VideoCardState extends State<VideoCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized;
|
||||
final showLoading = widget.isActive && _isLoading;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@ -151,9 +142,8 @@ class _VideoCardState extends State<VideoCard> {
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowMedium,
|
||||
@ -169,37 +159,51 @@ class _VideoCardState extends State<VideoCard> {
|
||||
children: [
|
||||
if (showVideo)
|
||||
Positioned.fill(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(
|
||||
width: _controller!.value.size.width > 0
|
||||
? _controller!.value.size.width
|
||||
: 16,
|
||||
height: _controller!.value.size.height > 0
|
||||
? _controller!.value.size.height
|
||||
: 9,
|
||||
child: VideoPlayer(_controller!),
|
||||
child: GestureDetector(
|
||||
onTap: _onPlayButtonTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(
|
||||
width: _controller!.value.size.width > 0
|
||||
? _controller!.value.size.width
|
||||
: 16,
|
||||
height: _controller!.value.size.height > 0
|
||||
? _controller!.value.size.height
|
||||
: 9,
|
||||
child: VideoPlayer(_controller!),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: widget.videoUrl != null &&
|
||||
widget.videoUrl!.isNotEmpty
|
||||
? _onPlayButtonTap
|
||||
: null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.overlayDark,
|
||||
@ -207,13 +211,14 @@ class _VideoCardState extends State<VideoCard> {
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 12,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.credits,
|
||||
style: const TextStyle(
|
||||
@ -227,43 +232,6 @@ class _VideoCardState extends State<VideoCard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: widget.videoUrl != null && widget.videoUrl!.isNotEmpty
|
||||
? _onPlayButtonTap
|
||||
: null,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.13),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: showLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.textPrimary,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isPlaying ? LucideIcons.pause : LucideIcons.play,
|
||||
size: 24,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isPlaying)
|
||||
Positioned(
|
||||
top: 8,
|
||||
@ -288,33 +256,41 @@ class _VideoCardState extends State<VideoCard> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
bottom: 16,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onGenerateSimilar,
|
||||
child: Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
child: Center(
|
||||
child: IntrinsicWidth(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onGenerateSimilar,
|
||||
child: Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryButtonFill,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.primary,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryButtonShadow,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Generate Similar',
|
||||
style: TextStyle(
|
||||
color: AppColors.surface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Generate Similar',
|
||||
style: TextStyle(
|
||||
color: AppColors.surface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
147
lib/features/recharge/google_play_purchase_service.dart
Normal file
147
lib/features/recharge/google_play_purchase_service.dart
Normal file
@ -0,0 +1,147 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
|
||||
import '../../core/log/app_logger.dart';
|
||||
|
||||
/// 调起 Google Play 内购(三方为 false 时使用;三方为 true 且 ceremony==GooglePay 时用 launchPurchaseAndReturnData)
|
||||
abstract final class GooglePlayPurchaseService {
|
||||
static final _log = AppLogger('GooglePlayPurchase');
|
||||
|
||||
/// 发起购买并返回服务器凭据(用于三方支付选 GooglePay 时上报 /v1/payment/googlepay)。
|
||||
/// 成功返回 purchaseData(serverVerificationData),取消/失败返回 null。
|
||||
static Future<String?> launchPurchaseAndReturnData(String productId) async {
|
||||
_log.d('谷歌支付请求商品 ID(helm): "$productId"');
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
_log.d('非 Android,跳过内购');
|
||||
return null;
|
||||
}
|
||||
final iap = InAppPurchase.instance;
|
||||
if (!await iap.isAvailable()) {
|
||||
_log.w('Billing 不可用');
|
||||
return null;
|
||||
}
|
||||
final response = await iap.queryProductDetails({productId});
|
||||
if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) {
|
||||
_log.w('商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致(区分大小写)');
|
||||
return null;
|
||||
}
|
||||
final product = response.productDetails.first;
|
||||
final completer = Completer<String?>();
|
||||
StreamSubscription<List<PurchaseDetails>>? sub;
|
||||
sub = iap.purchaseStream.listen(
|
||||
(purchases) {
|
||||
for (final p in purchases) {
|
||||
if (p.productID != productId) continue;
|
||||
if (p.status == PurchaseStatus.purchased ||
|
||||
p.status == PurchaseStatus.restored) {
|
||||
if (!completer.isCompleted) {
|
||||
final data = p.verificationData.serverVerificationData;
|
||||
iap.completePurchase(p);
|
||||
completer.complete(data);
|
||||
}
|
||||
sub?.cancel();
|
||||
return;
|
||||
}
|
||||
if (p.status == PurchaseStatus.error ||
|
||||
p.status == PurchaseStatus.canceled) {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
sub?.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
sub?.cancel();
|
||||
},
|
||||
);
|
||||
final success = await iap.buyConsumable(
|
||||
purchaseParam: PurchaseParam(productDetails: product),
|
||||
);
|
||||
if (!success) {
|
||||
sub.cancel();
|
||||
return null;
|
||||
}
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 120),
|
||||
onTimeout: () {
|
||||
sub?.cancel();
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 发起购买,商品 ID 为 [productId](即 ActivityItem.code / helm)。
|
||||
/// 返回 true 表示购买完成,false 表示取消或失败。
|
||||
static Future<bool> launchPurchase(String productId) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final iap = InAppPurchase.instance;
|
||||
final available = await iap.isAvailable();
|
||||
if (!available) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final response = await iap.queryProductDetails({productId});
|
||||
if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) {
|
||||
_log.w('商品未找到: productId="$productId", notFoundIDs=${response.notFoundIDs}');
|
||||
return false;
|
||||
}
|
||||
|
||||
final product = response.productDetails.first;
|
||||
final completer = Completer<bool>();
|
||||
StreamSubscription<List<PurchaseDetails>>? sub;
|
||||
|
||||
sub = iap.purchaseStream.listen(
|
||||
(purchases) {
|
||||
for (final p in purchases) {
|
||||
if (p.productID != productId) continue;
|
||||
if (p.status == PurchaseStatus.purchased ||
|
||||
p.status == PurchaseStatus.restored) {
|
||||
if (!completer.isCompleted) {
|
||||
iap.completePurchase(p);
|
||||
completer.complete(true);
|
||||
}
|
||||
sub?.cancel();
|
||||
return;
|
||||
}
|
||||
if (p.status == PurchaseStatus.error) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
sub?.cancel();
|
||||
return;
|
||||
}
|
||||
if (p.status == PurchaseStatus.canceled) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
sub?.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (Object e) {
|
||||
if (!completer.isCompleted) completer.complete(false);
|
||||
sub?.cancel();
|
||||
},
|
||||
);
|
||||
|
||||
final purchaseParam = PurchaseParam(productDetails: product);
|
||||
final success = await iap.buyConsumable(purchaseParam: purchaseParam);
|
||||
if (!success) {
|
||||
sub.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
return completer.future.timeout(const Duration(seconds: 120),
|
||||
onTimeout: () {
|
||||
sub?.cancel();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,14 @@ class ActivityItem {
|
||||
String get creditsDisplay =>
|
||||
credits > 0 ? '${credits.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},')} Credits' : title;
|
||||
|
||||
/// 显示价格文案,带 $ 符号(支付流程文档要求 guardian 需显示 $)
|
||||
String get priceDisplayWithDollar {
|
||||
if (actualAmount.isEmpty) return '';
|
||||
final amount = actualAmount.trimLeft();
|
||||
if (amount.startsWith(r'$')) return actualAmount;
|
||||
return '\$$actualAmount';
|
||||
}
|
||||
|
||||
/// 显示价格文案,如 "¥6" 或 "¥25 Save ¥5"
|
||||
String get priceDisplay {
|
||||
if (actualAmount.isEmpty) return '';
|
||||
|
||||
28
lib/features/recharge/models/payment_method_item.dart
Normal file
28
lib/features/recharge/models/payment_method_item.dart
Normal file
@ -0,0 +1,28 @@
|
||||
/// 支付方式项(get-payment-methods 返回的 renew 单项)
|
||||
class PaymentMethodItem {
|
||||
const PaymentMethodItem({
|
||||
required this.paymentMethod,
|
||||
this.subPaymentMethod,
|
||||
this.name,
|
||||
this.icon,
|
||||
this.recommend = false,
|
||||
});
|
||||
|
||||
final String paymentMethod; // resource,如 GOOGLEPAY/APPLEPAY
|
||||
final String? subPaymentMethod; // ceremony
|
||||
final String? name; // brigade 展示名称
|
||||
final String? icon; // greylist 图标 URL
|
||||
final bool recommend; // deny 取反为推荐
|
||||
|
||||
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
|
||||
return PaymentMethodItem(
|
||||
paymentMethod: json['resource']?.toString() ?? '',
|
||||
subPaymentMethod: json['ceremony']?.toString(),
|
||||
name: json['brigade']?.toString(),
|
||||
icon: json['greylist']?.toString(),
|
||||
recommend: json['deny'] != true,
|
||||
);
|
||||
}
|
||||
|
||||
String get displayName => name?.isNotEmpty == true ? name! : paymentMethod;
|
||||
}
|
||||
66
lib/features/recharge/payment_webview_screen.dart
Normal file
66
lib/features/recharge/payment_webview_screen.dart
Normal file
@ -0,0 +1,66 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
/// 三方支付成功后在应用内打开 convert 链接的 WebView 页面
|
||||
class PaymentWebViewScreen extends StatefulWidget {
|
||||
const PaymentWebViewScreen({super.key, required this.paymentUrl});
|
||||
|
||||
final String paymentUrl;
|
||||
|
||||
@override
|
||||
State<PaymentWebViewScreen> createState() => _PaymentWebViewScreenState();
|
||||
}
|
||||
|
||||
class _PaymentWebViewScreenState extends State<PaymentWebViewScreen> {
|
||||
late final WebViewController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (_) {},
|
||||
onPageFinished: (_) {},
|
||||
onWebResourceError: (e) {},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(widget.paymentUrl));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Payment',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: _buildWebView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWebView() {
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
return WebViewWidget.fromPlatformCreationParams(
|
||||
params: AndroidWebViewWidgetCreationParams(
|
||||
controller: _controller.platform as AndroidWebViewController,
|
||||
displayWithHybridComposition: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return WebViewWidget(controller: _controller);
|
||||
}
|
||||
}
|
||||
@ -3,36 +3,58 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
|
||||
import '../../core/adjust/adjust_events.dart';
|
||||
import '../../core/api/api_config.dart';
|
||||
import '../../core/api/services/payment_api.dart';
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../core/log/app_logger.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
import 'google_play_purchase_service.dart';
|
||||
import 'models/activity_item.dart';
|
||||
import 'models/payment_method_item.dart';
|
||||
import 'payment_webview_screen.dart';
|
||||
|
||||
/// Recharge screen - matches Pencil tPjdN
|
||||
/// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities
|
||||
class RechargeScreen extends StatefulWidget {
|
||||
static final _log = AppLogger('Recharge');
|
||||
const RechargeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RechargeScreen> createState() => _RechargeScreenState();
|
||||
}
|
||||
|
||||
class _RechargeScreenState extends State<RechargeScreen> {
|
||||
class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObserver {
|
||||
List<ActivityItem> _activities = [];
|
||||
bool _loadingTiers = true;
|
||||
String? _tierError;
|
||||
/// 当前正在支付的商品 code,仅该 item 的 Buy 显示 loading
|
||||
String? _loadingProductId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_fetchActivities();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) {
|
||||
setState(() => _loadingProductId = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchActivities() async {
|
||||
setState(() {
|
||||
_loadingTiers = true;
|
||||
@ -80,7 +102,10 @@ class _RechargeScreenState extends State<RechargeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onBuy(ActivityItem item) {
|
||||
Future<void> _onBuy(ActivityItem item) async {
|
||||
if (_loadingProductId != null) return;
|
||||
setState(() => _loadingProductId = item.code);
|
||||
|
||||
final price = AdjustEvents.parsePrice(item.actualAmount);
|
||||
if (price != null) {
|
||||
final tierToken = AdjustEvents.tierTokenFromPrice(price);
|
||||
@ -94,7 +119,245 @@ class _RechargeScreenState extends State<RechargeScreen> {
|
||||
} else if (titleLower.contains('weekly vip')) {
|
||||
AdjustEvents.trackWeeklyVip();
|
||||
}
|
||||
// TODO: 创建支付订单;成功时调用 AdjustEvents.trackPurchaseSuccess(),失败时调用 AdjustEvents.trackPaymentFailed()
|
||||
|
||||
final useThirdParty =
|
||||
UserState.enableThirdPartyPayment.value == true;
|
||||
final uid = UserState.userId.value;
|
||||
|
||||
if (useThirdParty && uid != null && uid.isNotEmpty) {
|
||||
await _runThirdPartyPayment(item, uid);
|
||||
} else {
|
||||
await _runGooglePay(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三方支付流程:获取支付方式 -> 弹窗选择 -> 创建订单 -> 打开支付页
|
||||
Future<void> _runThirdPartyPayment(ActivityItem item, String userId) async {
|
||||
final activityId = item.activityId;
|
||||
if (activityId == null || activityId.isEmpty) {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Invalid product', isError: true);
|
||||
}
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final country = UserState.navigate.value;
|
||||
|
||||
final methodsRes = await PaymentApi.getPaymentMethods(
|
||||
warrior: activityId,
|
||||
vambrace: country?.isEmpty == true ? null : country,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!methodsRes.isSuccess || methodsRes.data == null) {
|
||||
_showSnackBar(
|
||||
context,
|
||||
methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods',
|
||||
isError: true,
|
||||
);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
final methodsList = methodsRes.data is Map<String, dynamic>
|
||||
? (methodsRes.data! as Map<String, dynamic>)['renew'] as List<dynamic>?
|
||||
: null;
|
||||
if (methodsList == null || methodsList.isEmpty) {
|
||||
_showSnackBar(context, 'No payment methods available', isError: true);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
final methods = methodsList
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => PaymentMethodItem.fromJson(e))
|
||||
.where((m) => m.paymentMethod.isNotEmpty)
|
||||
.toList();
|
||||
if (methods.isEmpty) {
|
||||
_showSnackBar(context, 'No payment methods available', isError: true);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final selected = await showDialog<PaymentMethodItem>(
|
||||
context: context,
|
||||
builder: (ctx) => _PaymentMethodDialog(methods: methods),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (selected == null) {
|
||||
setState(() => _loadingProductId = null);
|
||||
return;
|
||||
}
|
||||
|
||||
await _createOrderAndOpenUrl(
|
||||
userId: userId,
|
||||
activityId: activityId,
|
||||
productId: item.code,
|
||||
paymentMethod: selected.paymentMethod,
|
||||
subPaymentMethod: selected.subPaymentMethod?.isEmpty == true ? null : selected.subPaymentMethod,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
|
||||
}
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loadingProductId = null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建订单;若为 Google Pay 则调起内购并上报凭据,否则打开支付链接
|
||||
Future<void> _createOrderAndOpenUrl({
|
||||
required String userId,
|
||||
required String activityId,
|
||||
required String productId,
|
||||
required String paymentMethod,
|
||||
String? subPaymentMethod,
|
||||
}) async {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final createRes = await PaymentApi.createPayment(
|
||||
sentinel: ApiConfig.appId,
|
||||
asset: userId,
|
||||
warrior: activityId,
|
||||
resource: paymentMethod,
|
||||
ceremony: subPaymentMethod,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!createRes.isSuccess) {
|
||||
_showSnackBar(
|
||||
context,
|
||||
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order',
|
||||
isError: true,
|
||||
);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
final data = createRes.data is Map<String, dynamic>
|
||||
? createRes.data as Map<String, dynamic>
|
||||
: null;
|
||||
final orderId = data?['federation']?.toString();
|
||||
|
||||
if (_isGooglePay(paymentMethod, subPaymentMethod)) {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
_showSnackBar(context, 'Google Pay is only available on Android.', isError: true);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
return;
|
||||
}
|
||||
final purchaseData = await GooglePlayPurchaseService.launchPurchaseAndReturnData(productId);
|
||||
if (!mounted) return;
|
||||
if (purchaseData != null && purchaseData.isNotEmpty && orderId != null && orderId.isNotEmpty) {
|
||||
RechargeScreen._log.d('googlepay 入参: federation=$orderId, asset=$userId');
|
||||
RechargeScreen._log.d('googlepay 入参: merchant(length=${purchaseData.length}) ${purchaseData.length > 400 ? "${purchaseData.substring(0, 400)}..." : purchaseData}');
|
||||
final googlepayRes = await PaymentApi.googlepay(
|
||||
merchant: purchaseData,
|
||||
federation: orderId,
|
||||
asset: userId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (googlepayRes.isSuccess) {
|
||||
_showSnackBar(context, 'Purchase completed.');
|
||||
AdjustEvents.trackPurchaseSuccess();
|
||||
} else {
|
||||
_showSnackBar(context, googlepayRes.msg.isNotEmpty ? googlepayRes.msg : 'Payment verification failed.', isError: true);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
}
|
||||
} else {
|
||||
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final payUrl = data?['convert']?.toString();
|
||||
if (payUrl != null && payUrl.isNotEmpty) {
|
||||
if (mounted) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl),
|
||||
),
|
||||
);
|
||||
_showSnackBar(context, 'Order created. Complete payment in the page.');
|
||||
AdjustEvents.trackPurchaseSuccess();
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Order created. Awaiting payment confirmation.');
|
||||
}
|
||||
AdjustEvents.trackPurchaseSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
|
||||
}
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loadingProductId = null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ceremony==GooglePay 或 resource==GOOGLEPAY 时走谷歌内购并上报
|
||||
static bool _isGooglePay(String paymentMethod, String? subPaymentMethod) {
|
||||
final r = paymentMethod.trim().toLowerCase();
|
||||
final c = (subPaymentMethod ?? '').trim().toLowerCase();
|
||||
return r == 'googlepay' || c == 'googlepay';
|
||||
}
|
||||
|
||||
/// 三方为 false 时走谷歌应用内支付
|
||||
Future<void> _runGooglePay(ActivityItem item) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
_showSnackBar(context, 'Google Pay is only available on Android.', isError: true);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
return;
|
||||
}
|
||||
await _launchGooglePlayPurchase(item);
|
||||
}
|
||||
|
||||
/// 调起 Google Play 内购(商品 ID = item.code / helm)
|
||||
Future<void> _launchGooglePlayPurchase(ActivityItem item) async {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final success = await GooglePlayPurchaseService.launchPurchase(item.code);
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
_showSnackBar(context, 'Purchase completed.');
|
||||
AdjustEvents.trackPurchaseSuccess();
|
||||
} else {
|
||||
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true);
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showSnackBar(context, 'Google Pay error: ${e.toString()}', isError: true);
|
||||
}
|
||||
AdjustEvents.trackPaymentFailed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loadingProductId = null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(BuildContext context, String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red.shade700 : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -192,6 +455,7 @@ class _RechargeScreenState extends State<RechargeScreen> {
|
||||
: isPopular
|
||||
? _TierBadge.popular
|
||||
: _TierBadge.none,
|
||||
loading: _loadingProductId == item.code,
|
||||
onBuy: () => _onBuy(item),
|
||||
),
|
||||
);
|
||||
@ -203,17 +467,58 @@ class _RechargeScreenState extends State<RechargeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付方式选择弹窗(三方支付列表)
|
||||
class _PaymentMethodDialog extends StatelessWidget {
|
||||
const _PaymentMethodDialog({required this.methods});
|
||||
|
||||
final List<PaymentMethodItem> methods;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Select payment method'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: methods
|
||||
.map(
|
||||
(m) => ListTile(
|
||||
title: Text(m.displayName),
|
||||
subtitle: m.subPaymentMethod != null && m.subPaymentMethod!.isNotEmpty
|
||||
? Text(m.subPaymentMethod!, style: AppTypography.caption)
|
||||
: null,
|
||||
trailing: m.recommend
|
||||
? Text('Recommended', style: AppTypography.caption.copyWith(color: AppColors.primary))
|
||||
: null,
|
||||
onTap: () => Navigator.of(context).pop(m),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _TierBadge { none, recommended, popular }
|
||||
|
||||
class _TierCardFromActivity extends StatelessWidget {
|
||||
const _TierCardFromActivity({
|
||||
required this.item,
|
||||
required this.badge,
|
||||
required this.loading,
|
||||
required this.onBuy,
|
||||
});
|
||||
|
||||
final ActivityItem item;
|
||||
final _TierBadge badge;
|
||||
final bool loading;
|
||||
final VoidCallback onBuy;
|
||||
|
||||
@override
|
||||
@ -239,25 +544,55 @@ class _TierCardFromActivity extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.creditsDisplay,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.creditsDisplay,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm),
|
||||
Text(
|
||||
item.priceDisplay,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
item.priceDisplayWithDollar,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (item.originAmount != null &&
|
||||
item.originAmount!.isNotEmpty &&
|
||||
item.originAmount != item.actualAmount) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
item.originAmount!,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
decorationColor: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.bonus > 0) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'Bonus: ${item.bonus} credits',
|
||||
style: AppTypography.caption.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_BuyButton(onTap: onBuy),
|
||||
_BuyButton(onTap: onBuy, loading: loading),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -358,30 +693,40 @@ class _CreditsSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BuyButton extends StatelessWidget {
|
||||
const _BuyButton({required this.onTap});
|
||||
const _BuyButton({required this.onTap, this.loading = false});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final bool loading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: loading ? null : onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xl,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
color: loading ? AppColors.primary.withValues(alpha: 0.7) : AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Buy',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.surface),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Buy',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
21
lib/shared/tab_selector_scope.dart
Normal file
21
lib/shared/tab_selector_scope.dart
Normal file
@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'widgets/bottom_nav_bar.dart';
|
||||
|
||||
/// Allows routes (e.g. progress screen) to request switching to a tab when popping.
|
||||
class TabSelectorScope extends InheritedWidget {
|
||||
const TabSelectorScope({
|
||||
super.key,
|
||||
required this.selectTab,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final void Function(NavTab) selectTab;
|
||||
|
||||
static TabSelectorScope? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<TabSelectorScope>();
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(TabSelectorScope old) => selectTab != old.selectTab;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
name: pets_hero_ai
|
||||
description: PetsHero AI Application.
|
||||
publish_to: 'none'
|
||||
version: 1.0.1+2
|
||||
version: 1.0.3+4
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@ -26,6 +26,10 @@ dependencies:
|
||||
path_provider: ^2.1.2
|
||||
shared_preferences: ^2.2.2
|
||||
flutter_cache_manager: ^3.3.1
|
||||
logger: ^2.0.2
|
||||
url_launcher: ^6.2.5
|
||||
in_app_purchase: ^3.2.0
|
||||
webview_flutter: ^4.10.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user