新增:支付对接
This commit is contained in:
parent
d9da82bbdc
commit
b464400b6b
@ -28,6 +28,12 @@ if (flutterVersionName == null) {
|
|||||||
flutterVersionName = '1.0'
|
flutterVersionName = '1.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configurations.all {
|
||||||
|
resolutionStrategy {
|
||||||
|
force 'com.android.billingclient:billing:7.1.1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "com.petsheroai.app"
|
namespace "com.petsheroai.app"
|
||||||
compileSdk 36
|
compileSdk 36
|
||||||
@ -79,6 +85,8 @@ dependencies {
|
|||||||
implementation 'com.google.android.gms:play-services-ads-identifier:18.1.0'
|
implementation 'com.google.android.gms:play-services-ads-identifier:18.1.0'
|
||||||
implementation 'com.android.installreferrer:installreferrer:2.2'
|
implementation 'com.android.installreferrer:installreferrer:2.2'
|
||||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
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 {
|
flutter {
|
||||||
|
|||||||
@ -2690,14 +2690,41 @@
|
|||||||
"fontSize": 18,
|
"fontSize": 18,
|
||||||
"fontWeight": "600"
|
"fontWeight": "600"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "frame",
|
||||||
|
"id": "iVYPz",
|
||||||
|
"name": "priceLine",
|
||||||
|
"gap": 8,
|
||||||
|
"alignItems": "center",
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "Xvh9R",
|
"id": "BO2Is",
|
||||||
"fill": "#71717A",
|
"fill": "#71717A",
|
||||||
"content": "$5.99",
|
"content": "$5.99",
|
||||||
"fontFamily": "Inter",
|
"fontFamily": "Inter",
|
||||||
"fontSize": 14,
|
"fontSize": 14,
|
||||||
"fontWeight": "500"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -2787,14 +2814,41 @@
|
|||||||
"fontSize": 18,
|
"fontSize": 18,
|
||||||
"fontWeight": "600"
|
"fontWeight": "600"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "frame",
|
||||||
|
"id": "IAiZI",
|
||||||
|
"name": "priceLine",
|
||||||
|
"gap": 8,
|
||||||
|
"alignItems": "center",
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "Rg4EJ",
|
"id": "oFR6Q",
|
||||||
"fill": "#71717A",
|
"fill": "#71717A",
|
||||||
"content": "$9.99",
|
"content": "$9.99",
|
||||||
"fontFamily": "Inter",
|
"fontFamily": "Inter",
|
||||||
"fontSize": 14,
|
"fontSize": 14,
|
||||||
"fontWeight": "500"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -2932,14 +2986,41 @@
|
|||||||
"fontSize": 18,
|
"fontSize": 18,
|
||||||
"fontWeight": "600"
|
"fontWeight": "600"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "frame",
|
||||||
|
"id": "gSnzm",
|
||||||
|
"name": "priceLine",
|
||||||
|
"gap": 8,
|
||||||
|
"alignItems": "center",
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "BxrWP",
|
"id": "g3vvN",
|
||||||
"fill": "#71717A",
|
"fill": "#71717A",
|
||||||
"content": "$19.99",
|
"content": "$19.99",
|
||||||
"fontFamily": "Inter",
|
"fontFamily": "Inter",
|
||||||
"fontSize": 14,
|
"fontSize": 14,
|
||||||
"fontWeight": "500"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -3060,14 +3141,41 @@
|
|||||||
"fontSize": 18,
|
"fontSize": 18,
|
||||||
"fontWeight": "600"
|
"fontWeight": "600"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "frame",
|
||||||
|
"id": "QcmBX",
|
||||||
|
"name": "priceLine",
|
||||||
|
"gap": 8,
|
||||||
|
"alignItems": "center",
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "lydr2",
|
"id": "QbQa2",
|
||||||
"fill": "#71717A",
|
"fill": "#71717A",
|
||||||
"content": "$49.99",
|
"content": "$49.99",
|
||||||
"fontFamily": "Inter",
|
"fontFamily": "Inter",
|
||||||
"fontSize": 14,
|
"fontSize": 14,
|
||||||
"fontWeight": "500"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -3140,14 +3248,41 @@
|
|||||||
"fontSize": 18,
|
"fontSize": 18,
|
||||||
"fontWeight": "600"
|
"fontWeight": "600"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "frame",
|
||||||
|
"id": "xMHYZ",
|
||||||
|
"name": "priceLine",
|
||||||
|
"gap": 8,
|
||||||
|
"alignItems": "center",
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "nG15k",
|
"id": "wLYzH",
|
||||||
"fill": "#71717A",
|
"fill": "#71717A",
|
||||||
"content": "$99.99",
|
"content": "$99.99",
|
||||||
"fontFamily": "Inter",
|
"fontFamily": "Inter",
|
||||||
"fontSize": 14,
|
"fontSize": 14,
|
||||||
"fontWeight": "500"
|
"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",
|
"type": "frame",
|
||||||
"id": "BNNL5",
|
"id": "BNNL5",
|
||||||
"name": "contentArea",
|
"name": "contentArea",
|
||||||
|
"context": "Content area: creditsCard + videoPreviewArea scrollable; resolutionRow + generateBtn fixed at bottom.",
|
||||||
"width": "fill_container",
|
"width": "fill_container",
|
||||||
"height": "fill_container",
|
"height": "fill_container",
|
||||||
"layout": "vertical",
|
"layout": "vertical",
|
||||||
@ -3400,6 +3536,7 @@
|
|||||||
"type": "frame",
|
"type": "frame",
|
||||||
"id": "vA8QJ",
|
"id": "vA8QJ",
|
||||||
"name": "videoPreviewArea",
|
"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",
|
"width": "fill_container",
|
||||||
"height": 280,
|
"height": 280,
|
||||||
"fill": {
|
"fill": {
|
||||||
@ -3413,80 +3550,13 @@
|
|||||||
"thickness": 1,
|
"thickness": 1,
|
||||||
"fill": "#E4E4E7"
|
"fill": "#E4E4E7"
|
||||||
},
|
},
|
||||||
"layout": "none",
|
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "frame",
|
"type": "frame",
|
||||||
"id": "oYwsm",
|
"id": "oYwsm",
|
||||||
"name": "resolutionRow",
|
"name": "resolutionRow",
|
||||||
|
"context": "Resolution options: 480p/720p. Fixed at screen bottom. When switching, update Generate Video credits (RGKvY).",
|
||||||
"width": "fill_container",
|
"width": "fill_container",
|
||||||
"height": 44,
|
"height": 44,
|
||||||
"gap": 12,
|
"gap": 12,
|
||||||
@ -3583,6 +3653,7 @@
|
|||||||
"type": "frame",
|
"type": "frame",
|
||||||
"id": "yunXY",
|
"id": "yunXY",
|
||||||
"name": "generateBtn",
|
"name": "generateBtn",
|
||||||
|
"context": "Generate Video button. Fixed at screen bottom. Click flow: image picker -> API. See docs/generate_video.md.",
|
||||||
"width": "fill_container",
|
"width": "fill_container",
|
||||||
"height": 56,
|
"height": 56,
|
||||||
"fill": "#8B5CF6",
|
"fill": "#8B5CF6",
|
||||||
@ -3614,6 +3685,7 @@
|
|||||||
"type": "frame",
|
"type": "frame",
|
||||||
"id": "RGKvY",
|
"id": "RGKvY",
|
||||||
"name": "creditsCost",
|
"name": "creditsCost",
|
||||||
|
"context": "Credits cost for current resolution. Updates when resolution (oYwsm) changes.",
|
||||||
"fill": "#FFFFFF30",
|
"fill": "#FFFFFF30",
|
||||||
"cornerRadius": 8,
|
"cornerRadius": 8,
|
||||||
"gap": 4,
|
"gap": 4,
|
||||||
@ -3803,6 +3875,7 @@
|
|||||||
"type": "frame",
|
"type": "frame",
|
||||||
"id": "rSN3T",
|
"id": "rSN3T",
|
||||||
"name": "videoDisplay",
|
"name": "videoDisplay",
|
||||||
|
"context": "Video area: display uploaded image with crop (fill/cover). Matches progress screen.",
|
||||||
"clip": true,
|
"clip": true,
|
||||||
"width": "fill_container",
|
"width": "fill_container",
|
||||||
"height": 360,
|
"height": 360,
|
||||||
@ -3842,6 +3915,7 @@
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "Eghqc",
|
"id": "Eghqc",
|
||||||
"name": "progressLabel",
|
"name": "progressLabel",
|
||||||
|
"context": "Progress label: 1=队列中 2=处理中 3=完成 4=超时 5=错误 6=中止. Updated from /v1/image/progress every 1s.",
|
||||||
"fill": "#18181B",
|
"fill": "#18181B",
|
||||||
"content": "Generating...",
|
"content": "Generating...",
|
||||||
"fontFamily": "Inter",
|
"fontFamily": "Inter",
|
||||||
|
|||||||
@ -1,146 +1,173 @@
|
|||||||
# petsHeroAI 接口调用流程说明
|
# 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(path), power_level(method),
|
||||||
└─────────────┘
|
│ │ quest_rank(headers), battle_score(queryParams),
|
||||||
│
|
│ │ loyalty_index(body) + 噪音字段
|
||||||
▼
|
|
||||||
┌─────────────┐
|
|
||||||
│ 构造代理请求 │ 填入 hero_class, pet_species, power_level 等参数
|
|
||||||
└─────────────┘
|
└─────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
POST {baseUrl}/quester/defender/summoner
|
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 解析
|
||||||
│ 提取密文 │ 从响应中获取加密字段
|
|
||||||
└─────────────┘
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────┐
|
取 vault.tome.codex.grimoire.sanctum
|
||||||
│ Base64 解码 │
|
|
||||||
└─────────────┘
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────┐
|
helm → code, rampart → msg, sidekick → data
|
||||||
│ AES 解密 │ AES-128-ECB 解密
|
|
||||||
└─────────────┘
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────┐
|
ApiResponse(code, msg, data)
|
||||||
│ JSON 解析 │
|
|
||||||
└─────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────┐
|
|
||||||
│ 字段逆映射 │ V2 字段 → 原始字段 (便于业务使用)
|
|
||||||
└─────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
业务数据 (code/msg/data)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 三、接口分类与调用顺序
|
---
|
||||||
|
|
||||||
### 3.1 登录与用户
|
## 三、按业务场景的接口调用顺序
|
||||||
|
|
||||||
|
### 3.1 应用启动与登录(AuthService.init)
|
||||||
|
|
||||||
| 顺序 | 接口 | 方法 | 说明 |
|
| 顺序 | 接口 | 方法 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| 1 | `/v1/user/fast_login` | POST | 设备快速登录,获取 userToken |
|
| 1 | `/v1/user/fast_login` | POST | 设备快速登录;body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 |
|
||||||
| 2 | `/v1/user/common_info` | GET | 获取用户通用信息(含积分、头像等) |
|
| 2 | (保存 token、用户信息到 UserState;首次登录打点 register) | — | — |
|
||||||
| 3 | `/v1/user/account` | GET | 获取用户账户信息 |
|
| 3 | `/v1/user/referrer` | POST | 归因上报;query: sentinel, asset(uid), portal;body: digest, origin。 |
|
||||||
| 4 | `/v1/user/referrer` | POST | 归因上报 |
|
| 4 | `/v1/user/common_info` | GET | 获取用户通用信息;query: sentinel, asset(uid)。解析 data 写入 UserState,并解析 surge 中 enable_third_party_payment 等。 |
|
||||||
| 5 | `/v1/user/delete` | GET | 注销账户 |
|
|
||||||
|
|
||||||
### 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 商品列表 |
|
| 1 | `/v1/image/upload-presigned-url` | POST | 获取上传 URL;body: gateway(fileName1), action(fileName2), pauldron(contentType), stronghold(expectedSize)。返回的 URL 用于**直接 PUT 上传图片**(不经过代理)。 |
|
||||||
| 2 | `/v1/payment/getApplePayActivities` | GET | 获取 Apple 商品列表 |
|
| 2 | (客户端 PUT 上传图片到 presigned URL) | PUT | 不经过代理,请求头按接口要求设置。 |
|
||||||
| 3 | `/v1/payment/createPayment` | POST | 创建支付订单 |
|
| 3 | `/v1/image/create-task` | POST | query: asset;body: commission(srcImg1Url 等)、guild(分辨率) 等。返回任务信息。 |
|
||||||
| 4 | `/v1/payment/googlepay` | POST | Google 支付结果回调 |
|
| 4 | `/v1/image/progress` | GET | query: sentinel, tree(taskId), asset。进度页**轮询**(如每 1s),直到 state 为 3(完成)/4/5/6。 |
|
||||||
| 5 | `/v1/payment/applepay` | POST | Apple 支付结果回调 |
|
| 5 | (生成完成后)`/v1/user/account` | GET | 刷新用户积分。 |
|
||||||
| 6 | `/v1/payment/getPaymentDetailList` | 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 | 获取推荐提示词 |
|
| 首页分类/列表 | `/v1/image/img2video/categories` | GET | 获取图转视频分类。 |
|
||||||
| 2 | `/v1/image/txt2img_tags` | GET | 获取文生图标签 |
|
| | `/v1/image/img2video/tasks` | GET | 获取图转视频任务列表;可选 query: insignia(categoryId)。 |
|
||||||
| 3 | `/v1/image/txt2img_prompts` | POST | 获取文生图提示词模板 |
|
| 图库「我的」 | `/v1/image/my-tasks` | GET | 获取我的任务列表;query: sentinel, trophy/heatmap/platoon 等。 |
|
||||||
| 4 | `/v1/image/txt2img_create` | POST | 创建文生图任务 |
|
|
||||||
| 5 | `/v1/image/progress` | GET | 查询图片生成进度 |
|
|
||||||
|
|
||||||
### 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 换衣 / 换脸
|
### 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 其他
|
|
||||||
|
|
||||||
| 接口 | 方法 | 说明 |
|
| 接口 | 方法 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/v1/image/category-list` | GET | 获取分类列表 |
|
| `/v1/user/account` | GET | 获取账户信息(积分等);个人页、生成视频完成后刷新积分时调用。 |
|
||||||
| `/v1/log/appevent` | POST | App 事件打点上报 |
|
| `/v1/image/getCreditsPageInfo` | GET | 获取积分页信息。 |
|
||||||
| `/v1/image/getCreditsPageInfo` | GET | 获取积分页面信息 |
|
| `/v1/image/prompt/recomends` | GET | 获取推荐提示词。 |
|
||||||
| `/v1/log/uploadUrl` | POST | 获取预签名上传 URL |
|
| `/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 字段 | 说明 |
|
| 原始字段 | V2 字段 | 说明 |
|
||||||
|----------|---------|------|
|
|----------|---------|------|
|
||||||
| pkg | portal | 应用包名,必填,如 `com.petsheroai.app` |
|
| pkg | portal | 应用包名,必填;ProxyClient 自动带。 |
|
||||||
| User_token | knight | 用户登录 token |
|
| User_token | knight | 用户 token;登录成功后 ProxyClient 自动带。 |
|
||||||
|
|
||||||
## 五、通用响应结构
|
---
|
||||||
|
|
||||||
|
## 六、通用响应结构(解密后业务层)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -150,7 +177,9 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 六、错误码
|
---
|
||||||
|
|
||||||
|
## 七、错误码
|
||||||
|
|
||||||
| code | 说明 |
|
| code | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# UI 开发流程
|
# UI逻辑
|
||||||
当点击中间upload an image as the base for generation 区域的时候,调用手机相册功能选择一张图片,一次只允许一张。用户选完图片后图片显示在 upload an image as the base for generation区域,并隐藏提示信息。
|
1.点击Generate Video的时候先进入选择图片的流程,可以是拍照或相册选择。
|
||||||
当点击Generate Video的时候如果用户没有选择图片择提示用户选择图片,如果已经选择了图片则开始按下面的步骤调用接口
|
2.选择完图片后进入接口调用步骤
|
||||||
# 接口调用
|
# 接口调用
|
||||||
## 第一步
|
## 第一步
|
||||||
通过/v1/image/upload-presigned-url接口获取文件上传地址,通常是uploadUrl1或者uploadUrl2,以及对应的filePath1和filePath2用于生成视频接口的入参
|
通过/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
|
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||||
FLUTTER_TARGET=lib/main.dart
|
FLUTTER_TARGET=lib/main.dart
|
||||||
FLUTTER_BUILD_DIR=build
|
FLUTTER_BUILD_DIR=build
|
||||||
FLUTTER_BUILD_NAME=1.0.0
|
FLUTTER_BUILD_NAME=1.0.2
|
||||||
FLUTTER_BUILD_NUMBER=1
|
FLUTTER_BUILD_NUMBER=3
|
||||||
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
||||||
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
||||||
DART_OBFUSCATION=false
|
DART_OBFUSCATION=false
|
||||||
|
|||||||
@ -5,8 +5,8 @@ export "FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client"
|
|||||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||||
export "FLUTTER_TARGET=lib/main.dart"
|
export "FLUTTER_TARGET=lib/main.dart"
|
||||||
export "FLUTTER_BUILD_DIR=build"
|
export "FLUTTER_BUILD_DIR=build"
|
||||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
export "FLUTTER_BUILD_NAME=1.0.2"
|
||||||
export "FLUTTER_BUILD_NUMBER=1"
|
export "FLUTTER_BUILD_NUMBER=3"
|
||||||
export "DART_OBFUSCATION=false"
|
export "DART_OBFUSCATION=false"
|
||||||
export "TRACK_WIDGET_CREATION=true"
|
export "TRACK_WIDGET_CREATION=true"
|
||||||
export "TREE_SHAKE_ICONS=false"
|
export "TREE_SHAKE_ICONS=false"
|
||||||
|
|||||||
@ -30,6 +30,12 @@
|
|||||||
@import image_picker_ios;
|
@import image_picker_ios;
|
||||||
#endif
|
#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>)
|
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
|
||||||
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
|
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
|
||||||
#else
|
#else
|
||||||
@ -42,6 +48,12 @@
|
|||||||
@import sqflite_darwin;
|
@import sqflite_darwin;
|
||||||
#endif
|
#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>)
|
#if __has_include(<video_player_avfoundation/FVPVideoPlayerPlugin.h>)
|
||||||
#import <video_player_avfoundation/FVPVideoPlayerPlugin.h>
|
#import <video_player_avfoundation/FVPVideoPlayerPlugin.h>
|
||||||
#else
|
#else
|
||||||
@ -54,6 +66,12 @@
|
|||||||
@import video_thumbnail;
|
@import video_thumbnail;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<webview_flutter_wkwebview/WebViewFlutterPlugin.h>)
|
||||||
|
#import <webview_flutter_wkwebview/WebViewFlutterPlugin.h>
|
||||||
|
#else
|
||||||
|
@import webview_flutter_wkwebview;
|
||||||
|
#endif
|
||||||
|
|
||||||
@implementation GeneratedPluginRegistrant
|
@implementation GeneratedPluginRegistrant
|
||||||
|
|
||||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||||
@ -61,10 +79,13 @@
|
|||||||
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
||||||
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
||||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||||
|
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
||||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||||
|
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||||
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
|
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
|
||||||
[VideoThumbnailPlugin registerWithRegistrar:[registry registrarForPlugin:@"VideoThumbnailPlugin"]];
|
[VideoThumbnailPlugin registerWithRegistrar:[registry registrarForPlugin:@"VideoThumbnailPlugin"]];
|
||||||
|
[WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
13
lib/app.dart
13
lib/app.dart
@ -12,6 +12,7 @@ import 'features/home/models/task_item.dart';
|
|||||||
import 'features/profile/profile_screen.dart';
|
import 'features/profile/profile_screen.dart';
|
||||||
import 'features/recharge/recharge_screen.dart';
|
import 'features/recharge/recharge_screen.dart';
|
||||||
import 'shared/widgets/bottom_nav_bar.dart';
|
import 'shared/widgets/bottom_nav_bar.dart';
|
||||||
|
import 'shared/tab_selector_scope.dart';
|
||||||
|
|
||||||
/// Root app widget with navigation
|
/// Root app widget with navigation
|
||||||
class App extends StatefulWidget {
|
class App extends StatefulWidget {
|
||||||
@ -27,6 +28,8 @@ class _AppState extends State<App> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return UserCreditsScope(
|
return UserCreditsScope(
|
||||||
|
child: TabSelectorScope(
|
||||||
|
selectTab: (tab) => setState(() => _currentTab = tab),
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'AI Video App',
|
title: 'AI Video App',
|
||||||
theme: AppTheme.light,
|
theme: AppTheme.light,
|
||||||
@ -52,8 +55,13 @@ class _AppState extends State<App> {
|
|||||||
return GenerateVideoScreen(task: task);
|
return GenerateVideoScreen(task: task);
|
||||||
},
|
},
|
||||||
'/progress': (ctx) {
|
'/progress': (ctx) {
|
||||||
final taskId = ModalRoute.of(ctx)?.settings.arguments;
|
final args = ModalRoute.of(ctx)?.settings.arguments;
|
||||||
return GenerateProgressScreen(taskId: taskId);
|
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) {
|
'/result': (ctx) {
|
||||||
final mediaItem =
|
final mediaItem =
|
||||||
@ -62,6 +70,7 @@ class _AppState extends State<App> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,8 @@ abstract final class ApiConfig {
|
|||||||
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
|
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
|
||||||
|
|
||||||
/// 生产环境域名
|
/// 生产环境域名
|
||||||
static const String prodBaseUrl = 'https://ai.petsheroai.xyz';
|
static const String prodBaseUrl =
|
||||||
|
'https://pre-ai.petsheroai.xyz'; //https://ai.petsheroai.xyz
|
||||||
|
|
||||||
/// 代理入口路径
|
/// 代理入口路径
|
||||||
static const String proxyPath = '/quester/defender/summoner';
|
static const String proxyPath = '/quester/defender/summoner';
|
||||||
|
|||||||
@ -3,13 +3,165 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../log/app_logger.dart';
|
||||||
import 'api_config.dart';
|
import 'api_config.dart';
|
||||||
import 'api_crypto.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) {
|
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({
|
static Future<ApiResponse> createPayment({
|
||||||
required String sentinel,
|
required String sentinel,
|
||||||
required String asset,
|
required String asset,
|
||||||
required String warrior,
|
required String warrior,
|
||||||
required String resource,
|
required String resource,
|
||||||
|
String? ceremony,
|
||||||
String? lineage,
|
String? lineage,
|
||||||
String? armor,
|
String? armor,
|
||||||
}) async {
|
}) async {
|
||||||
@ -65,9 +81,33 @@ abstract final class PaymentApi {
|
|||||||
'asset': asset,
|
'asset': asset,
|
||||||
'warrior': warrior,
|
'warrior': warrior,
|
||||||
'resource': resource,
|
'resource': resource,
|
||||||
|
if (ceremony != null && ceremony.isNotEmpty) 'ceremony': ceremony,
|
||||||
if (lineage != null) 'lineage': lineage,
|
if (lineage != null) 'lineage': lineage,
|
||||||
if (armor != null) 'armor': armor,
|
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({
|
static Future<ApiResponse> getCommonInfo({
|
||||||
required String sentinel,
|
required String sentinel,
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import '../adjust/adjust_events.dart';
|
import '../adjust/adjust_events.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
|
import '../api/api_config.dart';
|
||||||
import '../api/proxy_client.dart';
|
import '../api/proxy_client.dart';
|
||||||
import '../api/services/user_api.dart';
|
import '../api/services/user_api.dart';
|
||||||
|
import '../log/app_logger.dart';
|
||||||
import '../referrer/referrer_service.dart';
|
import '../referrer/referrer_service.dart';
|
||||||
import '../user/user_state.dart';
|
import '../user/user_state.dart';
|
||||||
|
|
||||||
@ -17,16 +19,15 @@ import '../user/user_state.dart';
|
|||||||
class AuthService {
|
class AuthService {
|
||||||
AuthService._();
|
AuthService._();
|
||||||
|
|
||||||
static const _tag = '[AuthService]';
|
static final _log = AppLogger('AuthService');
|
||||||
|
|
||||||
static Future<void>? _loginFuture;
|
static Future<void>? _loginFuture;
|
||||||
|
|
||||||
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
||||||
static Future<void> get loginComplete =>
|
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
||||||
_loginFuture ?? Future<void>.value();
|
|
||||||
|
|
||||||
static void _log(String msg) {
|
static void _logMsg(String msg) {
|
||||||
debugPrint('$_tag $msg');
|
_log.d(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取设备 ID(Android: androidId, iOS: identifierForVendor, Web: fallback)
|
/// 获取设备 ID(Android: androidId, iOS: identifierForVendor, Web: fallback)
|
||||||
@ -51,6 +52,31 @@ class AuthService {
|
|||||||
return digest.toString().toUpperCase();
|
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 启动时调用快速登录
|
/// APP 启动时调用快速登录
|
||||||
/// 启动时网络可能未就绪,会延迟后重试
|
/// 启动时网络可能未就绪,会延迟后重试
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
@ -58,7 +84,7 @@ class AuthService {
|
|||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
_loginFuture = completer.future;
|
_loginFuture = completer.future;
|
||||||
|
|
||||||
_log('init: 开始快速登录');
|
_logMsg('init: 开始快速登录');
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
const retryDelay = Duration(seconds: 2);
|
const retryDelay = Duration(seconds: 2);
|
||||||
|
|
||||||
@ -67,20 +93,20 @@ class AuthService {
|
|||||||
await Future<void>.delayed(const Duration(seconds: 2));
|
await Future<void>.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
final deviceId = await _getDeviceId();
|
final deviceId = await _getDeviceId();
|
||||||
_log('init: deviceId=$deviceId');
|
_logMsg('init: deviceId=$deviceId');
|
||||||
|
|
||||||
final sign = _computeSign(deviceId);
|
final sign = _computeSign(deviceId);
|
||||||
_log('init: sign=$sign');
|
_logMsg('init: sign=$sign');
|
||||||
|
|
||||||
final crest = await ReferrerService.getReferrer();
|
final crest = await ReferrerService.getReferrer();
|
||||||
if (crest != null && crest.isNotEmpty) {
|
if (crest != null && crest.isNotEmpty) {
|
||||||
_log('init: crest(referrer)=$crest');
|
_logMsg('init: crest(referrer)=$crest');
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse? res;
|
ApiResponse? res;
|
||||||
for (var i = 0; i < maxRetries; i++) {
|
for (var i = 0; i < maxRetries; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
_log('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
|
_logMsg('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
|
||||||
await Future<void>.delayed(retryDelay);
|
await Future<void>.delayed(retryDelay);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -92,23 +118,23 @@ class AuthService {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log('init: 第 ${i + 1} 次请求失败: $e');
|
_logMsg('init: 第 ${i + 1} 次请求失败: $e');
|
||||||
if (i == maxRetries - 1) rethrow;
|
if (i == maxRetries - 1) rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res == null) return;
|
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) {
|
if (res.isSuccess && res.data != null) {
|
||||||
final data = res.data as Map<String, dynamic>?;
|
final data = res.data as Map<String, dynamic>?;
|
||||||
final token = data?['reevaluate'] as String?;
|
final token = data?['reevaluate'] as String?;
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
ApiClient.instance.setUserToken(token);
|
ApiClient.instance.setUserToken(token);
|
||||||
_log('init: 已设置 userToken');
|
_logMsg('init: 已设置 userToken');
|
||||||
} else {
|
} else {
|
||||||
_log('init: 响应中无 reevaluate (userToken)');
|
_logMsg('init: 响应中无 reevaluate (userToken)');
|
||||||
}
|
}
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final hadLoggedIn = prefs.getBool('adjust_has_logged_in') ?? false;
|
final hadLoggedIn = prefs.getBool('adjust_has_logged_in') ?? false;
|
||||||
@ -119,17 +145,17 @@ class AuthService {
|
|||||||
'adjust_register_date',
|
'adjust_register_date',
|
||||||
DateTime.now().toIso8601String().substring(0, 10),
|
DateTime.now().toIso8601String().substring(0, 10),
|
||||||
);
|
);
|
||||||
_log('init: 首次登录,已上报 register');
|
_logMsg('init: 首次登录,已上报 register');
|
||||||
}
|
}
|
||||||
final credits = data?['reveal'] as int?;
|
final credits = data?['reveal'] as int?;
|
||||||
if (credits != null) {
|
if (credits != null) {
|
||||||
UserState.setCredits(credits);
|
UserState.setCredits(credits);
|
||||||
_log('init: 已同步积分 $credits');
|
_logMsg('init: 已同步积分 $credits');
|
||||||
}
|
}
|
||||||
final uid = data?['asset'] as String?;
|
final uid = data?['asset'] as String?;
|
||||||
if (uid != null && uid.isNotEmpty) {
|
if (uid != null && uid.isNotEmpty) {
|
||||||
UserState.setUserId(uid);
|
UserState.setUserId(uid);
|
||||||
_log('init: 已设置 userId');
|
_logMsg('init: 已设置 userId');
|
||||||
}
|
}
|
||||||
final avatarUrl = data?['realm'] as String?;
|
final avatarUrl = data?['realm'] as String?;
|
||||||
if (avatarUrl != null && avatarUrl.isNotEmpty) {
|
if (avatarUrl != null && avatarUrl.isNotEmpty) {
|
||||||
@ -143,12 +169,52 @@ class AuthService {
|
|||||||
if (countryCode != null) {
|
if (countryCode != null) {
|
||||||
UserState.setNavigate(countryCode);
|
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 {
|
} else {
|
||||||
_log('init: 登录失败');
|
_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 {
|
||||||
|
_logMsg('init: 登录失败');
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
_log('init: 异常 $e');
|
_logMsg('init: 异常 $e');
|
||||||
_log('init: 堆栈 $st');
|
_logMsg('init: 堆栈 $st');
|
||||||
} finally {
|
} finally {
|
||||||
if (!completer.isCompleted) completer.complete();
|
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 shadowLight = Color(0x14000000); // #00000008
|
||||||
static const Color shadowMedium = Color(0x0D000000); // #0000000D
|
static const Color shadowMedium = Color(0x0D000000); // #0000000D
|
||||||
static const Color shadowSoft = Color(0x0A000000); // #0000000A
|
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);
|
static final ValueNotifier<String?> userName = ValueNotifier<String?>(null);
|
||||||
/// 国家码 (navigate / countryCode)
|
/// 国家码 (navigate / countryCode)
|
||||||
static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null);
|
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) {
|
static void setCredits(int? value) {
|
||||||
credits.value = value;
|
credits.value = value;
|
||||||
@ -31,6 +34,10 @@ class UserState {
|
|||||||
navigate.value = value;
|
navigate.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void setEnableThirdPartyPayment(bool? value) {
|
||||||
|
enableThirdPartyPayment.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
static String formatCredits(int? value) {
|
static String formatCredits(int? value) {
|
||||||
if (value == null) return '--';
|
if (value == null) return '--';
|
||||||
return value.toString().replaceAllMapped(
|
return value.toString().replaceAllMapped(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_lucide/flutter_lucide.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 '../../shared/widgets/top_nav_bar.dart';
|
||||||
|
|
||||||
import '../../core/api/services/image_api.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 {
|
class GenerateProgressScreen extends StatefulWidget {
|
||||||
const GenerateProgressScreen({super.key, this.taskId});
|
const GenerateProgressScreen({super.key, this.taskId, this.imagePath});
|
||||||
|
|
||||||
final dynamic taskId;
|
final dynamic taskId;
|
||||||
|
final String? imagePath;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
|
State<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||||
double _progress = 0;
|
int? _state;
|
||||||
Timer? _pollTimer;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.taskId != null) {
|
if (widget.taskId != null) {
|
||||||
_startPolling();
|
_startPolling();
|
||||||
} else {
|
} else {
|
||||||
_progress = 0.45;
|
_state = 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,8 +106,10 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
|
|
||||||
Future<void> _startPolling() async {
|
Future<void> _startPolling() async {
|
||||||
await AuthService.loginComplete;
|
await AuthService.loginComplete;
|
||||||
|
_pollTimer = Timer.periodic(
|
||||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) => _fetchProgress());
|
const Duration(seconds: 1),
|
||||||
|
(_) => _fetchProgress(),
|
||||||
|
);
|
||||||
_fetchProgress();
|
_fetchProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,32 +126,53 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
if (!res.isSuccess || res.data == null) return;
|
if (!res.isSuccess || res.data == null) return;
|
||||||
|
|
||||||
final data = res.data as Map<String, dynamic>;
|
final data = res.data as Map<String, dynamic>;
|
||||||
final progress = (data['dice'] as num?)?.toInt() ?? 0;
|
|
||||||
final state = (data['listing'] as num?)?.toInt();
|
final state = (data['listing'] as num?)?.toInt();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() => _state = state);
|
||||||
_progress = progress / 100.0;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (progress >= 100 || state == 2) {
|
switch (state) {
|
||||||
|
case 3: // 完成
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
Navigator.of(context).pushReplacementNamed('/result', arguments: widget.taskId);
|
// 生成成功后同步更新积分
|
||||||
} else if (state == 3) {
|
final userId = UserState.userId.value;
|
||||||
_pollTimer?.cancel();
|
if (userId != null && userId.isNotEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
try {
|
||||||
const SnackBar(
|
final accountRes = await UserApi.getAccount(
|
||||||
content: Text('Generation failed'),
|
sentinel: ApiConfig.appId,
|
||||||
behavior: SnackBarBehavior.floating,
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final labelText = _stateLabels[_state] ?? '队列中';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
appBar: PreferredSize(
|
appBar: PreferredSize(
|
||||||
@ -96,7 +180,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
child: TopNavBar(
|
child: TopNavBar(
|
||||||
title: 'Generating',
|
title: 'Generating',
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
onBack: () => Navigator.of(context).pop(),
|
onBack: () => _onBack(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
@ -104,7 +188,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_VideoPreview(),
|
_VideoPreview(imagePath: widget.imagePath),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
Text(
|
Text(
|
||||||
'Video generation may take some time. Please wait patiently.',
|
'Video generation may take some time. Please wait patiently.',
|
||||||
@ -114,7 +198,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
_ProgressSection(progress: _progress),
|
_ProgressSection(progress: _progress, label: labelText),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -122,17 +206,33 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Video area rSN3T: show uploaded image with crop (BoxFit.cover)
|
||||||
class _VideoPreview extends StatelessWidget {
|
class _VideoPreview extends StatelessWidget {
|
||||||
|
const _VideoPreview({this.imagePath});
|
||||||
|
|
||||||
|
final String? imagePath;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final hasImage =
|
||||||
|
imagePath != null && imagePath!.isNotEmpty && File(imagePath!).existsSync();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 360,
|
height: 360,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.surfaceAlt,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppColors.border, width: 1),
|
border: Border.all(color: AppColors.border, width: 1),
|
||||||
),
|
),
|
||||||
child: Column(
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: hasImage
|
||||||
|
? Image.file(
|
||||||
|
File(imagePath!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@ -153,20 +253,23 @@ class _VideoPreview extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Progress bar: 3 stages. Label Eghqc shows state (队列中|处理中|完成|超时|错误|中止)
|
||||||
class _ProgressSection extends StatelessWidget {
|
class _ProgressSection extends StatelessWidget {
|
||||||
const _ProgressSection({required this.progress});
|
const _ProgressSection({required this.progress, required this.label});
|
||||||
|
|
||||||
final double progress;
|
final double progress;
|
||||||
|
final String label;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final percentage = (progress * 100).round();
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Generating... $percentage%',
|
label,
|
||||||
style: AppTypography.bodyMedium.copyWith(
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import 'dart:developer' as developer;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../core/auth/auth_service.dart';
|
import '../../core/auth/auth_service.dart';
|
||||||
|
import '../../core/log/app_logger.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_spacing.dart';
|
import '../../core/theme/app_spacing.dart';
|
||||||
import '../../core/theme/app_typography.dart';
|
import '../../core/theme/app_typography.dart';
|
||||||
@ -23,6 +25,8 @@ import '../../core/api/services/user_api.dart';
|
|||||||
class GenerateVideoScreen extends StatefulWidget {
|
class GenerateVideoScreen extends StatefulWidget {
|
||||||
const GenerateVideoScreen({super.key, this.task});
|
const GenerateVideoScreen({super.key, this.task});
|
||||||
|
|
||||||
|
static final _log = AppLogger('GenerateVideo');
|
||||||
|
|
||||||
final TaskItem? task;
|
final TaskItem? task;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -32,7 +36,6 @@ class GenerateVideoScreen extends StatefulWidget {
|
|||||||
enum _Resolution { p480, p720 }
|
enum _Resolution { p480, p720 }
|
||||||
|
|
||||||
class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||||
File? _selectedImage;
|
|
||||||
_Resolution _selectedResolution = _Resolution.p480;
|
_Resolution _selectedResolution = _Resolution.p480;
|
||||||
bool _isGenerating = false;
|
bool _isGenerating = false;
|
||||||
|
|
||||||
@ -54,15 +57,15 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
developer.log(
|
GenerateVideoScreen._log.d('opened with task: ${widget.task}');
|
||||||
'GenerateVideoScreen opened with task: ${widget.task}',
|
|
||||||
name: 'GenerateVideoScreen',
|
|
||||||
);
|
|
||||||
debugPrint('[GenerateVideoScreen] 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>(
|
final source = await showModalBottomSheet<ImageSource>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
@ -83,43 +86,25 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (source != null && mounted) {
|
if (source == null || !mounted) return;
|
||||||
_pickImage(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickImage(ImageSource source) async {
|
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final picked = await picker.pickImage(
|
final picked = await picker.pickImage(
|
||||||
source: source,
|
source: source,
|
||||||
imageQuality: 85,
|
imageQuality: 85,
|
||||||
);
|
);
|
||||||
if (picked != null && mounted) {
|
if (picked == null || !mounted) return;
|
||||||
setState(() {
|
|
||||||
_selectedImage = File(picked.path);
|
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);
|
setState(() => _isGenerating = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthService.loginComplete;
|
await AuthService.loginComplete;
|
||||||
|
|
||||||
final file = _selectedImage!;
|
|
||||||
final size = await file.length();
|
final size = await file.length();
|
||||||
final ext = file.path.split('.').last.toLowerCase();
|
final ext = file.path.split('.').last.toLowerCase();
|
||||||
final contentType = ext == 'png'
|
final contentType = ext == 'png'
|
||||||
@ -212,11 +197,12 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context)
|
Navigator.of(context).pushReplacementNamed(
|
||||||
.pushReplacementNamed('/progress', arguments: taskId);
|
'/progress',
|
||||||
|
arguments: <String, dynamic>{'taskId': taskId, 'imagePath': file.path},
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
developer.log('Generate failed: $e', name: 'GenerateVideoScreen');
|
GenerateVideoScreen._log.e('Generate failed', e, st);
|
||||||
debugPrint('[GenerateVideoScreen] error: $e\n$st');
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@ -247,33 +233,62 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: Column(
|
||||||
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.screenPaddingLarge,
|
||||||
|
AppSpacing.screenPaddingLarge,
|
||||||
|
AppSpacing.screenPaddingLarge,
|
||||||
|
AppSpacing.lg,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_CreditsCard(
|
_CreditsCard(
|
||||||
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
credits:
|
||||||
|
UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
_UploadArea(
|
_VideoPreviewArea(
|
||||||
selectedImage: _selectedImage,
|
videoUrl: widget.task?.previewVideoUrl,
|
||||||
onUpload: _showImageSourcePicker,
|
imageUrl: widget.task?.previewImageUrl,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
_ResolutionToggle(
|
||||||
selected: _selectedResolution,
|
selected: _selectedResolution,
|
||||||
onChanged: (r) => setState(() => _selectedResolution = r),
|
onChanged: (r) =>
|
||||||
|
setState(() => _selectedResolution = r),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
_GenerateButton(
|
_GenerateButton(
|
||||||
onGenerate: _onGenerate,
|
onGenerate: _onGenerateButtonTap,
|
||||||
isLoading: _isGenerating,
|
isLoading: _isGenerating,
|
||||||
credits: _currentCredits.toString(),
|
credits: _currentCredits.toString(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -333,63 +348,176 @@ class _CreditsCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UploadArea extends StatelessWidget {
|
/// Video preview area - video URL from card click. Auto-load and play on init.
|
||||||
const _UploadArea({
|
/// Video fit: contain (no crop). Loading animation until ready.
|
||||||
required this.selectedImage,
|
class _VideoPreviewArea extends StatefulWidget {
|
||||||
required this.onUpload,
|
const _VideoPreviewArea({
|
||||||
|
this.videoUrl,
|
||||||
|
this.imageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
final File? selectedImage;
|
final String? videoUrl;
|
||||||
final VoidCallback onUpload;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
final isReady =
|
||||||
onTap: onUpload,
|
_controller != null && _controller!.value.isInitialized;
|
||||||
child: Container(
|
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
|
||||||
height: 280,
|
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(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surfaceAlt,
|
color: AppColors.surfaceAlt,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.border,
|
color: AppColors.border,
|
||||||
width: 2,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: selectedImage != null
|
child: Stack(
|
||||||
? Image.file(
|
fit: StackFit.expand,
|
||||||
selectedImage!,
|
alignment: Alignment.center,
|
||||||
fit: BoxFit.cover,
|
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,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
|
placeholder: (_, __) => _LoadingOverlay(isLoading: true),
|
||||||
|
errorWidget: (_, __, ___) => _LoadingOverlay(isLoading: false),
|
||||||
)
|
)
|
||||||
: Column(
|
else
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
_LoadingOverlay(isLoading: false),
|
||||||
children: [
|
if (hasVideo && !isReady)
|
||||||
Icon(
|
Positioned.fill(
|
||||||
LucideIcons.image_plus,
|
child: _LoadingOverlay(isLoading: true),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
class _ResolutionToggle extends StatelessWidget {
|
||||||
const _ResolutionToggle({
|
const _ResolutionToggle({
|
||||||
required this.selected,
|
required this.selected,
|
||||||
@ -401,70 +529,82 @@ class _ResolutionToggle extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: GestureDetector(
|
'Resolution',
|
||||||
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(
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
color: selected == _Resolution.p480
|
fontSize: 14,
|
||||||
? AppColors.surface
|
color: AppColors.textPrimary,
|
||||||
: AppColors.textSecondary,
|
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
_ResolutionOption(
|
||||||
child: GestureDetector(
|
label: '480P',
|
||||||
onTap: () => onChanged(_Resolution.p720),
|
isSelected: selected == _Resolution.p480,
|
||||||
child: Container(
|
onTap: () => onChanged(_Resolution.p480),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ResolutionOption(
|
||||||
|
label: '720P',
|
||||||
|
isSelected: selected == _Resolution.p720,
|
||||||
|
onTap: () => onChanged(_Resolution.p720),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
'720P',
|
label,
|
||||||
style: AppTypography.bodyMedium.copyWith(
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
color: selected == _Resolution.p720
|
fontSize: 13,
|
||||||
? AppColors.surface
|
color: isSelected ? AppColors.surface : AppColors.textSecondary,
|
||||||
: AppColors.textSecondary,
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,7 +85,8 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
|||||||
|
|
||||||
if (_videoUrl != null) {
|
if (_videoUrl != null) {
|
||||||
final tempDir = await getTemporaryDirectory();
|
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!));
|
final response = await http.get(Uri.parse(_videoUrl!));
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Failed to download video');
|
throw Exception('Failed to download video');
|
||||||
@ -95,7 +96,8 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
|||||||
await file.delete();
|
await file.delete();
|
||||||
} else if (_imageUrl != null) {
|
} else if (_imageUrl != null) {
|
||||||
final tempDir = await getTemporaryDirectory();
|
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!));
|
final response = await http.get(Uri.parse(_imageUrl!));
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Failed to download image');
|
throw Exception('Failed to download image');
|
||||||
@ -156,9 +158,7 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
|||||||
_DownloadButton(
|
_DownloadButton(
|
||||||
onDownload: _saving ? null : _saveToAlbum,
|
onDownload: _saving ? null : _saveToAlbum,
|
||||||
saving: _saving,
|
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 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_spacing.dart';
|
|
||||||
|
|
||||||
/// Video card for home grid - 点击播放按钮可在卡片上播放视频
|
/// Video card for home grid - 点击播放按钮可在卡片上播放视频
|
||||||
/// 同时只能一个卡片处于播放状态
|
/// 同时只能一个卡片处于播放状态
|
||||||
@ -35,7 +34,6 @@ class VideoCard extends StatefulWidget {
|
|||||||
|
|
||||||
class _VideoCardState extends State<VideoCard> {
|
class _VideoCardState extends State<VideoCard> {
|
||||||
VideoPlayerController? _controller;
|
VideoPlayerController? _controller;
|
||||||
bool _isLoading = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -64,9 +62,7 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
void _stop() {
|
void _stop() {
|
||||||
_controller?.removeListener(_onVideoUpdate);
|
_controller?.removeListener(_onVideoUpdate);
|
||||||
_controller?.pause();
|
_controller?.pause();
|
||||||
if (mounted) {
|
if (mounted) setState(() {});
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _disposeController() {
|
void _disposeController() {
|
||||||
@ -85,34 +81,30 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
final needSeek = _controller!.value.position >= _controller!.value.duration &&
|
final needSeek = _controller!.value.position >= _controller!.value.duration &&
|
||||||
_controller!.value.duration.inMilliseconds > 0;
|
_controller!.value.duration.inMilliseconds > 0;
|
||||||
if (needSeek) {
|
if (needSeek) {
|
||||||
setState(() => _isLoading = true);
|
|
||||||
await _controller!.seekTo(Duration.zero);
|
await _controller!.seekTo(Duration.zero);
|
||||||
}
|
}
|
||||||
_controller!.addListener(_onVideoUpdate);
|
_controller!.addListener(_onVideoUpdate);
|
||||||
await _controller!.play();
|
await _controller!.play();
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!);
|
final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!);
|
||||||
if (!mounted || !widget.isActive) {
|
if (!mounted || !widget.isActive) return;
|
||||||
setState(() => _isLoading = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_controller = VideoPlayerController.file(file);
|
_controller = VideoPlayerController.file(file);
|
||||||
await _controller!.initialize();
|
await _controller!.initialize();
|
||||||
_controller!.addListener(_onVideoUpdate);
|
_controller!.addListener(_onVideoUpdate);
|
||||||
if (mounted && widget.isActive) {
|
if (mounted && widget.isActive) {
|
||||||
await _controller!.play();
|
await _controller!.play();
|
||||||
setState(() => _isLoading = false);
|
setState(() {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_disposeController();
|
_disposeController();
|
||||||
setState(() => _isLoading = false);
|
setState(() {});
|
||||||
widget.onStopRequested();
|
widget.onStopRequested();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,7 +135,6 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized;
|
final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized;
|
||||||
final showLoading = widget.isActive && _isLoading;
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@ -151,9 +142,8 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
width: constraints.maxWidth,
|
width: constraints.maxWidth,
|
||||||
height: constraints.maxHeight,
|
height: constraints.maxHeight,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surfaceAlt,
|
color: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
border: Border.all(color: AppColors.border, width: 1),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.shadowMedium,
|
color: AppColors.shadowMedium,
|
||||||
@ -169,6 +159,9 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
children: [
|
children: [
|
||||||
if (showVideo)
|
if (showVideo)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _onPlayButtonTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@ -181,9 +174,17 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
child: VideoPlayer(_controller!),
|
child: VideoPlayer(_controller!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
CachedNetworkImage(
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.videoUrl != null &&
|
||||||
|
widget.videoUrl!.isNotEmpty
|
||||||
|
? _onPlayButtonTap
|
||||||
|
: null,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: CachedNetworkImage(
|
||||||
imageUrl: widget.imageUrl,
|
imageUrl: widget.imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, __) => Container(
|
||||||
@ -193,13 +194,16 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
color: AppColors.surfaceAlt,
|
color: AppColors.surfaceAlt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 12,
|
top: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
height: 24,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.md,
|
horizontal: 8,
|
||||||
vertical: AppSpacing.xs,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.overlayDark,
|
color: AppColors.overlayDark,
|
||||||
@ -207,13 +211,14 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
LucideIcons.sparkles,
|
LucideIcons.sparkles,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
widget.credits,
|
widget.credits,
|
||||||
style: const TextStyle(
|
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)
|
if (_isPlaying)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
@ -288,20 +256,26 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 12,
|
bottom: 16,
|
||||||
left: 12,
|
left: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
|
child: Center(
|
||||||
|
child: IntrinsicWidth(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onGenerateSimilar,
|
onTap: widget.onGenerateSimilar,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 44,
|
height: 24,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primary,
|
color: AppColors.primaryButtonFill,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primary,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
color: AppColors.primaryButtonShadow,
|
||||||
blurRadius: 6,
|
blurRadius: 6,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@ -320,6 +294,8 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
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 =>
|
String get creditsDisplay =>
|
||||||
credits > 0 ? '${credits.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},')} Credits' : title;
|
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"
|
/// 显示价格文案,如 "¥6" 或 "¥25 Save ¥5"
|
||||||
String get priceDisplay {
|
String get priceDisplay {
|
||||||
if (actualAmount.isEmpty) return '';
|
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 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
|
|
||||||
import '../../core/adjust/adjust_events.dart';
|
import '../../core/adjust/adjust_events.dart';
|
||||||
|
import '../../core/api/api_config.dart';
|
||||||
import '../../core/api/services/payment_api.dart';
|
import '../../core/api/services/payment_api.dart';
|
||||||
import '../../core/auth/auth_service.dart';
|
import '../../core/auth/auth_service.dart';
|
||||||
|
import '../../core/log/app_logger.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
import '../../core/theme/app_spacing.dart';
|
import '../../core/theme/app_spacing.dart';
|
||||||
import '../../core/theme/app_typography.dart';
|
import '../../core/theme/app_typography.dart';
|
||||||
import '../../shared/widgets/top_nav_bar.dart';
|
import '../../shared/widgets/top_nav_bar.dart';
|
||||||
|
|
||||||
|
import 'google_play_purchase_service.dart';
|
||||||
import 'models/activity_item.dart';
|
import 'models/activity_item.dart';
|
||||||
|
import 'models/payment_method_item.dart';
|
||||||
|
import 'payment_webview_screen.dart';
|
||||||
|
|
||||||
/// Recharge screen - matches Pencil tPjdN
|
/// Recharge screen - matches Pencil tPjdN
|
||||||
/// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities
|
/// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities
|
||||||
class RechargeScreen extends StatefulWidget {
|
class RechargeScreen extends StatefulWidget {
|
||||||
|
static final _log = AppLogger('Recharge');
|
||||||
const RechargeScreen({super.key});
|
const RechargeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RechargeScreen> createState() => _RechargeScreenState();
|
State<RechargeScreen> createState() => _RechargeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RechargeScreenState extends State<RechargeScreen> {
|
class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObserver {
|
||||||
List<ActivityItem> _activities = [];
|
List<ActivityItem> _activities = [];
|
||||||
bool _loadingTiers = true;
|
bool _loadingTiers = true;
|
||||||
String? _tierError;
|
String? _tierError;
|
||||||
|
/// 当前正在支付的商品 code,仅该 item 的 Buy 显示 loading
|
||||||
|
String? _loadingProductId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_fetchActivities();
|
_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 {
|
Future<void> _fetchActivities() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loadingTiers = true;
|
_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);
|
final price = AdjustEvents.parsePrice(item.actualAmount);
|
||||||
if (price != null) {
|
if (price != null) {
|
||||||
final tierToken = AdjustEvents.tierTokenFromPrice(price);
|
final tierToken = AdjustEvents.tierTokenFromPrice(price);
|
||||||
@ -94,7 +119,245 @@ class _RechargeScreenState extends State<RechargeScreen> {
|
|||||||
} else if (titleLower.contains('weekly vip')) {
|
} else if (titleLower.contains('weekly vip')) {
|
||||||
AdjustEvents.trackWeeklyVip();
|
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
|
@override
|
||||||
@ -192,6 +455,7 @@ class _RechargeScreenState extends State<RechargeScreen> {
|
|||||||
: isPopular
|
: isPopular
|
||||||
? _TierBadge.popular
|
? _TierBadge.popular
|
||||||
: _TierBadge.none,
|
: _TierBadge.none,
|
||||||
|
loading: _loadingProductId == item.code,
|
||||||
onBuy: () => _onBuy(item),
|
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 }
|
enum _TierBadge { none, recommended, popular }
|
||||||
|
|
||||||
class _TierCardFromActivity extends StatelessWidget {
|
class _TierCardFromActivity extends StatelessWidget {
|
||||||
const _TierCardFromActivity({
|
const _TierCardFromActivity({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.badge,
|
required this.badge,
|
||||||
|
required this.loading,
|
||||||
required this.onBuy,
|
required this.onBuy,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ActivityItem item;
|
final ActivityItem item;
|
||||||
final _TierBadge badge;
|
final _TierBadge badge;
|
||||||
|
final bool loading;
|
||||||
final VoidCallback onBuy;
|
final VoidCallback onBuy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -239,7 +544,8 @@ class _TierCardFromActivity extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Expanded(
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@ -249,15 +555,44 @@ class _TierCardFromActivity extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm),
|
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
item.priceDisplay,
|
item.priceDisplayWithDollar,
|
||||||
style: AppTypography.bodyRegular.copyWith(
|
style: AppTypography.bodyRegular.copyWith(
|
||||||
color: AppColors.textSecondary,
|
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,24 +693,34 @@ class _CreditsSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BuyButton extends StatelessWidget {
|
class _BuyButton extends StatelessWidget {
|
||||||
const _BuyButton({required this.onTap});
|
const _BuyButton({required this.onTap, this.loading = false});
|
||||||
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final bool loading;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: loading ? null : onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.xl,
|
horizontal: AppSpacing.xl,
|
||||||
vertical: AppSpacing.md,
|
vertical: AppSpacing.md,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primary,
|
color: loading ? AppColors.primary.withValues(alpha: 0.7) : AppColors.primary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: loading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.surface),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
'Buy',
|
'Buy',
|
||||||
style: AppTypography.bodyRegular.copyWith(
|
style: AppTypography.bodyRegular.copyWith(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
|
|||||||
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
|
name: pets_hero_ai
|
||||||
description: PetsHero AI Application.
|
description: PetsHero AI Application.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.1+2
|
version: 1.0.3+4
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@ -26,6 +26,10 @@ dependencies:
|
|||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.2
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
flutter_cache_manager: ^3.3.1
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user