新增:支付对接

This commit is contained in:
ivan 2026-03-12 14:30:19 +08:00
parent d9da82bbdc
commit b464400b6b
31 changed files with 2266 additions and 581 deletions

View File

@ -28,6 +28,12 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
configurations.all {
resolutionStrategy {
force 'com.android.billingclient:billing:7.1.1'
}
}
android {
namespace "com.petsheroai.app"
compileSdk 36
@ -79,6 +85,8 @@ dependencies {
implementation 'com.google.android.gms:play-services-ads-identifier:18.1.0'
implementation 'com.android.installreferrer:installreferrer:2.2'
implementation 'androidx.appcompat:appcompat:1.7.0'
// Play Billing Library 6.0.1+ (use 7.1.1 for in_app_purchase plugin compatibility)
implementation 'com.android.billingclient:billing:7.1.1'
}
flutter {

View File

@ -2691,13 +2691,40 @@
"fontWeight": "600"
},
{
"type": "text",
"id": "Xvh9R",
"fill": "#71717A",
"content": "$5.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
"type": "frame",
"id": "iVYPz",
"name": "priceLine",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "BO2Is",
"fill": "#71717A",
"content": "$5.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "sgYV2",
"fill": "#A1A1AA",
"content": "$6.99",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "text",
"id": "1bb2Y",
"fill": "#8B5CF6",
"content": "+10 pts",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600"
}
]
}
]
},
@ -2788,13 +2815,40 @@
"fontWeight": "600"
},
{
"type": "text",
"id": "Rg4EJ",
"fill": "#71717A",
"content": "$9.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
"type": "frame",
"id": "IAiZI",
"name": "priceLine",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "oFR6Q",
"fill": "#71717A",
"content": "$9.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "bZrc4",
"fill": "#A1A1AA",
"content": "$12.99",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "text",
"id": "2oQd4",
"fill": "#8B5CF6",
"content": "+25 pts",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600"
}
]
}
]
},
@ -2933,13 +2987,40 @@
"fontWeight": "600"
},
{
"type": "text",
"id": "BxrWP",
"fill": "#71717A",
"content": "$19.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
"type": "frame",
"id": "gSnzm",
"name": "priceLine",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "g3vvN",
"fill": "#71717A",
"content": "$19.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "9DcxP",
"fill": "#A1A1AA",
"content": "$24.99",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "text",
"id": "SCF0u",
"fill": "#8B5CF6",
"content": "+80 pts",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600"
}
]
}
]
},
@ -3061,13 +3142,40 @@
"fontWeight": "600"
},
{
"type": "text",
"id": "lydr2",
"fill": "#71717A",
"content": "$49.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
"type": "frame",
"id": "QcmBX",
"name": "priceLine",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "QbQa2",
"fill": "#71717A",
"content": "$49.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "bvZ8D",
"fill": "#A1A1AA",
"content": "$59.99",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "text",
"id": "xKMOi",
"fill": "#8B5CF6",
"content": "+200 pts",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600"
}
]
}
]
},
@ -3141,13 +3249,40 @@
"fontWeight": "600"
},
{
"type": "text",
"id": "nG15k",
"fill": "#71717A",
"content": "$99.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
"type": "frame",
"id": "xMHYZ",
"name": "priceLine",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "wLYzH",
"fill": "#71717A",
"content": "$99.99",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "ch71f",
"fill": "#A1A1AA",
"content": "$119.99",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "text",
"id": "H6lDP",
"fill": "#8B5CF6",
"content": "+500 pts",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600"
}
]
}
]
},
@ -3321,6 +3456,7 @@
"type": "frame",
"id": "BNNL5",
"name": "contentArea",
"context": "Content area: creditsCard + videoPreviewArea scrollable; resolutionRow + generateBtn fixed at bottom.",
"width": "fill_container",
"height": "fill_container",
"layout": "vertical",
@ -3400,6 +3536,7 @@
"type": "frame",
"id": "vA8QJ",
"name": "videoPreviewArea",
"context": "Video preview: video URL from card. Auto-load and play. Width: fill container; height: adapt by video aspect ratio. Fit: contain (no crop). Loading animation until ready.",
"width": "fill_container",
"height": 280,
"fill": {
@ -3413,80 +3550,13 @@
"thickness": 1,
"fill": "#E4E4E7"
},
"layout": "none",
"children": [
{
"type": "icon_font",
"id": "1VkSk",
"x": 151,
"y": 93,
"enabled": false,
"width": 48,
"height": 48,
"iconFontName": "image-plus",
"iconFontFamily": "lucide",
"fill": "#A1A1AA"
},
{
"type": "text",
"id": "flPeY",
"x": 35,
"y": 105.5,
"enabled": false,
"fill": "#FFFFFF",
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "#00000080",
"offset": {
"x": 0,
"y": 1
},
"blur": 4
},
"textGrowth": "fixed-width",
"width": 280,
"content": "Video Preview",
"textAlign": "center",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "frame",
"id": "5y1EY",
"x": 12,
"y": 208,
"name": "iconWrap",
"width": 60,
"height": 60,
"fill": "#FFFFFF80",
"cornerRadius": 30,
"stroke": {
"thickness": 2,
"fill": "#18181B"
},
"layout": "vertical",
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "yMNqH",
"width": 28,
"height": 28,
"iconFontName": "image-plus",
"iconFontFamily": "lucide",
"fill": "#18181B"
}
]
}
]
"layout": "none"
},
{
"type": "frame",
"id": "oYwsm",
"name": "resolutionRow",
"context": "Resolution options: 480p/720p. Fixed at screen bottom. When switching, update Generate Video credits (RGKvY).",
"width": "fill_container",
"height": 44,
"gap": 12,
@ -3583,6 +3653,7 @@
"type": "frame",
"id": "yunXY",
"name": "generateBtn",
"context": "Generate Video button. Fixed at screen bottom. Click flow: image picker -> API. See docs/generate_video.md.",
"width": "fill_container",
"height": 56,
"fill": "#8B5CF6",
@ -3614,6 +3685,7 @@
"type": "frame",
"id": "RGKvY",
"name": "creditsCost",
"context": "Credits cost for current resolution. Updates when resolution (oYwsm) changes.",
"fill": "#FFFFFF30",
"cornerRadius": 8,
"gap": 4,
@ -3803,6 +3875,7 @@
"type": "frame",
"id": "rSN3T",
"name": "videoDisplay",
"context": "Video area: display uploaded image with crop (fill/cover). Matches progress screen.",
"clip": true,
"width": "fill_container",
"height": 360,
@ -3842,6 +3915,7 @@
"type": "text",
"id": "Eghqc",
"name": "progressLabel",
"context": "Progress label: 1=队列中 2=处理中 3=完成 4=超时 5=错误 6=中止. Updated from /v1/image/progress every 1s.",
"fill": "#18181B",
"content": "Generating...",
"fontFamily": "Inter",

View File

@ -1,146 +1,173 @@
# petsHeroAI 接口调用流程说明
## 一、整体流程
本文档说明客户端请求的加解密与代理流程,以及**按业务场景**的接口调用顺序。具体字段以 **petsHeroAI_client_guide.md** 为准。
---
## 一、请求流程(所有接口统一)
```
┌─────────────────────────────────────────────────────────────────┐
│ 客户端请求流程 │
└─────────────────────────────────────────────────────────────────┘
业务参数(原始字段)
业务参数V2 字段名)
┌─────────────┐
字段名映射 │ body / params / headers 中的原始字段 → V2 字段
│ V2 包装 │ body → arsenal/vault/tome/codex/grimoire/sanctum
└─────────────┘
┌─────────────┐
V2 包装 │ 将 body 包装为 arsenal/vault/tome/codex/grimoire/sanctum 结构
JSON 序列化 │ headers / queryParams / body 分别序列化
└─────────────┘
┌─────────────┐
JSON 序列化 │
AES+Base64 │ 各字段 AES-128-ECB、PKCS5 填充后 Base64
└─────────────┘
┌─────────────┐
│ AES+Base64 │ AES-128-ECB, PKCS5Padding 加密
└─────────────┘
┌─────────────┐
│ 构造代理请求 │ 填入 hero_class, pet_species, power_level 等参数
│ 构造代理请求 │ hero_class, pet_species(path), power_level(method),
│ │ quest_rank(headers), battle_score(queryParams),
│ │ loyalty_index(body) + 噪音字段
└─────────────┘
POST {baseUrl}/quester/defender/summoner
```
- **path**(如 `/v1/user/fast_login`)加密后放在 **pet_species**
- **method**GET/POST加密后放在 **power_level**
- 登录后 **knight**userToken由 ProxyClient 自动写入请求头并参与加密。
---
## 二、响应处理流程
```
POST 代理入口响应
POST 代理入口响应(密文 body
┌─────────────┐
│ 提取密文 │ 从响应中获取加密字段
└─────────────┘
Base64 解码 → AES 解密 → JSON 解析
┌─────────────┐
│ Base64 解码 │
└─────────────┘
取 vault.tome.codex.grimoire.sanctum
┌─────────────┐
│ AES 解密 │ AES-128-ECB 解密
└─────────────┘
helm → code, rampart → msg, sidekick → data
┌─────────────┐
│ JSON 解析 │
└─────────────┘
┌─────────────┐
│ 字段逆映射 │ V2 字段 → 原始字段 (便于业务使用)
└─────────────┘
业务数据 (code/msg/data)
ApiResponse(code, msg, data)
```
## 三、接口分类与调用顺序
---
### 3.1 登录与用户
## 三、按业务场景的接口调用顺序
### 3.1 应用启动与登录AuthService.init
| 顺序 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 1 | `/v1/user/fast_login` | POST | 设备快速登录,获取 userToken |
| 2 | `/v1/user/common_info` | GET | 获取用户通用信息(含积分、头像等) |
| 3 | `/v1/user/account` | GET | 获取用户账户信息 |
| 4 | `/v1/user/referrer` | POST | 归因上报 |
| 5 | `/v1/user/delete` | GET | 注销账户 |
| 1 | `/v1/user/fast_login` | POST | 设备快速登录body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 |
| 2 | (保存 token、用户信息到 UserState首次登录打点 register | — | — |
| 3 | `/v1/user/referrer` | POST | 归因上报query: sentinel, asset(uid), portalbody: digest, origin。 |
| 4 | `/v1/user/common_info` | GET | 获取用户通用信息query: sentinel, asset(uid)。解析 data 写入 UserState并解析 surge 中 enable_third_party_payment 等。 |
### 3.2 支付
**调用处**`lib/core/auth/auth_service.dart`init登录成功后顺序执行
---
### 3.2 充值 / 支付
| 场景 | 顺序 | 接口 | 方法 | 说明 |
|------|------|------|------|------|
| 进入充值页 | 1 | `/v1/payment/getGooglePayActivities``/v1/payment/getApplePayActivities` | GET | 按平台获取商品列表data.summon 为列表,单项含 helm(商品ID)、warrior(activityId)、guardian、curriculum、forge 等。 |
| 用户点 Buy第三方支付开 | 2 | `/v1/payment/get-payment-methods` | POST | body: warrior(activityId), vambrace(国家可选)。返回 data.renew 支付方式列表。 |
| 用户选支付方式后 | 3 | `/v1/payment/createPayment` | POST | query: sentinel, asset(userId)body: sentinel, asset, warrior, resource, ceremony。返回 federation(订单ID)、convert(payUrl)。 |
| 若选中 Google Pay | 4 | 调起 Google Play 内购,成功后 | — | 使用 serverVerificationData 作为 merchant。 |
| | 5 | `/v1/payment/googlepay` | POST | body: merchant(凭据), federation(订单ID), asset(userId)。 |
| 若选其他支付方式 | — | 打开 createPayment 返回的 convert(payUrl) | — | 在外部浏览器完成支付。 |
| 用户点 Buy第三方支付关 | — | 不调 2、3直接调起 Google Play 内购(商品 ID = helm | — | 无 createPayment / googlepay。 |
**调用处**`lib/features/recharge/recharge_screen.dart``PaymentApi``GooglePlayPurchaseService`。详见 **docs/payment_flow.md**
---
### 3.3 生成视频(图生视频)
| 顺序 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 1 | `/v1/payment/getGooglePayActivities` | GET | 获取 Google 商品列表 |
| 2 | `/v1/payment/getApplePayActivities` | GET | 获取 Apple 商品列表 |
| 3 | `/v1/payment/createPayment` | POST | 创建支付订单 |
| 4 | `/v1/payment/googlepay` | POST | Google 支付结果回调 |
| 5 | `/v1/payment/applepay` | POST | Apple 支付结果回调 |
| 6 | `/v1/payment/getPaymentDetailList` | GET | 获取支付订单列表 |
| 1 | `/v1/image/upload-presigned-url` | POST | 获取上传 URLbody: gateway(fileName1), action(fileName2), pauldron(contentType), stronghold(expectedSize)。返回的 URL 用于**直接 PUT 上传图片**(不经过代理)。 |
| 2 | (客户端 PUT 上传图片到 presigned URL | PUT | 不经过代理,请求头按接口要求设置。 |
| 3 | `/v1/image/create-task` | POST | query: assetbody: commission(srcImg1Url 等)、guild(分辨率) 等。返回任务信息。 |
| 4 | `/v1/image/progress` | GET | query: sentinel, tree(taskId), asset。进度页**轮询**(如每 1s直到 state 为 3(完成)/4/5/6。 |
| 5 | (生成完成后)`/v1/user/account` | GET | 刷新用户积分。 |
### 3.3 图片生成
**调用处**`lib/features/generate_video/generate_video_screen.dart`1→2→3`generate_progress_screen.dart`4、5
| 顺序 | 接口 | 方法 | 说明 |
---
### 3.4 首页与图库
| 场景 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 1 | `/v1/image/prompt/recomends` | GET | 获取推荐提示词 |
| 2 | `/v1/image/txt2img_tags` | GET | 获取文生图标签 |
| 3 | `/v1/image/txt2img_prompts` | POST | 获取文生图提示词模板 |
| 4 | `/v1/image/txt2img_create` | POST | 创建文生图任务 |
| 5 | `/v1/image/progress` | GET | 查询图片生成进度 |
| 首页分类/列表 | `/v1/image/img2video/categories` | GET | 获取图转视频分类。 |
| | `/v1/image/img2video/tasks` | GET | 获取图转视频任务列表;可选 query: insignia(categoryId)。 |
| 图库「我的」 | `/v1/image/my-tasks` | GET | 获取我的任务列表query: sentinel, trophy/heatmap/platoon 等。 |
### 3.4 图转视频
**调用处**`lib/features/home/home_screen.dart``lib/features/gallery/gallery_screen.dart`
| 顺序 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 1 | `/v1/image/img2Video_pose_template` | GET | 获取图转视频姿态模板 |
| 2 | `/v1/image/img2video_pose_task` | POST | 创建图转视频姿态任务 |
| 3 | `/v1/image/progress` | GET | 查询任务进度 |
---
### 3.5 换衣 / 换脸
| 顺序 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 1 | `/v1/image/clothes_template` | GET | 获取换衣模板 |
| 2 | `/v1/image/clothes_swap_ex` | POST | 创建换衣任务 |
| 3 | `/v1/image/faceswap_task` | POST | 创建换脸任务 |
| 4 | `/v1/image/video_facewap_task` | POST | 创建视频换脸任务 |
### 3.6 其他
### 3.5 其他
| 接口 | 方法 | 说明 |
|------|------|------|
| `/v1/image/category-list` | GET | 获取分类列表 |
| `/v1/log/appevent` | POST | App 事件打点上报 |
| `/v1/image/getCreditsPageInfo` | GET | 获取积分页面信息 |
| `/v1/log/uploadUrl` | POST | 获取预签名上传 URL |
| `/v1/user/account` | GET | 获取账户信息(积分等);个人页、生成视频完成后刷新积分时调用。 |
| `/v1/image/getCreditsPageInfo` | GET | 获取积分页信息。 |
| `/v1/image/prompt/recomends` | GET | 获取推荐提示词。 |
| `/v1/image/txt2img_create` | POST | 创建文生图任务。 |
| `/v1/image/img2Video_pose_template` | GET | 获取图转视频姿态模板。 |
| `/v1/image/img2video_pose_task` | POST | 创建图转视频姿态任务。 |
## 四、通用请求头
---
登录后所有请求需携带:
## 四、接口路径与客户端封装对照
| 接口路径 | 方法 | 客户端封装lib/core/api/services/ |
|----------|------|--------------------------------------|
| /v1/user/fast_login | POST | UserApi.fastLogin |
| /v1/user/referrer | POST | UserApi.referrer |
| /v1/user/common_info | GET | UserApi.getCommonInfo |
| /v1/user/account | GET | UserApi.getAccount |
| /v1/payment/getGooglePayActivities | GET | PaymentApi.getGooglePayActivities |
| /v1/payment/getApplePayActivities | GET | PaymentApi.getApplePayActivities |
| /v1/payment/get-payment-methods | POST | PaymentApi.getPaymentMethods |
| /v1/payment/createPayment | POST | PaymentApi.createPayment |
| /v1/payment/googlepay | POST | PaymentApi.googlepay |
| /v1/image/img2video/categories | GET | ImageApi.getCategoryList |
| /v1/image/img2video/tasks | GET | ImageApi.getImg2VideoTasks |
| /v1/image/my-tasks | GET | ImageApi.getMyTasks |
| /v1/image/upload-presigned-url | POST | ImageApi.getUploadPresignedUrl |
| /v1/image/create-task | POST | ImageApi.createTask |
| /v1/image/progress | GET | ImageApi.getProgress |
| /v1/image/getCreditsPageInfo | GET | ImageApi.getCreditsPageInfo |
| /v1/image/prompt/recomends | GET | ImageApi.getPromptRecommends |
| /v1/image/txt2img_create | POST | ImageApi.createTxt2Img |
| /v1/image/img2Video_pose_template | GET | ImageApi.getImg2VideoPoseTemplates |
| /v1/image/img2video_pose_task | POST | ImageApi.createImg2VideoPose |
---
## 五、通用请求头(登录后)
| 原始字段 | V2 字段 | 说明 |
|----------|---------|------|
| pkg | portal | 应用包名,必填,如 `com.petsheroai.app` |
| User_token | knight | 用户登录 token |
| pkg | portal | 应用包名,必填ProxyClient 自动带。 |
| User_token | knight | 用户 token登录成功后 ProxyClient 自动带。 |
## 五、通用响应结构
---
## 六、通用响应结构(解密后业务层)
```json
{
@ -150,7 +177,9 @@
}
```
## 六、错误码
---
## 七、错误码
| code | 说明 |
|------|------|

View File

@ -1,6 +1,6 @@
# UI 开发流程
当点击中间upload an image as the base for generation 区域的时候,调用手机相册功能选择一张图片,一次只允许一张。用户选完图片后图片显示在 upload an image as the base for generation区域并隐藏提示信息
当点击Generate Video的时候如果用户没有选择图片择提示用户选择图片如果已经选择了图片则开始按下面的步骤调用接口
# UI逻辑
1.点击Generate Video的时候先进入选择图片的流程可以是拍照或相册选择
2.选择完图片后进入接口调用步骤
# 接口调用
## 第一步
通过/v1/image/upload-presigned-url接口获取文件上传地址通常是uploadUrl1或者uploadUrl2,以及对应的filePath1和filePath2用于生成视频接口的入参

View 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
View 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
View 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 Payresource/ceremony == "GooglePay"
│ │ ├─ 调起 Google Play 内购productId = item.code
│ │ ├─ 拿到 serverVerificationData
│ │ └─ POST /v1/payment/googlepay(merchant, federation, asset)
│ │
│ └─ 否则(其他支付方式)
│ └─ 打开 createPayment 返回的 payUrlconvert在外部浏览器完成支付
└─ 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或 getApplePayActivitiesiOS列表为 data.summonactivitys
- **单条商品字段V2 映射)**
| 字段(映射后) | 说明 |
|----------------|------|
| helm | 产品代码,即 **Google Pay 商品 ID**(与 Play 后台「产品 ID」必须一致 |
| warrior | 活动 IDactivityIdgetPaymentMethods / 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**bodywarrior=activityIdvambrace=国家(可选)。 |
| 3 | 弹窗 **支付方式列表**renew用户选择一项 → 得到 resourcepaymentMethod、ceremonysubPaymentMethod。 |
| 4 | **POST /v1/payment/createPayment**bodysentinel, asset(userId), warrior(activityId), resource, ceremony得到 federation订单 ID、convertpayUrl等。 |
| 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**PaymentMethodItemresource, 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 且订单 IDfederation存在调用 **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**
Querysentinel, portal响应 data.summon / data.cleanse单项含 helm, warrior, guardian, curriculum, forge 等。
- **POST /v1/payment/get-payment-methods**
Bodywarrior(activityId), vambrace(可选)。响应 data.renew每项 resource, ceremony, brigade, greylist, deny 等。
- **POST /v1/payment/createPayment**
Querysentinel, asset(userId)。Bodysentinel, asset, warrior, resource, ceremony(可选)。
响应 datafederation(订单ID), convert(payUrl), destroy(状态), handshake, transplant 等。
- **POST /v1/payment/googlepay**
Querysentinel, asset(userId)。Bodymerchant(购买凭据 serverVerificationData), federation(订单ID), asset, sample(签名可选)。
响应 datafederation, line(状态), awaken(是否加积分) 等。
- 请求/响应 V2 加解密与字段映射以 **petsHeroAI_client_guide.md** 为准。
---
## 7. 代码与文档对应
| 功能 | 位置 |
|------|------|
| 支付分支与 Buy 入口 | recharge_screen.dart_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay |
| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPaymentPaymentApi.getPaymentMethods → _PaymentMethodDialog |
| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrlcreatePayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) |
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase |
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dartlaunchPurchaseAndReturnData / launchPurchasePaymentApi.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
View 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 | 设备 IDdeviceId |
### 响应(解密后 data / sidekick
| 字段(映射后) | 说明 |
|----------------|------|
| reevaluate | **userToken**,后续请求 Header 的 `knight` |
| asset | **userId**,用户 ID |
| reveal | **credit**,积分余额 |
| realm | 头像 URL |
| terminal | 用户名 |
| navigate | 国家代码 |
| surge | 客户端界面配置extConfigJSON 字符串,需按需解析 |
| equip | 是否首次注册firstRegister |
| 其他 | 见接口文档 sidekick 结构 |
### 客户端必做
- 保存并设置 **userToken**(如写入请求头 `knight`)。
- 保存并更新 **积分**reveal、**用户信息**userId、头像、昵称、国家码等
- **首次登录**:将该次登录日期记为**注册日期**(用于归因/统计);可本地标记“已注册”避免重复上报。
---
## 3. 归因上报 `POST /v1/user/referrer`
- **鉴权**需要Header 带 `portal``knight`)。
- **说明**:登录成功后调用,上报归因数据;**digest 从 Adjust 取**。
### 请求
| 位置 | 字段(映射后) | 说明 |
|--------|----------------|------|
| Query | sentinel | 应用标识(必填) |
| Query | asset | 用户 IDuserId |
| Query | accolade | 类型(可选) |
| Query | portal | 应用包名(必填) |
| Body | digest | **归因信息,从 Adjust 获取** |
| Body | origin | 设备 IDdeviceId |
### 响应
- code=0 表示成功;可解析 msg / data 做提示或日志。
- **调用成功后建议增加日志输出**,便于排查归因是否上报成功。
---
## 4. 获取用户通用信息 `GET /v1/user/common_info`
- **鉴权**需要Header 带 `portal``knight`)。
- **说明**:登录成功后调用,拉取通用配置并**保存到全局/本地**。
### 请求
| 位置 | 字段(映射后) | 说明 |
|--------|----------------|------|
| Query | sentinel | 应用标识(必填) |
| Query | asset | 用户 IDuserId |
| 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_loginbody: digest, resolution, originquery: portal, crest, accolade
→ 若成功:
1. 保存 userTokenknight、userIdasset、积分reveal、头像/昵称/国家码等
2. 若首次登录:记录注册日期并标记已注册
3. POST /v1/user/referrerbody: digest 从 Adjust 取, originquery: sentinel, asset, portal
→ 成功后打日志
4. GET /v1/user/common_infoquery: sentinel, asset
→ 将结果保存到全局
→ 对 data.surge 做 JSON decode保存 enable_third_party_payment 等
→ 之后所有请求 Header 带 knight = userToken
```
接口的 V2 请求体/响应体、加解密与字段映射以 **petsHeroAI_client_guide.md** 为准。

View File

@ -4,8 +4,8 @@ FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib/main.dart
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1
FLUTTER_BUILD_NAME=1.0.2
FLUTTER_BUILD_NUMBER=3
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
DART_OBFUSCATION=false

View File

@ -5,8 +5,8 @@ export "FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "FLUTTER_BUILD_NAME=1.0.2"
export "FLUTTER_BUILD_NUMBER=3"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"

View File

@ -30,6 +30,12 @@
@import image_picker_ios;
#endif
#if __has_include(<in_app_purchase_storekit/InAppPurchasePlugin.h>)
#import <in_app_purchase_storekit/InAppPurchasePlugin.h>
#else
@import in_app_purchase_storekit;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else
@ -42,6 +48,12 @@
@import sqflite_darwin;
#endif
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
#import <url_launcher_ios/URLLauncherPlugin.h>
#else
@import url_launcher_ios;
#endif
#if __has_include(<video_player_avfoundation/FVPVideoPlayerPlugin.h>)
#import <video_player_avfoundation/FVPVideoPlayerPlugin.h>
#else
@ -54,6 +66,12 @@
@import video_thumbnail;
#endif
#if __has_include(<webview_flutter_wkwebview/WebViewFlutterPlugin.h>)
#import <webview_flutter_wkwebview/WebViewFlutterPlugin.h>
#else
@import webview_flutter_wkwebview;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
@ -61,10 +79,13 @@
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
[VideoThumbnailPlugin registerWithRegistrar:[registry registrarForPlugin:@"VideoThumbnailPlugin"]];
[WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]];
}
@end

View File

@ -12,6 +12,7 @@ import 'features/home/models/task_item.dart';
import 'features/profile/profile_screen.dart';
import 'features/recharge/recharge_screen.dart';
import 'shared/widgets/bottom_nav_bar.dart';
import 'shared/tab_selector_scope.dart';
/// Root app widget with navigation
class App extends StatefulWidget {
@ -27,33 +28,40 @@ class _AppState extends State<App> {
@override
Widget build(BuildContext context) {
return UserCreditsScope(
child: MaterialApp(
title: 'AI Video App',
theme: AppTheme.light,
debugShowCheckedModeBanner: false,
initialRoute: '/',
builder: (context, child) {
return SafeArea(
top: true,
left: false,
right: false,
bottom: false,
child: child ?? const SizedBox.shrink(),
);
},
routes: {
'/': (_) => _MainScaffold(
currentTab: _currentTab,
onTabSelected: (tab) => setState(() => _currentTab = tab),
),
child: TabSelectorScope(
selectTab: (tab) => setState(() => _currentTab = tab),
child: MaterialApp(
title: 'AI Video App',
theme: AppTheme.light,
debugShowCheckedModeBanner: false,
initialRoute: '/',
builder: (context, child) {
return SafeArea(
top: true,
left: false,
right: false,
bottom: false,
child: child ?? const SizedBox.shrink(),
);
},
routes: {
'/': (_) => _MainScaffold(
currentTab: _currentTab,
onTabSelected: (tab) => setState(() => _currentTab = tab),
),
'/recharge': (_) => const RechargeScreen(),
'/generate': (ctx) {
final task = ModalRoute.of(ctx)?.settings.arguments as TaskItem?;
return GenerateVideoScreen(task: task);
},
'/progress': (ctx) {
final taskId = ModalRoute.of(ctx)?.settings.arguments;
return GenerateProgressScreen(taskId: taskId);
final args = ModalRoute.of(ctx)?.settings.arguments;
final taskId = args is Map ? args['taskId'] : args;
final imagePath = args is Map ? args['imagePath'] as String? : null;
return GenerateProgressScreen(
taskId: taskId,
imagePath: imagePath,
);
},
'/result': (ctx) {
final mediaItem =
@ -61,6 +69,7 @@ class _AppState extends State<App> {
return GenerationResultScreen(mediaItem: mediaItem);
},
},
),
),
);
}

View File

@ -15,7 +15,8 @@ abstract final class ApiConfig {
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
///
static const String prodBaseUrl = 'https://ai.petsheroai.xyz';
static const String prodBaseUrl =
'https://pre-ai.petsheroai.xyz'; //https://ai.petsheroai.xyz
///
static const String proxyPath = '/quester/defender/summoner';

View File

@ -3,13 +3,165 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../log/app_logger.dart';
import 'api_config.dart';
import 'api_crypto.dart';
const _logTag = '[ProxyClient]';
final _proxyLog = AppLogger('ProxyClient');
/// JSON
const int _maxLogChunk = 1000;
/// [str] [start] [open] ] } -1
int _findMatchingBracket(String str, int start, String open) {
final close = open == '{' ? '}' : ']';
int depth = 1;
int i = start + 1;
while (i < str.length) {
final c = str[i];
if (c == '"') {
i = _skipJsonString(str, i);
if (i < 0) return -1;
continue;
}
if (c == open) {
depth++;
} else if (c == close) {
depth--;
if (depth == 0) return i;
}
i++;
}
return -1;
}
/// [str] [start] " 开始,跳过整个 JSON 字符串(处理 \"),返回结束 " -1
int _skipJsonString(String str, int start) {
if (start >= str.length || str[start] != '"') return -1;
int i = start + 1;
while (i < str.length) {
final c = str[i++];
if (c == '\\') {
if (i < str.length) i++;
continue;
}
if (c == '"') return i;
}
return -1;
}
/// [_maxLogChunk] JSON
void _logLong(String text) {
if (text.isEmpty) return;
final lines = text.split('\n');
if (lines.length == 1 && text.length <= _maxLogChunk) {
_proxyLog.d(text);
return;
}
final buffer = StringBuffer();
int chunkIndex = 0;
for (final line in lines) {
final lineWithNewline = buffer.isEmpty ? line : '\n$line';
if (buffer.length + lineWithNewline.length > _maxLogChunk && buffer.isNotEmpty) {
chunkIndex++;
_proxyLog.d('(part $chunkIndex)\n$buffer');
buffer.clear();
buffer.write(line);
} else {
if (buffer.isNotEmpty) buffer.write('\n');
buffer.write(line);
}
}
if (buffer.isNotEmpty) {
chunkIndex++;
_proxyLog.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString());
}
}
/// { [ JSON } ] 1000
void logWithEmbeddedJson(Object? msg) {
if (!kDebugMode) return;
if (msg is! String) {
_proxyLog.d(msg);
return;
}
final String str = msg.trim();
final sb = StringBuffer();
void write(String line) {
sb.writeln(line);
}
void printJson(dynamic value, [int indent = 0]) {
final pad = ' ' * indent;
final padInner = ' ' * (indent + 1);
if (value is Map<String, dynamic>) {
write('$pad{');
value.forEach((k, v) {
if (v is Map || v is List) {
write('$padInner"$k":');
printJson(v, indent + 1);
} else {
write('$padInner"$k": ${json.encode(v)}');
}
});
write('$pad}');
} else if (value is List) {
write('$pad[');
for (final item in value) {
printJson(item, indent + 1);
}
write('$pad]');
} else {
write('$pad${json.encode(value)}');
}
}
int i = 0;
int lastEnd = 0;
while (i < str.length) {
final c = str[i];
if (c == '"') {
final next = _skipJsonString(str, i);
if (next > 0) {
i = next;
continue;
}
i++;
continue;
}
if (c == '{' || c == '[') {
final end = _findMatchingBracket(str, i, c);
if (end >= 0) {
final prefix = str.substring(lastEnd, i).trim();
if (prefix.isNotEmpty) write(prefix);
final jsonStr = str.substring(i, end + 1);
try {
final parsed = json.decode(jsonStr);
printJson(parsed, 0);
} catch (e) {
write(jsonStr);
}
lastEnd = end + 1;
i = end + 1;
continue;
}
}
i++;
}
final trailing = str.substring(lastEnd).trim();
if (trailing.isNotEmpty) write(trailing);
final out = sb.toString().trim();
if (out.isNotEmpty) _logLong(out);
}
void _log(String msg) {
if (kDebugMode) debugPrint('$_logTag $msg');
logWithEmbeddedJson(msg);
}
///

View File

@ -44,12 +44,28 @@ abstract final class PaymentApi {
);
}
/// activityId
static Future<ApiResponse> getPaymentMethods({
required String warrior,
String? vambrace,
}) async {
return _client.request(
path: '/v1/payment/get-payment-methods',
method: 'POST',
body: {
'warrior': warrior,
if (vambrace != null && vambrace.isNotEmpty) 'vambrace': vambrace,
},
);
}
///
static Future<ApiResponse> createPayment({
required String sentinel,
required String asset,
required String warrior,
required String resource,
String? ceremony,
String? lineage,
String? armor,
}) async {
@ -65,9 +81,33 @@ abstract final class PaymentApi {
'asset': asset,
'warrior': warrior,
'resource': resource,
if (ceremony != null && ceremony.isNotEmpty) 'ceremony': ceremony,
if (lineage != null) 'lineage': lineage,
if (armor != null) 'armor': armor,
},
);
}
/// Google ID ID
static Future<ApiResponse> googlepay({
required String merchant,
required String federation,
required String asset,
String? sample,
}) async {
return _client.request(
path: '/v1/payment/googlepay',
method: 'POST',
queryParams: {
'sentinel': ApiConfig.appId,
'asset': asset,
},
body: {
'merchant': merchant,
'federation': federation,
'asset': asset,
if (sample != null && sample.isNotEmpty) 'sample': sample,
},
);
}
}

View File

@ -31,6 +31,31 @@ abstract final class UserApi {
);
}
/// digest Adjust install referrer
static Future<ApiResponse> referrer({
required String sentinel,
required String asset,
required String digest,
required String origin,
String? accolade,
String? portal,
}) async {
return _client.request(
path: '/v1/user/referrer',
method: 'POST',
queryParams: {
'sentinel': sentinel,
'asset': asset,
if (accolade != null) 'accolade': accolade,
'portal': portal ?? ApiConfig.packageName,
},
body: {
'digest': digest,
'origin': origin,
},
);
}
///
static Future<ApiResponse> getCommonInfo({
required String sentinel,

View File

@ -8,8 +8,10 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../adjust/adjust_events.dart';
import '../api/api_client.dart';
import '../api/api_config.dart';
import '../api/proxy_client.dart';
import '../api/services/user_api.dart';
import '../log/app_logger.dart';
import '../referrer/referrer_service.dart';
import '../user/user_state.dart';
@ -17,16 +19,15 @@ import '../user/user_state.dart';
class AuthService {
AuthService._();
static const _tag = '[AuthService]';
static final _log = AppLogger('AuthService');
static Future<void>? _loginFuture;
/// Future await Future
static Future<void> get loginComplete =>
_loginFuture ?? Future<void>.value();
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
static void _log(String msg) {
debugPrint('$_tag $msg');
static void _logMsg(String msg) {
_log.d(msg);
}
/// IDAndroid: androidId, iOS: identifierForVendor, Web: fallback
@ -51,6 +52,31 @@ class AuthService {
return digest.toString().toUpperCase();
}
/// common_info surge enable_third_party_payment
static void _saveCommonInfoToState(Map<String, dynamic> data) {
final reveal = data['reveal'] as int?;
if (reveal != null) UserState.setCredits(reveal);
final realm = data['realm'] as String?;
if (realm != null && realm.isNotEmpty) UserState.setAvatar(realm);
final terminal = data['terminal'] as String?;
if (terminal != null && terminal.isNotEmpty) UserState.setUserName(terminal);
final navigate = data['navigate'] as String?;
if (navigate != null) UserState.setNavigate(navigate);
final surgeStr = data['surge'] as String?;
if (surgeStr != null && surgeStr.isNotEmpty) {
try {
final surge = json.decode(surgeStr) as Map<String, dynamic>?;
if (surge != null) {
final enable = surge['enable_third_party_payment'] as bool?;
UserState.setEnableThirdPartyPayment(enable);
}
} catch (e) {
_logMsg('surge JSON 解析失败: $e');
}
}
}
/// APP
///
static Future<void> init() async {
@ -58,7 +84,7 @@ class AuthService {
final completer = Completer<void>();
_loginFuture = completer.future;
_log('init: 开始快速登录');
_logMsg('init: 开始快速登录');
const maxRetries = 3;
const retryDelay = Duration(seconds: 2);
@ -67,20 +93,20 @@ class AuthService {
await Future<void>.delayed(const Duration(seconds: 2));
final deviceId = await _getDeviceId();
_log('init: deviceId=$deviceId');
_logMsg('init: deviceId=$deviceId');
final sign = _computeSign(deviceId);
_log('init: sign=$sign');
_logMsg('init: sign=$sign');
final crest = await ReferrerService.getReferrer();
if (crest != null && crest.isNotEmpty) {
_log('init: crest(referrer)=$crest');
_logMsg('init: crest(referrer)=$crest');
}
ApiResponse? res;
for (var i = 0; i < maxRetries; i++) {
if (i > 0) {
_log('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
_logMsg('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
await Future<void>.delayed(retryDelay);
}
try {
@ -92,23 +118,23 @@ class AuthService {
);
break;
} catch (e) {
_log('init: 第 ${i + 1} 次请求失败: $e');
_logMsg('init: 第 ${i + 1} 次请求失败: $e');
if (i == maxRetries - 1) rethrow;
}
}
if (res == null) return;
_log('init: 登录结果 code=${res.code} msg=${res.msg}');
_logMsg('init: 登录结果 code=${res.code} msg=${res.msg}');
if (res.isSuccess && res.data != null) {
final data = res.data as Map<String, dynamic>?;
final token = data?['reevaluate'] as String?;
if (token != null && token.isNotEmpty) {
ApiClient.instance.setUserToken(token);
_log('init: 已设置 userToken');
_logMsg('init: 已设置 userToken');
} else {
_log('init: 响应中无 reevaluate (userToken)');
_logMsg('init: 响应中无 reevaluate (userToken)');
}
final prefs = await SharedPreferences.getInstance();
final hadLoggedIn = prefs.getBool('adjust_has_logged_in') ?? false;
@ -119,17 +145,17 @@ class AuthService {
'adjust_register_date',
DateTime.now().toIso8601String().substring(0, 10),
);
_log('init: 首次登录,已上报 register');
_logMsg('init: 首次登录,已上报 register');
}
final credits = data?['reveal'] as int?;
if (credits != null) {
UserState.setCredits(credits);
_log('init: 已同步积分 $credits');
_logMsg('init: 已同步积分 $credits');
}
final uid = data?['asset'] as String?;
if (uid != null && uid.isNotEmpty) {
UserState.setUserId(uid);
_log('init: 已设置 userId');
_logMsg('init: 已设置 userId');
}
final avatarUrl = data?['realm'] as String?;
if (avatarUrl != null && avatarUrl.isNotEmpty) {
@ -143,12 +169,52 @@ class AuthService {
if (countryCode != null) {
UserState.setNavigate(countryCode);
}
// 3. digest Adjust install referrer
try {
final referrerRes = await UserApi.referrer(
sentinel: ApiConfig.appId,
asset: uid!,
digest: crest ?? '',
origin: deviceId,
);
if (referrerRes.isSuccess) {
_logMsg('referrer 上报成功');
} else {
_logMsg(
'referrer 上报失败: code=${referrerRes.code} msg=${referrerRes.msg}');
}
} catch (e) {
_logMsg('referrer 请求异常: $e');
}
// 4. surge
try {
final commonRes = await UserApi.getCommonInfo(
sentinel: ApiConfig.appId,
asset: uid,
);
if (commonRes.isSuccess && commonRes.data != null) {
final commonData = commonRes.data as Map<String, dynamic>?;
if (commonData != null) {
_saveCommonInfoToState(commonData);
_logMsg('common_info 已保存到全局');
}
_logMsg('common_info 响应:');
logWithEmbeddedJson(json.encode(commonRes.data));
} else {
_logMsg(
'common_info 失败: code=${commonRes.code} msg=${commonRes.msg}');
}
} catch (e) {
_logMsg('common_info 请求异常: $e');
}
} else {
_log('init: 登录失败');
_logMsg('init: 登录失败');
}
} catch (e, st) {
_log('init: 异常 $e');
_log('init: 堆栈 $st');
_logMsg('init: 异常 $e');
_logMsg('init: 堆栈 $st');
} finally {
if (!completer.isCompleted) completer.complete();
}

View 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);
}

View File

@ -27,4 +27,10 @@ abstract final class AppColors {
static const Color shadowLight = Color(0x14000000); // #00000008
static const Color shadowMedium = Color(0x0D000000); // #0000000D
static const Color shadowSoft = Color(0x0A000000); // #0000000A
// Card overlays (from Pencil)
static const Color playButtonFill = Color(0xE6FFFFFF); // #FFFFFFE6
static const Color playButtonShadow = Color(0x20000000); // #00000020
static const Color primaryButtonFill = Color(0xCC8B5CF6); // #8B5CF6CC
static const Color primaryButtonShadow = Color(0x408B5CF6); // #8B5CF640
}

View File

@ -10,6 +10,9 @@ class UserState {
static final ValueNotifier<String?> userName = ValueNotifier<String?>(null);
/// (navigate / countryCode)
static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null);
/// common_info surge.enable_third_party_payment
static final ValueNotifier<bool?> enableThirdPartyPayment =
ValueNotifier<bool?>(null);
static void setCredits(int? value) {
credits.value = value;
@ -31,6 +34,10 @@ class UserState {
navigate.value = value;
}
static void setEnableThirdPartyPayment(bool? value) {
enableThirdPartyPayment.value = value;
}
static String formatCredits(int? value) {
if (value == null) return '--';
return value.toString().replaceAllMapped(

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
@ -12,28 +13,88 @@ import '../../core/user/user_state.dart';
import '../../shared/widgets/top_nav_bar.dart';
import '../../core/api/services/image_api.dart';
import '../../core/api/services/user_api.dart';
import '../../features/gallery/models/gallery_task_item.dart';
import '../../shared/tab_selector_scope.dart';
import '../../shared/widgets/bottom_nav_bar.dart';
/// Generate Video Progress screen - matches Pencil qGs6n
/// Progress states: 1= 2= 3= 4= 5= 6=
/// Progress bar has 3 stages; states 36 are stage 3.
const _stateLabels = <int, String>{
1: 'Queued',
2: 'Processing',
3: 'Completed',
4: 'Timeout',
5: 'Error',
6: 'Aborted',
};
/// Stage progress: 1 -> 1/3, 2 -> 2/3, 3..6 -> 1.0
double _progressForState(int? state) {
if (state == null) return 0;
if (state == 1) return 1 / 3;
if (state == 2) return 2 / 3;
return 1.0; // 3, 4, 5, 6
}
/// Build GalleryMediaItem from /v1/image/progress response (data = sidekick).
/// curate[].reconfigure = result URL; for img2video use as videoUrl.
GalleryMediaItem? _mediaItemFromProgressData(Map<String, dynamic> data) {
final curate = data['curate'] as List<dynamic>?;
if (curate == null || curate.isEmpty) return null;
final first = curate.first;
if (first is! Map<String, dynamic>) return null;
final reconfigure = first['reconfigure'] as String?;
final digitize = first['digitize'] as String?;
final videoUrl = reconfigure?.isNotEmpty == true
? reconfigure
: digitize?.isNotEmpty == true
? digitize
: null;
final imageUrl = digitize?.isNotEmpty == true ? digitize : null;
if (videoUrl != null) {
return GalleryMediaItem(videoUrl: videoUrl, imageUrl: imageUrl);
}
if (imageUrl != null) {
return GalleryMediaItem(imageUrl: imageUrl);
}
return null;
}
/// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label)
class GenerateProgressScreen extends StatefulWidget {
const GenerateProgressScreen({super.key, this.taskId});
const GenerateProgressScreen({super.key, this.taskId, this.imagePath});
final dynamic taskId;
final String? imagePath;
@override
State<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
}
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
double _progress = 0;
int? _state;
Timer? _pollTimer;
double get _progress => _progressForState(_state);
void _onBack(BuildContext context) {
final completed = _state == 3;
if (!completed) {
TabSelectorScope.maybeOf(context)?.selectTab(NavTab.gallery);
Navigator.of(context).popUntil(ModalRoute.withName('/'));
} else {
Navigator.of(context).pop();
}
}
@override
void initState() {
super.initState();
if (widget.taskId != null) {
_startPolling();
} else {
_progress = 0.45;
_state = 2;
}
}
@ -45,8 +106,10 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
Future<void> _startPolling() async {
await AuthService.loginComplete;
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) => _fetchProgress());
_pollTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _fetchProgress(),
);
_fetchProgress();
}
@ -63,32 +126,53 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
if (!res.isSuccess || res.data == null) return;
final data = res.data as Map<String, dynamic>;
final progress = (data['dice'] as num?)?.toInt() ?? 0;
final state = (data['listing'] as num?)?.toInt();
if (!mounted) return;
setState(() {
_progress = progress / 100.0;
});
setState(() => _state = state);
if (progress >= 100 || state == 2) {
_pollTimer?.cancel();
Navigator.of(context).pushReplacementNamed('/result', arguments: widget.taskId);
} else if (state == 3) {
_pollTimer?.cancel();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Generation failed'),
behavior: SnackBarBehavior.floating,
),
);
switch (state) {
case 3: //
_pollTimer?.cancel();
//
final userId = UserState.userId.value;
if (userId != null && userId.isNotEmpty) {
try {
final accountRes = await UserApi.getAccount(
sentinel: ApiConfig.appId,
asset: userId,
);
if (accountRes.isSuccess && accountRes.data != null) {
final accountData =
accountRes.data as Map<String, dynamic>?;
final credits = accountData?['reveal'] as int?;
if (credits != null) {
UserState.setCredits(credits);
}
}
} catch (_) {}
}
if (!mounted) return;
final mediaItem = _mediaItemFromProgressData(data);
Navigator.of(context).pushReplacementNamed(
'/result',
arguments: mediaItem,
);
break;
case 4:
case 5:
case 6: // / /
_pollTimer?.cancel();
break;
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final labelText = _stateLabels[_state] ?? '队列中';
return Scaffold(
backgroundColor: AppColors.background,
appBar: PreferredSize(
@ -96,7 +180,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
child: TopNavBar(
title: 'Generating',
showBackButton: true,
onBack: () => Navigator.of(context).pop(),
onBack: () => _onBack(context),
),
),
body: SingleChildScrollView(
@ -104,7 +188,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_VideoPreview(),
_VideoPreview(imagePath: widget.imagePath),
const SizedBox(height: AppSpacing.xxl),
Text(
'Video generation may take some time. Please wait patiently.',
@ -114,7 +198,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
),
),
const SizedBox(height: AppSpacing.xxl),
_ProgressSection(progress: _progress),
_ProgressSection(progress: _progress, label: labelText),
],
),
),
@ -122,51 +206,70 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
}
}
/// Video area rSN3T: show uploaded image with crop (BoxFit.cover)
class _VideoPreview extends StatelessWidget {
const _VideoPreview({this.imagePath});
final String? imagePath;
@override
Widget build(BuildContext context) {
final hasImage =
imagePath != null && imagePath!.isNotEmpty && File(imagePath!).existsSync();
return Container(
height: 360,
decoration: BoxDecoration(
color: AppColors.textPrimary,
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.film,
size: 64,
color: AppColors.textSecondary,
),
const SizedBox(height: AppSpacing.lg),
Text(
'Video Preview',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
clipBehavior: Clip.antiAlias,
child: hasImage
? Image.file(
File(imagePath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.film,
size: 64,
color: AppColors.textSecondary,
),
const SizedBox(height: AppSpacing.lg),
Text(
'Video Preview',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
],
),
);
}
}
/// Progress bar: 3 stages. Label Eghqc shows state (|||||)
class _ProgressSection extends StatelessWidget {
const _ProgressSection({required this.progress});
const _ProgressSection({required this.progress, required this.label});
final double progress;
final String label;
@override
Widget build(BuildContext context) {
final percentage = (progress * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Generating... $percentage%',
label,
style: AppTypography.bodyMedium.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),

View File

@ -1,13 +1,15 @@
import 'dart:developer' as developer;
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:video_player/video_player.dart';
import '../../core/auth/auth_service.dart';
import '../../core/log/app_logger.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart';
@ -23,6 +25,8 @@ import '../../core/api/services/user_api.dart';
class GenerateVideoScreen extends StatefulWidget {
const GenerateVideoScreen({super.key, this.task});
static final _log = AppLogger('GenerateVideo');
final TaskItem? task;
@override
@ -32,7 +36,6 @@ class GenerateVideoScreen extends StatefulWidget {
enum _Resolution { p480, p720 }
class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
File? _selectedImage;
_Resolution _selectedResolution = _Resolution.p480;
bool _isGenerating = false;
@ -54,15 +57,15 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
developer.log(
'GenerateVideoScreen opened with task: ${widget.task}',
name: 'GenerateVideoScreen',
);
debugPrint('[GenerateVideoScreen] task: ${widget.task}');
GenerateVideoScreen._log.d('opened with task: ${widget.task}');
});
}
Future<void> _showImageSourcePicker() async {
/// Click flow per docs/generate_video.md: tap Generate Video -> image picker
/// (camera or gallery) -> after image selected -> proceed to API.
Future<void> _onGenerateButtonTap() async {
if (_isGenerating) return;
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
@ -83,43 +86,25 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
),
),
);
if (source != null && mounted) {
_pickImage(source);
}
}
if (source == null || !mounted) return;
Future<void> _pickImage(ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: source,
imageQuality: 85,
);
if (picked != null && mounted) {
setState(() {
_selectedImage = File(picked.path);
});
}
if (picked == null || !mounted) return;
final file = File(picked.path);
await _runGenerationApi(file);
}
Future<void> _onGenerate() async {
if (_selectedImage == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select an image first'),
behavior: SnackBarBehavior.floating,
),
);
}
return;
}
Future<void> _runGenerationApi(File file) async {
setState(() => _isGenerating = true);
try {
await AuthService.loginComplete;
final file = _selectedImage!;
final size = await file.length();
final ext = file.path.split('.').last.toLowerCase();
final contentType = ext == 'png'
@ -212,11 +197,12 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
}
if (!mounted) return;
Navigator.of(context)
.pushReplacementNamed('/progress', arguments: taskId);
Navigator.of(context).pushReplacementNamed(
'/progress',
arguments: <String, dynamic>{'taskId': taskId, 'imagePath': file.path},
);
} catch (e, st) {
developer.log('Generate failed: $e', name: 'GenerateVideoScreen');
debugPrint('[GenerateVideoScreen] error: $e\n$st');
GenerateVideoScreen._log.e('Generate failed', e, st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -247,32 +233,61 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CreditsCard(
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CreditsCard(
credits:
UserCreditsData.of(context)?.creditsDisplay ?? '--',
),
const SizedBox(height: AppSpacing.xxl),
_VideoPreviewArea(
videoUrl: widget.task?.previewVideoUrl,
imageUrl: widget.task?.previewImageUrl,
),
],
),
),
const SizedBox(height: AppSpacing.xxl),
_UploadArea(
selectedImage: _selectedImage,
onUpload: _showImageSourcePicker,
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPaddingLarge,
AppSpacing.lg,
AppSpacing.screenPaddingLarge,
AppSpacing.screenPaddingLarge,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ResolutionToggle(
selected: _selectedResolution,
onChanged: (r) =>
setState(() => _selectedResolution = r),
),
const SizedBox(height: AppSpacing.xxl),
_GenerateButton(
onGenerate: _onGenerateButtonTap,
isLoading: _isGenerating,
credits: _currentCredits.toString(),
),
],
),
),
const SizedBox(height: AppSpacing.xxl),
_ResolutionToggle(
selected: _selectedResolution,
onChanged: (r) => setState(() => _selectedResolution = r),
),
const SizedBox(height: AppSpacing.xxl),
_GenerateButton(
onGenerate: _onGenerate,
isLoading: _isGenerating,
credits: _currentCredits.toString(),
),
],
),
),
],
),
);
}
@ -333,63 +348,176 @@ class _CreditsCard extends StatelessWidget {
}
}
class _UploadArea extends StatelessWidget {
const _UploadArea({
required this.selectedImage,
required this.onUpload,
/// Video preview area - video URL from card click. Auto-load and play on init.
/// Video fit: contain (no crop). Loading animation until ready.
class _VideoPreviewArea extends StatefulWidget {
const _VideoPreviewArea({
this.videoUrl,
this.imageUrl,
});
final File? selectedImage;
final VoidCallback onUpload;
final String? videoUrl;
final String? imageUrl;
@override
State<_VideoPreviewArea> createState() => _VideoPreviewAreaState();
}
class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
VideoPlayerController? _controller;
@override
void initState() {
super.initState();
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
_loadAndPlay();
}
}
@override
void dispose() {
_controller?.dispose();
_controller = null;
super.dispose();
}
@override
void didUpdateWidget(covariant _VideoPreviewArea oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoUrl != widget.videoUrl) {
_controller?.dispose();
_controller = null;
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
_loadAndPlay();
}
}
}
Future<void> _loadAndPlay() async {
final url = widget.videoUrl;
if (url == null || url.isEmpty) return;
setState(() {});
try {
final file = await DefaultCacheManager().getSingleFile(url);
if (!mounted) return;
final controller = VideoPlayerController.file(file);
await controller.initialize();
if (!mounted) return;
await controller.play();
controller.setLooping(true);
if (mounted) {
setState(() {
_controller = controller;
});
}
} catch (e) {
GenerateVideoScreen._log.e('Video load failed', e);
if (mounted) setState(() {});
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onUpload,
child: Container(
height: 280,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.border,
width: 2,
final isReady =
_controller != null && _controller!.value.isInitialized;
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
final hasImage =
widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
// Aspect ratio: from video when ready, else 16:9 placeholder
final aspectRatio = isReady &&
_controller!.value.size.width > 0 &&
_controller!.value.size.height > 0
? _controller!.value.size.width / _controller!.value.size.height
: 16 / 9;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = width / aspectRatio;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.border,
width: 1,
),
),
),
clipBehavior: Clip.antiAlias,
child: selectedImage != null
? Image.file(
selectedImage!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.image_plus,
size: 48,
color: AppColors.textMuted,
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: 280,
child: Text(
'Please upload an image as the base for generation',
textAlign: TextAlign.center,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
if (isReady)
SizedBox.expand(
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: _controller!.value.size.width,
height: _controller!.value.size.height,
child: VideoPlayer(_controller!),
),
),
],
),
),
)
else if (hasImage)
CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => _LoadingOverlay(isLoading: true),
errorWidget: (_, __, ___) => _LoadingOverlay(isLoading: false),
)
else
_LoadingOverlay(isLoading: false),
if (hasVideo && !isReady)
Positioned.fill(
child: _LoadingOverlay(isLoading: true),
),
],
),
);
},
);
}
}
class _LoadingOverlay extends StatelessWidget {
const _LoadingOverlay({this.isLoading = true});
final bool isLoading;
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.surfaceAlt,
alignment: Alignment.center,
child: isLoading
? const SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
)
: Icon(
LucideIcons.video,
size: 48,
color: AppColors.textMuted,
),
);
}
}
/// Resolution row - 1:1 match Pencil oYwsm: label + 480P/720P on same row.
/// Button: height 36, cornerRadius 18, padding 8x16, gap 12 label-to-btns, gap 8 between btns.
class _ResolutionToggle extends StatelessWidget {
const _ResolutionToggle({
required this.selected,
@ -401,70 +529,82 @@ class _ResolutionToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: GestureDetector(
return SizedBox(
height: 44,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Resolution',
style: AppTypography.bodyMedium.copyWith(
fontSize: 14,
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 12),
_ResolutionOption(
label: '480P',
isSelected: selected == _Resolution.p480,
onTap: () => onChanged(_Resolution.p480),
child: Container(
height: 48,
decoration: BoxDecoration(
color: selected == _Resolution.p480
? AppColors.primary
: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected == _Resolution.p480
? AppColors.primary.withValues(alpha: 0.5)
: AppColors.border,
width: selected == _Resolution.p480 ? 1 : 2,
),
),
alignment: Alignment.center,
child: Text(
'480P',
style: AppTypography.bodyMedium.copyWith(
color: selected == _Resolution.p480
? AppColors.surface
: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
const SizedBox(width: 8),
_ResolutionOption(
label: '720P',
isSelected: selected == _Resolution.p720,
onTap: () => onChanged(_Resolution.p720),
child: Container(
height: 48,
decoration: BoxDecoration(
color: selected == _Resolution.p720
? AppColors.primary
: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected == _Resolution.p720
? AppColors.primary.withValues(alpha: 0.5)
: AppColors.border,
width: selected == _Resolution.p720 ? 1 : 2,
),
),
alignment: Alignment.center,
child: Text(
'720P',
style: AppTypography.bodyMedium.copyWith(
color: selected == _Resolution.p720
? AppColors.surface
: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
}
class _ResolutionOption extends StatelessWidget {
const _ResolutionOption({
required this.label,
required this.isSelected,
required this.onTap,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(18),
border: isSelected
? null
: Border.all(color: AppColors.border, width: 1),
boxShadow: isSelected
? [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.19),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
alignment: Alignment.center,
child: Text(
label,
style: AppTypography.bodyMedium.copyWith(
fontSize: 13,
color: isSelected ? AppColors.surface : AppColors.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
);
}
}

View File

@ -85,7 +85,8 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
if (_videoUrl != null) {
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/gallery_video_${DateTime.now().millisecondsSinceEpoch}.mp4');
final file = File(
'${tempDir.path}/gallery_video_${DateTime.now().millisecondsSinceEpoch}.mp4');
final response = await http.get(Uri.parse(_videoUrl!));
if (response.statusCode != 200) {
throw Exception('Failed to download video');
@ -95,7 +96,8 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
await file.delete();
} else if (_imageUrl != null) {
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/gallery_image_${DateTime.now().millisecondsSinceEpoch}.jpg');
final file = File(
'${tempDir.path}/gallery_image_${DateTime.now().millisecondsSinceEpoch}.jpg');
final response = await http.get(Uri.parse(_imageUrl!));
if (response.statusCode != 200) {
throw Exception('Failed to download image');
@ -156,9 +158,7 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
_DownloadButton(
onDownload: _saving ? null : _saveToAlbum,
saving: _saving,
),
const SizedBox(height: AppSpacing.lg),
_ShareButton(onShare: () {}),
)
],
),
),

View File

@ -5,7 +5,6 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_spacing.dart';
/// Video card for home grid -
///
@ -35,7 +34,6 @@ class VideoCard extends StatefulWidget {
class _VideoCardState extends State<VideoCard> {
VideoPlayerController? _controller;
bool _isLoading = false;
@override
void initState() {
@ -64,9 +62,7 @@ class _VideoCardState extends State<VideoCard> {
void _stop() {
_controller?.removeListener(_onVideoUpdate);
_controller?.pause();
if (mounted) {
setState(() => _isLoading = false);
}
if (mounted) setState(() {});
}
void _disposeController() {
@ -85,34 +81,30 @@ class _VideoCardState extends State<VideoCard> {
final needSeek = _controller!.value.position >= _controller!.value.duration &&
_controller!.value.duration.inMilliseconds > 0;
if (needSeek) {
setState(() => _isLoading = true);
await _controller!.seekTo(Duration.zero);
}
_controller!.addListener(_onVideoUpdate);
await _controller!.play();
if (mounted) setState(() => _isLoading = false);
if (mounted) setState(() {});
return;
}
setState(() => _isLoading = true);
setState(() {});
try {
final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!);
if (!mounted || !widget.isActive) {
setState(() => _isLoading = false);
return;
}
if (!mounted || !widget.isActive) return;
_controller = VideoPlayerController.file(file);
await _controller!.initialize();
_controller!.addListener(_onVideoUpdate);
if (mounted && widget.isActive) {
await _controller!.play();
setState(() => _isLoading = false);
setState(() {});
}
} catch (e) {
if (mounted) {
_disposeController();
setState(() => _isLoading = false);
setState(() {});
widget.onStopRequested();
}
}
@ -143,7 +135,6 @@ class _VideoCardState extends State<VideoCard> {
@override
Widget build(BuildContext context) {
final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized;
final showLoading = widget.isActive && _isLoading;
return LayoutBuilder(
builder: (context, constraints) {
@ -151,9 +142,8 @@ class _VideoCardState extends State<VideoCard> {
width: constraints.maxWidth,
height: constraints.maxHeight,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
color: Colors.transparent,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.border, width: 1),
boxShadow: [
BoxShadow(
color: AppColors.shadowMedium,
@ -169,37 +159,51 @@ class _VideoCardState extends State<VideoCard> {
children: [
if (showVideo)
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.size.width > 0
? _controller!.value.size.width
: 16,
height: _controller!.value.size.height > 0
? _controller!.value.size.height
: 9,
child: VideoPlayer(_controller!),
child: GestureDetector(
onTap: _onPlayButtonTap,
behavior: HitTestBehavior.opaque,
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.size.width > 0
? _controller!.value.size.width
: 16,
height: _controller!.value.size.height > 0
? _controller!.value.size.height
: 9,
child: VideoPlayer(_controller!),
),
),
),
)
else
CachedNetworkImage(
imageUrl: widget.imageUrl,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: AppColors.surfaceAlt,
),
errorWidget: (_, __, ___) => Container(
color: AppColors.surfaceAlt,
Positioned.fill(
child: GestureDetector(
onTap: widget.videoUrl != null &&
widget.videoUrl!.isNotEmpty
? _onPlayButtonTap
: null,
behavior: HitTestBehavior.opaque,
child: CachedNetworkImage(
imageUrl: widget.imageUrl,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: AppColors.surfaceAlt,
),
errorWidget: (_, __, ___) => Container(
color: AppColors.surfaceAlt,
),
),
),
),
Positioned(
top: 12,
right: 12,
child: Container(
height: 24,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.overlayDark,
@ -207,13 +211,14 @@ class _VideoCardState extends State<VideoCard> {
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.sparkles,
size: 12,
color: AppColors.surface,
),
const SizedBox(width: AppSpacing.sm),
const SizedBox(width: 4),
Text(
widget.credits,
style: const TextStyle(
@ -227,43 +232,6 @@ class _VideoCardState extends State<VideoCard> {
),
),
),
Positioned.fill(
child: Center(
child: GestureDetector(
onTap: widget.videoUrl != null && widget.videoUrl!.isNotEmpty
? _onPlayButtonTap
: null,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.surface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.13),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: showLoading
? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(
color: AppColors.textPrimary,
strokeWidth: 2,
),
)
: Icon(
_isPlaying ? LucideIcons.pause : LucideIcons.play,
size: 24,
color: AppColors.textPrimary,
),
),
),
),
),
if (_isPlaying)
Positioned(
top: 8,
@ -288,33 +256,41 @@ class _VideoCardState extends State<VideoCard> {
),
),
Positioned(
bottom: 12,
bottom: 16,
left: 12,
right: 12,
child: GestureDetector(
onTap: widget.onGenerateSimilar,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25),
blurRadius: 6,
offset: const Offset(0, 2),
child: Center(
child: IntrinsicWidth(
child: GestureDetector(
onTap: widget.onGenerateSimilar,
child: Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: AppColors.primaryButtonFill,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.primary,
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.primaryButtonShadow,
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: const Text(
'Generate Similar',
style: TextStyle(
color: AppColors.surface,
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: 'Inter',
),
),
],
),
alignment: Alignment.center,
child: const Text(
'Generate Similar',
style: TextStyle(
color: AppColors.surface,
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: 'Inter',
),
),
),

View 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
/// purchaseDataserverVerificationData/ 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;
});
}
}

View File

@ -40,6 +40,14 @@ class ActivityItem {
String get creditsDisplay =>
credits > 0 ? '${credits.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},')} Credits' : title;
/// $ guardian $
String get priceDisplayWithDollar {
if (actualAmount.isEmpty) return '';
final amount = actualAmount.trimLeft();
if (amount.startsWith(r'$')) return actualAmount;
return '\$$actualAmount';
}
/// "¥6" "¥25 Save ¥5"
String get priceDisplay {
if (actualAmount.isEmpty) return '';

View 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;
}

View 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);
}
}

View File

@ -3,36 +3,58 @@ import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import '../../core/adjust/adjust_events.dart';
import '../../core/api/api_config.dart';
import '../../core/api/services/payment_api.dart';
import '../../core/auth/auth_service.dart';
import '../../core/log/app_logger.dart';
import '../../core/theme/app_colors.dart';
import '../../core/user/user_state.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart';
import '../../shared/widgets/top_nav_bar.dart';
import 'google_play_purchase_service.dart';
import 'models/activity_item.dart';
import 'models/payment_method_item.dart';
import 'payment_webview_screen.dart';
/// Recharge screen - matches Pencil tPjdN
/// Tier cards (DC6NS, YRaOG, QlNz6) getGooglePayActivities / getApplePayActivities
class RechargeScreen extends StatefulWidget {
static final _log = AppLogger('Recharge');
const RechargeScreen({super.key});
@override
State<RechargeScreen> createState() => _RechargeScreenState();
}
class _RechargeScreenState extends State<RechargeScreen> {
class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObserver {
List<ActivityItem> _activities = [];
bool _loadingTiers = true;
String? _tierError;
/// code item Buy loading
String? _loadingProductId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_fetchActivities();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) {
setState(() => _loadingProductId = null);
}
}
Future<void> _fetchActivities() async {
setState(() {
_loadingTiers = true;
@ -80,7 +102,10 @@ class _RechargeScreenState extends State<RechargeScreen> {
}
}
void _onBuy(ActivityItem item) {
Future<void> _onBuy(ActivityItem item) async {
if (_loadingProductId != null) return;
setState(() => _loadingProductId = item.code);
final price = AdjustEvents.parsePrice(item.actualAmount);
if (price != null) {
final tierToken = AdjustEvents.tierTokenFromPrice(price);
@ -94,7 +119,245 @@ class _RechargeScreenState extends State<RechargeScreen> {
} else if (titleLower.contains('weekly vip')) {
AdjustEvents.trackWeeklyVip();
}
// TODO: AdjustEvents.trackPurchaseSuccess() AdjustEvents.trackPaymentFailed()
final useThirdParty =
UserState.enableThirdPartyPayment.value == true;
final uid = UserState.userId.value;
if (useThirdParty && uid != null && uid.isNotEmpty) {
await _runThirdPartyPayment(item, uid);
} else {
await _runGooglePay(item);
}
}
/// -> -> ->
Future<void> _runThirdPartyPayment(ActivityItem item, String userId) async {
final activityId = item.activityId;
if (activityId == null || activityId.isEmpty) {
if (mounted) {
_showSnackBar(context, 'Invalid product', isError: true);
}
AdjustEvents.trackPaymentFailed();
return;
}
if (!mounted) return;
try {
final country = UserState.navigate.value;
final methodsRes = await PaymentApi.getPaymentMethods(
warrior: activityId,
vambrace: country?.isEmpty == true ? null : country,
);
if (!mounted) return;
if (!methodsRes.isSuccess || methodsRes.data == null) {
_showSnackBar(
context,
methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods',
isError: true,
);
AdjustEvents.trackPaymentFailed();
return;
}
final methodsList = methodsRes.data is Map<String, dynamic>
? (methodsRes.data! as Map<String, dynamic>)['renew'] as List<dynamic>?
: null;
if (methodsList == null || methodsList.isEmpty) {
_showSnackBar(context, 'No payment methods available', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final methods = methodsList
.whereType<Map<String, dynamic>>()
.map((e) => PaymentMethodItem.fromJson(e))
.where((m) => m.paymentMethod.isNotEmpty)
.toList();
if (methods.isEmpty) {
_showSnackBar(context, 'No payment methods available', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
if (!mounted) return;
final selected = await showDialog<PaymentMethodItem>(
context: context,
builder: (ctx) => _PaymentMethodDialog(methods: methods),
);
if (!mounted) return;
if (selected == null) {
setState(() => _loadingProductId = null);
return;
}
await _createOrderAndOpenUrl(
userId: userId,
activityId: activityId,
productId: item.code,
paymentMethod: selected.paymentMethod,
subPaymentMethod: selected.subPaymentMethod?.isEmpty == true ? null : selected.subPaymentMethod,
);
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
}
AdjustEvents.trackPaymentFailed();
} finally {
if (mounted) {
setState(() => _loadingProductId = null);
}
}
}
/// Google Pay
Future<void> _createOrderAndOpenUrl({
required String userId,
required String activityId,
required String productId,
required String paymentMethod,
String? subPaymentMethod,
}) async {
if (!mounted) return;
try {
final createRes = await PaymentApi.createPayment(
sentinel: ApiConfig.appId,
asset: userId,
warrior: activityId,
resource: paymentMethod,
ceremony: subPaymentMethod,
);
if (!mounted) return;
if (!createRes.isSuccess) {
_showSnackBar(
context,
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order',
isError: true,
);
AdjustEvents.trackPaymentFailed();
return;
}
final data = createRes.data is Map<String, dynamic>
? createRes.data as Map<String, dynamic>
: null;
final orderId = data?['federation']?.toString();
if (_isGooglePay(paymentMethod, subPaymentMethod)) {
if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final purchaseData = await GooglePlayPurchaseService.launchPurchaseAndReturnData(productId);
if (!mounted) return;
if (purchaseData != null && purchaseData.isNotEmpty && orderId != null && orderId.isNotEmpty) {
RechargeScreen._log.d('googlepay 入参: federation=$orderId, asset=$userId');
RechargeScreen._log.d('googlepay 入参: merchant(length=${purchaseData.length}) ${purchaseData.length > 400 ? "${purchaseData.substring(0, 400)}..." : purchaseData}');
final googlepayRes = await PaymentApi.googlepay(
merchant: purchaseData,
federation: orderId,
asset: userId,
);
if (!mounted) return;
if (googlepayRes.isSuccess) {
_showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess();
} else {
_showSnackBar(context, googlepayRes.msg.isNotEmpty ? googlepayRes.msg : 'Payment verification failed.', isError: true);
AdjustEvents.trackPaymentFailed();
}
} else {
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true);
AdjustEvents.trackPaymentFailed();
}
return;
}
final payUrl = data?['convert']?.toString();
if (payUrl != null && payUrl.isNotEmpty) {
if (mounted) {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl),
),
);
_showSnackBar(context, 'Order created. Complete payment in the page.');
AdjustEvents.trackPurchaseSuccess();
}
} else {
if (mounted) {
_showSnackBar(context, 'Order created. Awaiting payment confirmation.');
}
AdjustEvents.trackPurchaseSuccess();
}
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
}
AdjustEvents.trackPaymentFailed();
} finally {
if (mounted) {
setState(() => _loadingProductId = null);
}
}
}
/// ceremony==GooglePay resource==GOOGLEPAY
static bool _isGooglePay(String paymentMethod, String? subPaymentMethod) {
final r = paymentMethod.trim().toLowerCase();
final c = (subPaymentMethod ?? '').trim().toLowerCase();
return r == 'googlepay' || c == 'googlepay';
}
/// false
Future<void> _runGooglePay(ActivityItem item) async {
if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
await _launchGooglePlayPurchase(item);
}
/// Google Play ID = item.code / helm
Future<void> _launchGooglePlayPurchase(ActivityItem item) async {
if (!mounted) return;
try {
final success = await GooglePlayPurchaseService.launchPurchase(item.code);
if (!mounted) return;
if (success) {
_showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess();
} else {
_showSnackBar(context, 'Purchase was cancelled or failed.', isError: true);
AdjustEvents.trackPaymentFailed();
}
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Google Pay error: ${e.toString()}', isError: true);
}
AdjustEvents.trackPaymentFailed();
} finally {
if (mounted) {
setState(() => _loadingProductId = null);
}
}
}
void _showSnackBar(BuildContext context, String message, {bool isError = false}) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red.shade700 : null,
),
);
}
@override
@ -192,6 +455,7 @@ class _RechargeScreenState extends State<RechargeScreen> {
: isPopular
? _TierBadge.popular
: _TierBadge.none,
loading: _loadingProductId == item.code,
onBuy: () => _onBuy(item),
),
);
@ -203,17 +467,58 @@ class _RechargeScreenState extends State<RechargeScreen> {
}
}
///
class _PaymentMethodDialog extends StatelessWidget {
const _PaymentMethodDialog({required this.methods});
final List<PaymentMethodItem> methods;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Select payment method'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: methods
.map(
(m) => ListTile(
title: Text(m.displayName),
subtitle: m.subPaymentMethod != null && m.subPaymentMethod!.isNotEmpty
? Text(m.subPaymentMethod!, style: AppTypography.caption)
: null,
trailing: m.recommend
? Text('Recommended', style: AppTypography.caption.copyWith(color: AppColors.primary))
: null,
onTap: () => Navigator.of(context).pop(m),
),
)
.toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
);
}
}
enum _TierBadge { none, recommended, popular }
class _TierCardFromActivity extends StatelessWidget {
const _TierCardFromActivity({
required this.item,
required this.badge,
required this.loading,
required this.onBuy,
});
final ActivityItem item;
final _TierBadge badge;
final bool loading;
final VoidCallback onBuy;
@override
@ -239,25 +544,55 @@ class _TierCardFromActivity extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.creditsDisplay,
style: AppTypography.bodyLarge.copyWith(
color: AppColors.textPrimary,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.creditsDisplay,
style: AppTypography.bodyLarge.copyWith(
color: AppColors.textPrimary,
),
),
),
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm),
Text(
item.priceDisplay,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
item.priceDisplayWithDollar,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
),
if (item.originAmount != null &&
item.originAmount!.isNotEmpty &&
item.originAmount != item.actualAmount) ...[
const SizedBox(width: AppSpacing.sm),
Text(
item.originAmount!,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
decoration: TextDecoration.lineThrough,
decorationColor: AppColors.textSecondary,
),
),
],
if (item.bonus > 0) ...[
const SizedBox(width: AppSpacing.sm),
Text(
'Bonus: ${item.bonus} credits',
style: AppTypography.caption.copyWith(
color: AppColors.primary,
),
),
],
],
),
),
],
],
),
),
_BuyButton(onTap: onBuy),
_BuyButton(onTap: onBuy, loading: loading),
],
),
);
@ -358,30 +693,40 @@ class _CreditsSection extends StatelessWidget {
}
class _BuyButton extends StatelessWidget {
const _BuyButton({required this.onTap});
const _BuyButton({required this.onTap, this.loading = false});
final VoidCallback onTap;
final bool loading;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onTap: loading ? null : onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.primary,
color: loading ? AppColors.primary.withValues(alpha: 0.7) : AppColors.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Buy',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface,
fontWeight: FontWeight.w600,
),
),
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.surface),
),
)
: Text(
'Buy',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface,
fontWeight: FontWeight.w600,
),
),
),
);
}

View 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;
}

View File

@ -1,7 +1,7 @@
name: pets_hero_ai
description: PetsHero AI Application.
publish_to: 'none'
version: 1.0.1+2
version: 1.0.3+4
environment:
sdk: '>=3.0.0 <4.0.0'
@ -26,6 +26,10 @@ dependencies:
path_provider: ^2.1.2
shared_preferences: ^2.2.2
flutter_cache_manager: ^3.3.1
logger: ^2.0.2
url_launcher: ^6.2.5
in_app_purchase: ^3.2.0
webview_flutter: ^4.10.0
dev_dependencies:
flutter_test: