Compare commits

..

10 Commits

Author SHA1 Message Date
ivan
cacb32c25f 打包:版本发布1.1.1 2026-03-21 23:39:28 +08:00
ivan
eb72fb4979 更新:启动图和logo,登录还没成功的时候显示加载指示器 2026-03-17 09:38:49 +08:00
ivan
596fe05e09 打包:版本发布 2026-03-15 12:02:36 +08:00
ivan
066d8b7391 新增:三方支付完整调用 2026-03-15 11:25:09 +08:00
ivan
ea14c36d63 新增:图生图功能 2026-03-13 22:04:57 +08:00
ivan
62f5c9a19b 新增:首页新增图一图功能 2026-03-13 20:10:57 +08:00
ivan
bde8db3673 优化:谷歌支付补单功能 2026-03-13 20:09:50 +08:00
ivan
d2e7c0ac8f 优化:谷歌支付完整流程 2026-03-12 22:16:36 +08:00
ivan
bd6f8ba813 优化:支付回调 2026-03-12 20:40:37 +08:00
ivan
6ae2b55677 优化:积分页面积分同步处理 2026-03-12 15:30:45 +08:00
107 changed files with 3264 additions and 691 deletions

View File

@ -1,7 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="com.android.vending.BILLING" /> <uses-permission android:name="com.android.vending.BILLING" />
<application <application
android:usesCleartextTraffic="${usesCleartextTraffic}" android:usesCleartextTraffic="${usesCleartextTraffic}"

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#0a0a12</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@android:color/black</item> <item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@android:color/black</item> <item name="android:windowBackground">@android:color/black</item>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#0a0a12</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@android:color/black</item> <item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@android:color/black</item> <item name="android:windowBackground">@android:color/black</item>

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

BIN
assets/images/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -2293,6 +2293,52 @@
"fill": "#A1A1AA" "fill": "#A1A1AA"
} }
] ]
},
{
"type": "frame",
"id": "WYY7b",
"name": "menuItem3",
"width": "fill_container",
"fill": "#FFFFFF",
"cornerRadius": 12,
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "#00000008",
"offset": {
"x": 0,
"y": 2
},
"blur": 6
},
"padding": [
14,
16
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "P6isW",
"name": "textNode",
"fill": "#DC2626",
"content": "Delete Account",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "8OWH9",
"name": "iconNode",
"width": 20,
"height": 20,
"iconFontName": "trash-2",
"iconFontFamily": "lucide",
"fill": "#DC2626"
}
]
} }
] ]
} }
@ -4082,10 +4128,12 @@
}, },
{ {
"type": "frame", "type": "frame",
"id": "OKMsh", "id": "RGAdu",
"name": "navSpacer", "name": "reportBtn",
"width": 40, "width": 40,
"height": 40 "height": 40,
"justifyContent": "center",
"alignItems": "center"
} }
] ]
}, },
@ -4117,12 +4165,62 @@
}, },
"cornerRadius": 16, "cornerRadius": 16,
"layout": "vertical", "layout": "vertical",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "Ne80z",
"name": "topRow",
"width": "fill_container",
"height": 40,
"padding": [
16,
20,
0,
0
],
"justifyContent": "end",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "h2IK3",
"name": "reportBtn",
"width": 44,
"height": 44,
"fill": "#00000099",
"cornerRadius": 8,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "GtWHh",
"width": 24,
"height": 24,
"iconFontName": "triangle-alert",
"iconFontFamily": "lucide",
"fill": "#FFFFFF"
}
]
}
]
},
{
"type": "frame",
"id": "wD2sT",
"name": "centerGroup",
"width": "fill_container",
"layout": "vertical",
"gap": 8,
"justifyContent": "center", "justifyContent": "center",
"alignItems": "center", "alignItems": "center",
"children": [ "children": [
{ {
"type": "icon_font", "type": "icon_font",
"id": "a3uQs", "id": "a3uQs",
"enabled": false,
"width": 72, "width": 72,
"height": 72, "height": 72,
"iconFontName": "play", "iconFontName": "play",
@ -4133,13 +4231,39 @@
"type": "text", "type": "text",
"id": "SuXL2", "id": "SuXL2",
"fill": "#FFFFFF99", "fill": "#FFFFFF99",
"content": "Your video is ready", "content": "your work is ready",
"fontFamily": "Inter", "fontFamily": "Inter",
"fontSize": 14, "fontSize": 14,
"fontWeight": "500" "fontWeight": "500"
} }
] ]
}, },
{
"type": "frame",
"id": "nNSbD",
"name": "watermarkRow",
"width": "fill_container",
"padding": [
0,
20,
20,
0
],
"justifyContent": "end",
"children": [
{
"type": "text",
"id": "BqSHo",
"fill": "#FFFFFF99",
"content": "PetsHero AI",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
}
]
},
{ {
"type": "frame", "type": "frame",
"id": "jCww0", "id": "jCww0",
@ -4194,6 +4318,312 @@
] ]
} }
] ]
},
{
"type": "frame",
"id": "5qKUB",
"x": 3150,
"y": 100,
"name": "Report Dialog",
"width": 342,
"fill": "#FFFFFF",
"cornerRadius": 16,
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "#00000026",
"offset": {
"x": 0,
"y": 8
},
"blur": 24
},
"layout": "vertical",
"gap": 20,
"padding": 24,
"children": [
{
"type": "frame",
"id": "6i0Kw",
"name": "dialogHeader",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "ISmLf",
"name": "title",
"fill": "#18181B",
"content": "Report",
"fontFamily": "Plus Jakarta Sans",
"fontSize": 20,
"fontWeight": "700"
},
{
"type": "frame",
"id": "yxaWM",
"name": "closeBtn",
"width": 40,
"height": 40,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "g1Nfm",
"width": 24,
"height": 24,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
}
]
},
{
"type": "frame",
"id": "884m3",
"name": "descriptionInput",
"width": "fill_container",
"height": 120,
"fill": "#FAFAFA",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#E4E4E7"
},
"layout": "vertical",
"padding": 16,
"children": [
{
"type": "text",
"id": "JDjJq",
"name": "placeholder",
"fill": "#A1A1AA",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Describe the issue...",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "2ofcH",
"name": "imageUpload",
"width": "fill_container",
"height": 120,
"fill": "#F4F4F5",
"cornerRadius": 12,
"stroke": {
"align": "center",
"thickness": 2,
"fill": "#D4D4D8"
},
"layout": "vertical",
"gap": 8,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "1NYr7",
"name": "uploadIcon",
"width": 32,
"height": 32,
"iconFontName": "image-plus",
"iconFontFamily": "lucide",
"fill": "#A1A1AA"
},
{
"type": "text",
"id": "jnJim",
"name": "uploadLabel",
"fill": "#71717A",
"content": "Tap to upload image",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "ZOA9y",
"name": "submitBtn",
"width": "fill_container",
"height": 52,
"fill": "#7C3AED",
"cornerRadius": 14,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "Jfj7q",
"fill": "#FFFFFF",
"content": "Submit",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
}
]
},
{
"type": "frame",
"id": "Xp6Qz",
"x": 900,
"y": 0,
"name": "Delete Account Overlay",
"width": 390,
"height": 844,
"fill": "#00000080",
"layout": "vertical",
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "vFAbj",
"name": "Delete Account Dialog",
"width": 342,
"fill": "#FFFFFF",
"cornerRadius": 16,
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "#00000026",
"offset": {
"x": 0,
"y": 8
},
"blur": 24
},
"layout": "vertical",
"gap": 20,
"padding": 24,
"children": [
{
"type": "frame",
"id": "YR8mP",
"name": "header",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "Pck1A",
"name": "title",
"fill": "#18181B",
"content": "Delete Account?",
"fontFamily": "Plus Jakarta Sans",
"fontSize": 20,
"fontWeight": "700"
},
{
"type": "frame",
"id": "d8c1P",
"name": "closeBtn",
"width": 40,
"height": 40,
"layout": "vertical",
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "l4PfI",
"name": "closeIcon",
"width": 24,
"height": 24,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
}
]
},
{
"type": "text",
"id": "8K9GG",
"name": "desc",
"fill": "#71717A",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "This action cannot be undone. All your data will be permanently deleted.",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
},
{
"type": "frame",
"id": "0HXLh",
"name": "btnRow",
"width": "fill_container",
"gap": 12,
"children": [
{
"type": "frame",
"id": "csIsQ",
"name": "cancelBtn",
"width": "fill_container",
"height": 52,
"fill": "#F4F4F5",
"cornerRadius": 14,
"layout": "vertical",
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "yqSEk",
"name": "cancelText",
"fill": "#18181B",
"content": "Cancel",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "2r5VL",
"name": "deleteBtn",
"width": "fill_container",
"height": 52,
"fill": "#DC2626",
"cornerRadius": 14,
"layout": "vertical",
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "fQlFx",
"name": "deleteText",
"fill": "#FFFFFF",
"content": "Delete",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
}
]
}
]
}
]
} }
] ]
} }

View File

@ -70,7 +70,7 @@
| 1 | `/v1/user/fast_login` | POST | 设备快速登录body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 | | 1 | `/v1/user/fast_login` | POST | 设备快速登录body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 |
| 2 | (保存 token、用户信息到 UserState首次登录打点 register | — | — | | 2 | (保存 token、用户信息到 UserState首次登录打点 register | — | — |
| 3 | `/v1/user/referrer` | POST | 归因上报query: sentinel, asset(uid), portalbody: digest, origin。 | | 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 等。 | | 4 | `/v1/user/common_info` | GET | 获取用户通用信息query: sentinel, asset(uid)。解析 data 写入 UserState并解析 surge 中 lucky 等。 |
**调用处**`lib/core/auth/auth_service.dart`init登录成功后顺序执行 **调用处**`lib/core/auth/auth_service.dart`init登录成功后顺序执行

16
docs/extConfig.md Normal file
View File

@ -0,0 +1,16 @@
{
// 下面的三个字段说明A面都是 falseB面都是 true
"need_wait": false, // 是否展示 Video 菜单
"safe_area": false, // 是否防止截屏;为 true 时启用系统截屏/录屏防护Android FLAG_SECUREiOS 检测)
"lucky": false, // 是否显示第三方支付
"privacy": "https://www.petsheroai.xyz/privacy.html",
"agreement": "https://www.petsheroai.xyz/terms.html",
"items": [ // 图片列表
{
"image": "https://cdn.magieveryai.xyz/cdn/temp/20260305/2029445184939401216.jpg", // 图片URL
"cost": 1, // 需要的积分
"title": "BananaTask", // taskType 任务类型,生图的时候需要的字段
"detail": "parallel_cyberpunk_city_heroes" // ext 生图的时候需要的字段
}
]
}

82
docs/feedback_flow.md Normal file
View File

@ -0,0 +1,82 @@
# 举报 / 反馈流程
本文档说明举报弹窗的提交流程及接口调用顺序。字段映射详见 **petsHeroAI_client_guide.md**
---
## 一、前置校验
点击 Submit 时需校验:
| 校验项 | 要求 | 不满足时 |
|--------|------|----------|
| 文字输入 | 必填 | 提示用户填写描述 |
| 图片选择 | 必选 | 提示用户上传图片 |
---
## 二、接口调用顺序
### 2.1 获取上传 URL
| 接口 | 方法 | 说明 |
|------|------|------|
| `/v1/feedback/upload-presigned-url` | POST | 获取图片上传地址 |
**请求体(映射后)**
- `layer` (fileName):文件名
**响应 data映射后**
- `shed` (uploadUrl):上传 URL用于 PUT 请求
- `hunt` (filePath):文件路径,用于 submit 的 fileUrls
---
### 2.2 上传图片
使用 **PUT** 方式将所选图片上传到上一步返回的 `uploadUrl`
- 不经过代理,直接请求返回的 URL
- 请求体为图片二进制数据
- Content-Type 按 HTTP 规范填写(如 `image/jpeg`
---
### 2.3 提交反馈
| 接口 | 方法 | 说明 |
|------|------|------|
| `/v1/feedback/submit` | POST | 提交举报内容 |
**请求体(映射后)**
- `inventory` (fileUrls):文件路径列表,填入 2.1 返回的 `filePath`
- `cloak` (content):用户输入的文字描述
- `pauldron` (contentType):内容类型,按 HTTP 格式填写(如 `text/plain`
---
## 三、流程概览
```
用户填写描述 + 选择图片
校验:文字 + 图片均必填
POST /v1/feedback/upload-presigned-url
body: { layer: fileName }
获取 uploadUrl、filePath
PUT 图片到 uploadUrl
POST /v1/feedback/submit
body: { inventory: [filePath], cloak: content, pauldron: contentType }
提交成功,关闭弹窗
```

View File

@ -1,34 +1,164 @@
# 支付界面 # 谷歌支付流程
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
# 购买 本文档描述 Android 上 Google Play 内购的完整流程,与 `recharge_screen.dart``GooglePlayPurchaseService``PaymentApi` 实现对应。
从/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
## 1. 流程总览
- **第三方支付开启**`lucky === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay`
- **第三方支付关闭或未登录**:仅 Android 直接调起谷歌支付,不创建订单、不回调 googlepay。
```
用户点击 Buy某商品
├─ 第三方支付开 + 已登录
│ ├─ getPaymentMethods(activityId)
│ ├─ 弹窗选择支付方式
│ ├─ 若选「Google Pay」
│ │ ├─ POST /v1/payment/createPayment → 得到 federation订单 id
│ │ ├─ 调起 Google Play 内购productId = 商品 code/helm
│ │ └─ 支付成功后 POST /v1/payment/googlepay见下文
│ └─ 若选其他方式 → 打开 createPayment 返回的 payUrl
└─ 第三方支付关或未登录
└─ 仅 Android直接调起 Google Play 内购productId = 商品 code无 createPayment、无 googlepay 回调
```
---
## 2. 创建订单(仅第三方 + 选 Google Pay 时)
| 项目 | 说明 |
|------|------|
| 接口 | `POST /v1/payment/createPayment` |
| 入参 | sentinel, asset(userId), warrior(activityId), resource, ceremony选 Google Pay 时 resource/ceremony 含 "GooglePay" |
| 关键响应 | **federation**:订单 id后续 googlepay 回调必传;**convert**:其他支付方式的 payUrl选 Google Pay 时不使用 |
- 当返回的 **federation订单 id不为空**时,继续调起谷歌支付并可在成功后回调 googlepay。
- 当返回的 federation 为空时:可按业务要求重试创建订单(如最多 3 次),仍失败则提示失败。
---
## 3. 调起谷歌支付
| 项目 | 说明 |
|------|------|
| 代码 | `GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)`(所有内购统一使用) |
| productId | 当前商品的 **code**(即接口里的 **helm**),须与 Google Play 后台「产品 ID」完全一致 |
| 成功结果 | 返回的凭据用于构造 googlepay 回调 body见下节 |
**Android** 执行;非 Android 提示 "Google Pay is only available on Android"。
---
## 4. 支付成功后回调:`POST /v1/payment/googlepay`
仅在**第三方支付开 + 选 Google Pay + 已先创建订单**时调用。请求体为 JSON服务端用于校验并落单。
### 4.1 请求体字段body
请求体为四个顶层字段语义与取值来源对应关系如下id / purchaseData / signature / userId 分别对应 federation / merchant / sample / asset
| 请求体字段 | 含义(填入的值) | 客户端取值来源 |
|------------|------------------|----------------|
| **sample** | 签名signature | 谷歌支付成功后,`GooglePlayPurchaseDetails.billingClientPurchase`**signature** |
| **merchant** | 购买凭据 JSONpurchaseData | 同上 **billingClientPurchase****originalJson** |
| **federation** | 支付/订单 idid | 创建订单接口 `createPayment` 返回的 **federation** |
| **asset** | 用户 iduserId | 当前登录用户 id与 createPayment 的 asset 一致) |
### 4.2 示例 body 结构
```json
{
"sample": "YbOntv0sVOsZ5d4F8hIYdPNSMy9a4+5oAsV/...",
"merchant": "{\"orderId\":\"GPA.3327-0087-2324-9960\",\"packageName\":\"com.xxx.xxxx\",\"productId\":\"com.xxx.xxxx599\",\"purchaseTime\":1773305500428,\"purchaseState\":0,\"purchaseToken\":\"...\",\"quantity\":1,\"acknowledged\":false}",
"federation": "1315538320560683421235",
"asset": "135303839048"
}
```
- **federation**:来自 createPayment 的 **federation**(服务端订单 id
- **merchant**:来自 Google Play 的 **originalJson**(整段购买凭据 JSON 字符串)。
- **sample**:来自 Google Play 的 **signature**,服务端用其校验 merchant。
- **asset**:当前用户 id。
### 4.3 与客户端实现对应
- 内购成功后,从 `GooglePlayPurchaseDetails.billingClientPurchase``PurchaseWrapper`)取 **originalJson**、**signature**,分别填入请求体的 **merchant**、**sample**。
- createPayment 返回的 **federation** 填入 **federation**,当前用户 id 填入 **asset**
---
## 5. 获取未核销订单
「未核销」指 Google Play 侧尚未被确认(`isAcknowledged == false`)的购买,通常出现在:上次支付成功后未完成 `completePurchase`、或未成功回调服务端即退出应用。
### 5.1 客户端如何获取
- **方法**`GooglePlayPurchaseService.getUnacknowledgedPurchases()`
- **实现**:仅 Android通过 `InAppPurchaseAndroidPlatformAddition.queryPastPurchases()` 查询本地/缓存的购买,再筛选 `billingClientPurchase.isAcknowledged == false` 的项。
- **返回**`List<UnacknowledgedGooglePayPurchase>`,每项包含 `orderId`Google 订单号)、`productId``payload`purchaseData + signature用于回调 body 的 merchant/sample
### 5.2 典型用法
- **应用启动时**:调用 `getUnacknowledgedPurchases()`,若有未核销订单,补单流程会使用本地保存的「创建订单时的 federation」逐笔回调无保存 federation 的订单会跳过。
- **注意**`queryPastPurchases` 不包含已消耗consumed的商品未确认的消耗型商品会一直在列表中直到被确认或消耗。
---
## 6. 补单流程(自动)
客户端已实现完整补单:拉取未核销订单 → 用本地保存的**创建订单时的 federation** 逐笔回调 googlepay → 服务端返回 `line == 'SUCCESS'``completePurchase` 并刷新用户信息。补单必须使用创建订单时的订单 id不能使用 Google 的 orderId。
### 6.1 创建订单 id 的持久化
- 每次调起内购前都会先 **createPayment** 拿到 **federation**(服务端订单 id
- 在发起 `POST /v1/payment/googlepay` 前,将 **Google orderId → federation** 写入本地SharedPreferences供补单时使用。
- 回调成功或补单成功后删除对应映射。
### 6.2 入口与触发时机
| 项目 | 说明 |
|------|------|
| 方法 | `GooglePlayPurchaseService.runOrderRecovery()` |
| 触发时机 | ① 应用启动:`main()``AuthService.loginComplete.then((_) => runOrderRecovery())`<br>② 进入充值页:`RechargeScreen.initState` 中调用 |
| 前置条件 | 仅 Android已登录`UserState.userId` 非空) |
### 6.3 流程步骤
1. 若非 Android 或未登录,直接返回。
2. 调用 `getUnacknowledgedPurchases()` 获取未核销列表。
3. 对每笔订单:用 **Google orderId** 查本地保存的 **federation**(创建订单时的订单 id若无则跳过该笔并打日志。
4. 用查到的 federation 调用 `POST /v1/payment/googlepay`
5. 若响应成功且 `line == 'SUCCESS'``completePurchase`、删除该笔映射、标记需要刷新。
6. 若有任意一笔补单成功,最后调用 `refreshAccount()`
### 6.4 服务端约定
- 补单请求的 **federation** 与正常回调一致,均为 **createPayment 返回的订单 id**;服务端按订单 id 落单/去重。
- 校验逻辑与正常回调一致sample=签名、merchant=购买凭据),成功时返回 `line: 'SUCCESS'`
---
## 7. 代码位置速查
| 步骤 | 位置 |
|------|------|
| Buy 分支(第三方 vs 直接谷歌) | `recharge_screen.dart``_onBuy``_runThirdPartyPayment` / `_runGooglePay` |
| 创建订单 | `PaymentApi.createPayment`;调用处在 `_createOrderAndOpenUrl` |
| 调起内购并拿凭据 | `GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)` |
| 回调 googlepay | `PaymentApi.googlepay(...)`,在 `_createOrderAndOpenUrl` 内、内购成功后调用 |
| 凭据数据结构 | Android`GooglePlayPurchaseDetails.billingClientPurchase`orderId, originalJson, signature |
| 获取未核销订单 | `GooglePlayPurchaseService.getUnacknowledgedPurchases()`,见第 5 节 |
| 补单流程 | `GooglePlayPurchaseService.runOrderRecovery()`,见第 6 节 |
---
## 8. 小结
1. **创建订单**仅在选择「Google Pay」且第三方支付开启时调用 createPayment拿到 **federation** 后才会调起谷歌支付并回调 googlepay。
2. **调起谷歌支付**productId 固定为当前商品的 **codehelm**,与 Play 后台产品 ID 一致。
3. **回调 googlepay**body 为四字段 **sample**signature、**merchant**purchaseData/originalJson、**federation**(订单 id、**asset**userIdfederation 为空则不回调,可按策略重试创建订单或提示失败。
4. **未核销订单**:通过 `getUnacknowledgedPurchases()` 获取 `isAcknowledged == false` 的购买;每项含 `purchaseDetails` 用于补单成功后 `completePurchase`
5. **补单**`runOrderRecovery()` 在应用启动(登录完成后)与进入充值页时执行;补单使用的 **federation** 为创建订单时的订单 id内购前会持久化 Google orderId→federation补单时按 Google orderId 取回);无保存 federation 的未核销订单会跳过。

28
docs/home.md Normal file
View File

@ -0,0 +1,28 @@
# 主页 UI 显示逻辑
依赖接口:`GET /v1/user/common_info` 返回的 `data.surge`JSON 字符串)解析为 extConfig结构见 [extConfig.md](extConfig.md)。
## extConfig 与顶部分类栏
- **need_wait**(是否展示 Video 菜单)
- 解析自 `surge.need_wait`,写入 `UserState.needShowVideoMenu`
- **仅 need_wait === true**:展示顶部分类栏(对应 Pencil 设计中的 tabRow节点 bK6o6行为见下。
- **其他情况**need_wait === false、未下发或未解析不展示顶部分类栏列表只展示 **extConfig.items** 的图片列表。
## need_wait === true 时的分类与列表
1. **分类栏**
使用图转视频分类接口数据,在分类列表**末尾**增加一个固定分类「pets」。
2. **列表内容**
- 选中**固定分类 pets**:列表展示 **extConfig.items** 的图片列表(不请求视频任务接口)。
- 选中**其他分类**:按原逻辑请求对应分类的视频任务接口,列表展示接口返回的视频任务。
## 数据流简述
1. 登录后请求 `common_info`,在 `AuthService._saveCommonInfoToState` 中解析 `data.surge`
- 写入 `lucky` 等;
- 解析 `need_wait``items`,通过 `UserState.setExtConfig(needShowVideoMenuValue: needWait, items: items)` 写入。
2. 主页 `HomeScreen` 监听 `UserState.needShowVideoMenu``UserState.extConfigItems`,据此决定:
- 是否渲染顶部分类栏;
- 当前列表是来自 extConfig.items 还是来自视频任务接口。
3. extConfig 的 **items** 单项字段:`image``cost``title``detail`用于展示卡片并作为生图参数taskType / ext

View File

@ -9,7 +9,7 @@
``` ```
用户点击 Buy 用户点击 Buy
├─ enable_third_party_payment === true 且已登录 ├─ lucky === true 且已登录
│ │ │ │
│ ├─ getPaymentMethods(activityId) │ ├─ getPaymentMethods(activityId)
│ ├─ 弹窗选择支付方式_PaymentMethodDialog │ ├─ 弹窗选择支付方式_PaymentMethodDialog
@ -23,7 +23,7 @@
│ └─ 否则(其他支付方式) │ └─ 否则(其他支付方式)
│ └─ 打开 createPayment 返回的 payUrlconvert在外部浏览器完成支付 │ └─ 打开 createPayment 返回的 payUrlconvert在外部浏览器完成支付
└─ enable_third_party_payment !== true 或未登录 └─ lucky !== true 或未登录
└─ 仅 Android直接调起 Google Play 内购productId = item.code无 createPayment └─ 仅 Android直接调起 Google Play 内购productId = item.code无 createPayment
``` ```
@ -31,10 +31,10 @@
## 2. 支付分支依据 ## 2. 支付分支依据
- **数据来源**`/v1/user/common_info` 响应中的 **surge**JSON 字符串),解析得到 **enable_third_party_payment**。 - **数据来源**`/v1/user/common_info` 响应中的 **surge**JSON 字符串),解析得到 **lucky**。
- **客户端状态**`UserState.enableThirdPartyPayment`(登录后由 AuthService 从 common_info 写入)。 - **客户端状态**`UserState.enableThirdPartyPayment`(登录后由 AuthService 从 common_info 写入)。
- **分支** - **分支**
- **第三方支付**`enable_third_party_payment == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。 - **第三方支付**`lucky == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。
- **直接谷歌支付**:否则(未开三方或未登录)→ 仅 Android 下直接调起 Google Play 内购,不调 getPaymentMethods / createPayment。 - **直接谷歌支付**:否则(未开三方或未登录)→ 仅 Android 下直接调起 Google Play 内购,不调 getPaymentMethods / createPayment。
--- ---
@ -58,7 +58,7 @@
--- ---
## 4. 第三方支付流程(enable_third_party_payment === true ## 4. 第三方支付流程(lucky === true
### 4.1 步骤顺序 ### 4.1 步骤顺序
@ -80,16 +80,16 @@
- 条件:`_isGooglePay(paymentMethod, subPaymentMethod)` 为 true即 paymentMethod 或 subPaymentMethod 转为小写后等于 `"googlepay"`)。 - 条件:`_isGooglePay(paymentMethod, subPaymentMethod)` 为 true即 paymentMethod 或 subPaymentMethod 转为小写后等于 `"googlepay"`)。
- 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。 - 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。
- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)** - 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**(所有内购统一使用)
- productId 为当前商品的 **code**helm - productId 为当前商品的 **code**helm
- 成功返回 **serverVerificationData**merchant失败/取消返回 null。 - 成功返回 **serverVerificationData**merchant失败/取消返回 null。
- 若有 merchant 且订单 IDfederation存在调用 **PaymentApi.googlepay(merchant, federation, asset)**根据返回提示成功或失败并打点AdjustEvents - 若有 merchant 且订单 IDfederation存在调用 **PaymentApi.googlepay(merchant, federation, asset)**根据返回提示成功或失败并打点AdjustEvents
--- ---
## 5. 直接谷歌支付(enable_third_party_payment !== true ## 5. 直接谷歌支付(lucky !== true
- 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchase(item.code)**,不请求 getPaymentMethods / createPayment。 - 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(item.code)**,不请求 getPaymentMethods / createPayment;凭据可用于后续服务端回调
- 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。 - 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。
- 非 Android提示 "Google Pay is only available on Android"。 - 非 Android提示 "Google Pay is only available on Android"。
@ -122,8 +122,8 @@
| 支付分支与 Buy 入口 | recharge_screen.dart_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay | | 支付分支与 Buy 入口 | recharge_screen.dart_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay |
| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPaymentPaymentApi.getPaymentMethods → _PaymentMethodDialog | | 第三方:获取支付方式 + 弹窗 | _runThirdPartyPaymentPaymentApi.getPaymentMethods → _PaymentMethodDialog |
| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrlcreatePayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) | | 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrlcreatePayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) |
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase | | 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchaseAndReturnData |
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dartlaunchPurchaseAndReturnData / launchPurchasePaymentApi.googlepay | | 谷歌内购 + 凭据上报 | google_play_purchase_service.dartlaunchPurchaseAndReturnDataPaymentApi.googlepay |
| 商品未找到排查 | docs/google_pay_product_not_found.md | | 商品未找到排查 | docs/google_pay_product_not_found.md |
--- ---

View File

@ -1297,7 +1297,7 @@ V2 完整响应体 (解密后):
{ {
"sentinel": "", // app — 应用标识(必填) "sentinel": "", // app — 应用标识(必填)
"asset": "", // userId — 用户ID "asset": "", // userId — 用户ID
"accolade": "", // type — 类型 "accolade": "android_adjust", // type — 类型,传 android_adjust
"portal": "" // pkg — 应用包名(必填) "portal": "" // pkg — 应用包名(必填)
} }
``` ```

View File

@ -9,7 +9,7 @@
1. **设备快速登录**`POST /v1/user/fast_login`,拿到 `userToken` 和用户信息。 1. **设备快速登录**`POST /v1/user/fast_login`,拿到 `userToken` 和用户信息。
2. **保存登录态与用户信息**:保存 token、积分、userId、头像、昵称、国家码等**首次登录**记录为注册日期。 2. **保存登录态与用户信息**:保存 token、积分、userId、头像、昵称、国家码等**首次登录**记录为注册日期。
3. **归因上报**`POST /v1/user/referrer`,将归因数据(如从 Adjust 获取的 digest上报。 3. **归因上报**`POST /v1/user/referrer`,将归因数据(如从 Adjust 获取的 digest上报。
4. **获取用户通用信息**`GET /v1/user/common_info`,拉取通用配置并保存(含 `surge``enable_third_party_payment` 等)。 4. **获取用户通用信息**`GET /v1/user/common_info`,拉取通用配置并保存(含 `surge``lucky` 等)。
登录成功后,后续所有请求需在 Header 中携带 `knight`(即 `User_token` / userToken 登录成功后,后续所有请求需在 Header 中携带 `knight`(即 `User_token` / userToken
@ -64,7 +64,7 @@
|--------|----------------|------| |--------|----------------|------|
| Query | sentinel | 应用标识(必填) | | Query | sentinel | 应用标识(必填) |
| Query | asset | 用户 IDuserId | | Query | asset | 用户 IDuserId |
| Query | accolade | 类型(可选) | | Query | accolade | 类型(可选),传 `android_adjust` |
| Query | portal | 应用包名(必填) | | Query | portal | 应用包名(必填) |
| Body | digest | **归因信息,从 Adjust 获取** | | Body | digest | **归因信息,从 Adjust 获取** |
| Body | origin | 设备 IDdeviceId | | Body | origin | 设备 IDdeviceId |
@ -93,13 +93,13 @@
- 与 fast_login 的 data 结构类似,含 **surge**extConfig、积分、用户信息等。 - 与 fast_login 的 data 结构类似,含 **surge**extConfig、积分、用户信息等。
- **surge**:字符串,为 JSON**先 JSON 解析再使用** - **surge**:字符串,为 JSON**先 JSON 解析再使用**
- 解析后的对象中,包含 **enable_third_party_payment** 等字段,用于控制第三方支付等能力。 - 解析后的对象中,包含 **lucky** 等字段,用于控制第三方支付等能力。
- 其他字段reveal、realm、terminal、navigate 等)按需保存到全局变量或状态。 - 其他字段reveal、realm、terminal、navigate 等)按需保存到全局变量或状态。
### 客户端必做 ### 客户端必做
- 调用 common_info 并将结果**保存到全局/状态**。 - 调用 common_info 并将结果**保存到全局/状态**。
- 对 **surge****JSON decode**,得到对象后读取并保存 **enable_third_party_payment** 等配置。 - 对 **surge****JSON decode**,得到对象后读取并保存 **lucky** 等配置。
--- ---
@ -117,7 +117,7 @@ APP 启动
→ 成功后打日志 → 成功后打日志
4. GET /v1/user/common_infoquery: sentinel, asset 4. GET /v1/user/common_infoquery: sentinel, asset
→ 将结果保存到全局 → 将结果保存到全局
→ 对 data.surge 做 JSON decode保存 enable_third_party_payment → 对 data.surge 做 JSON decode保存 lucky
→ 之后所有请求 Header 带 knight = userToken → 之后所有请求 Header 带 knight = userToken
``` ```

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
</subviews>
<color key="backgroundColor" red="0.04" green="0.04" blue="0.07" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchBackground" width="375" height="812"/>
</resources>
</document>

View File

@ -18,6 +18,12 @@
@import device_info_plus; @import device_info_plus;
#endif #endif
#if __has_include(<flutter_native_splash/FlutterNativeSplashPlugin.h>)
#import <flutter_native_splash/FlutterNativeSplashPlugin.h>
#else
@import flutter_native_splash;
#endif
#if __has_include(<gal/GalPlugin.h>) #if __has_include(<gal/GalPlugin.h>)
#import <gal/GalPlugin.h> #import <gal/GalPlugin.h>
#else #else
@ -36,6 +42,12 @@
@import in_app_purchase_storekit; @import in_app_purchase_storekit;
#endif #endif
#if __has_include(<screen_secure/ScreenSecurePlugin.h>)
#import <screen_secure/ScreenSecurePlugin.h>
#else
@import screen_secure;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>) #if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h> #import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else #else
@ -77,9 +89,11 @@
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry { + (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]]; [AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]];
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
[FlutterNativeSplashPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterNativeSplashPlugin"]];
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]]; [GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]]; [InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@ -47,5 +47,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
</dict> <key>UIStatusBarHidden</key>
<false/>
</dict>
</plist> </plist>

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'core/auth/auth_service.dart';
import 'core/theme/app_colors.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'core/user/user_state.dart'; import 'core/user/user_state.dart';
import 'features/gallery/gallery_screen.dart'; import 'features/gallery/gallery_screen.dart';
@ -36,12 +38,37 @@ class _AppState extends State<App> {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
initialRoute: '/', initialRoute: '/',
builder: (context, child) { builder: (context, child) {
return SafeArea( return Stack(
fit: StackFit.expand,
children: [
SafeArea(
top: true, top: true,
left: false, left: false,
right: false, right: false,
bottom: false, bottom: false,
child: child ?? const SizedBox.shrink(), child: child ?? const SizedBox.shrink(),
),
FutureBuilder<void>(
future: AuthService.loginComplete,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: AbsorbPointer(
child: Container(
color: Colors.black.withValues(alpha: 0.2),
child: const Center(
child: CircularProgressIndicator(
color: AppColors.primary,
),
),
),
),
);
},
),
],
); );
}, },
routes: { routes: {
@ -90,7 +117,7 @@ class _MainScaffold extends StatelessWidget {
body: IndexedStack( body: IndexedStack(
index: currentTab.index, index: currentTab.index,
children: [ children: [
const HomeScreen(), HomeScreen(isActive: currentTab == NavTab.home),
GalleryScreen(isActive: currentTab == NavTab.gallery), GalleryScreen(isActive: currentTab == NavTab.gallery),
ProfileScreen(isActive: currentTab == NavTab.profile), ProfileScreen(isActive: currentTab == NavTab.profile),
], ],

View File

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

View File

@ -62,7 +62,8 @@ void _logLong(String text) {
int chunkIndex = 0; int chunkIndex = 0;
for (final line in lines) { for (final line in lines) {
final lineWithNewline = buffer.isEmpty ? line : '\n$line'; final lineWithNewline = buffer.isEmpty ? line : '\n$line';
if (buffer.length + lineWithNewline.length > _maxLogChunk && buffer.isNotEmpty) { if (buffer.length + lineWithNewline.length > _maxLogChunk &&
buffer.isNotEmpty) {
chunkIndex++; chunkIndex++;
_proxyLog.d('(part $chunkIndex)\n$buffer'); _proxyLog.d('(part $chunkIndex)\n$buffer');
buffer.clear(); buffer.clear();
@ -74,7 +75,8 @@ void _logLong(String text) {
} }
if (buffer.isNotEmpty) { if (buffer.isNotEmpty) {
chunkIndex++; chunkIndex++;
_proxyLog.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString()); _proxyLog
.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString());
} }
} }
@ -252,13 +254,9 @@ class ProxyClient {
final paramsEncoded = jsonEncode(paramsMap); final paramsEncoded = jsonEncode(paramsMap);
final v2BodyEncoded = jsonEncode(v2Body); final v2BodyEncoded = jsonEncode(v2Body);
_log('========== 原始入参 =========='); final logStr =
_log('path: $path'); '========== 原始入参 ===========\npath: $path\nmethod: $method\nqueryParams: $paramsEncoded\nbody(sanctum): ${jsonEncode(sanctum)}';
_log('method: $method'); _log(logStr);
_log('headers: $headersEncoded');
_log('queryParams: $paramsEncoded');
_log('body(sanctum): ${jsonEncode(sanctum)}');
_log('v2Body: $v2BodyEncoded');
final petSpeciesEnc = ApiCrypto.encrypt(path); final petSpeciesEnc = ApiCrypto.encrypt(path);
final powerLevelEnc = ApiCrypto.encrypt(method); final powerLevelEnc = ApiCrypto.encrypt(method);
@ -266,13 +264,6 @@ class ProxyClient {
final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded); final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded);
final loyaltyIndexEnc = ApiCrypto.encrypt(v2BodyEncoded); final loyaltyIndexEnc = ApiCrypto.encrypt(v2BodyEncoded);
_log('========== 加密后 ==========');
_log('pet_species: $petSpeciesEnc');
_log('power_level: $powerLevelEnc');
_log('quest_rank: $questRankEnc');
_log('battle_score: $battleScoreEnc');
_log('loyalty_index: $loyaltyIndexEnc');
final proxyBody = { final proxyBody = {
ProxyKeys.heroClass: ApiConfig.appId, ProxyKeys.heroClass: ApiConfig.appId,
ProxyKeys.petSpecies: petSpeciesEnc, ProxyKeys.petSpecies: petSpeciesEnc,
@ -289,8 +280,6 @@ class ProxyClient {
}; };
final url = '$_baseUrl${ApiConfig.proxyPath}'; final url = '$_baseUrl${ApiConfig.proxyPath}';
_log('========== 请求 URL ==========');
_log('$url');
final response = await http.post( final response = await http.post(
Uri.parse(url), Uri.parse(url),
@ -298,19 +287,17 @@ class ProxyClient {
body: jsonEncode(proxyBody), body: jsonEncode(proxyBody),
); );
_log('========== 响应 ==========');
_log('statusCode: ${response.statusCode}');
_log('body: ${response.body}');
return _parseResponse(response); return _parseResponse(response);
} }
ApiResponse _parseResponse(http.Response response) { ApiResponse _parseResponse(http.Response response) {
try { try {
// Base64 AES-ECB PKCS5 UTF-8 // Base64 AES-ECB PKCS5 UTF-8
var responseLogStr = '========== 响应 ===========';
final decrypted = ApiCrypto.decrypt(response.body); final decrypted = ApiCrypto.decrypt(response.body);
final json = jsonDecode(decrypted) as Map<String, dynamic>; final json = jsonDecode(decrypted) as Map<String, dynamic>;
_log('json: $json'); responseLogStr += jsonEncode(json);
_log(responseLogStr);
// helm=code, rampart=msg, sidekick=data // helm=code, rampart=msg, sidekick=data
final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum']; final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum'];
if (sanctum is Map<String, dynamic>) { if (sanctum is Map<String, dynamic>) {

View File

@ -0,0 +1,38 @@
import '../api_client.dart';
import '../proxy_client.dart';
/// / API
abstract final class FeedbackApi {
static final _client = ApiClient.instance.proxy;
/// URL
/// body: layer (fileName)
/// data: shed (uploadUrl), hunt (filePath)
static Future<ApiResponse> getUploadPresignedUrl({
required String fileName,
}) async {
return _client.request(
path: '/v1/feedback/upload-presigned-url',
method: 'POST',
body: {'layer': fileName},
);
}
///
/// body: inventory (fileUrls), cloak (content), pauldron (contentType)
static Future<ApiResponse> submit({
required List<String> fileUrls,
required String content,
required String contentType,
}) async {
return _client.request(
path: '/v1/feedback/submit',
method: 'POST',
body: {
'inventory': fileUrls,
'cloak': content,
'pauldron': contentType,
},
);
}
}

View File

@ -88,12 +88,27 @@ abstract final class PaymentApi {
); );
} }
/// Google ID ID /// webview
static Future<ApiResponse> getOrderDetail({
required String asset,
required String federation,
}) async {
return _client.request(
path: '/v1/payment/getOrderDetail',
method: 'GET',
queryParams: {
'asset': asset,
'federation': federation,
},
);
}
/// Google body sample(signature)merchant(purchaseData)federation(id)asset(userId) docs/googlepay.md
static Future<ApiResponse> googlepay({ static Future<ApiResponse> googlepay({
required String sample,
required String merchant, required String merchant,
required String federation, required String federation,
required String asset, required String asset,
String? sample,
}) async { }) async {
return _client.request( return _client.request(
path: '/v1/payment/googlepay', path: '/v1/payment/googlepay',
@ -103,10 +118,10 @@ abstract final class PaymentApi {
'asset': asset, 'asset': asset,
}, },
body: { body: {
'sample': sample,
'merchant': merchant, 'merchant': merchant,
'federation': federation, 'federation': federation,
'asset': asset, 'asset': asset,
if (sample != null && sample.isNotEmpty) 'sample': sample,
}, },
); );
} }

View File

@ -97,4 +97,19 @@ abstract final class UserApi {
}, },
); );
} }
///
static Future<ApiResponse> deleteAccount({
required String sentinel,
String? asset,
}) async {
return _client.request(
path: '/v1/user/delete',
method: 'GET',
queryParams: {
'sentinel': sentinel,
if (asset != null) 'asset': asset,
},
);
}
} }

View File

@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:screen_secure/screen_secure.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../adjust/adjust_events.dart'; import '../adjust/adjust_events.dart';
@ -23,6 +24,9 @@ class AuthService {
static Future<void>? _loginFuture; static Future<void>? _loginFuture;
/// UI
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
/// Future await Future /// Future await Future
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value(); static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
@ -52,7 +56,29 @@ class AuthService {
return digest.toString().toUpperCase(); return digest.toString().toUpperCase();
} }
/// common_info surge enable_third_party_payment /// extConfig.safe_area /
static Future<void> _applyScreenSecure(bool? safeArea) async {
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
return;
}
try {
await ScreenSecure.init(screenshotBlock: false, screenRecordBlock: false);
if (safeArea == true) {
await ScreenSecure.enableScreenshotBlock();
await ScreenSecure.enableScreenRecordBlock();
_logMsg('safe_area=true: 已启用截屏/录屏防护');
} else {
await ScreenSecure.disableScreenshotBlock();
await ScreenSecure.disableScreenRecordBlock();
_logMsg('safe_area=$safeArea: 已关闭截屏/录屏防护');
}
} on ScreenSecureException catch (e) {
_logMsg('ScreenSecure 设置失败: ${e.message}');
}
}
/// common_info surge lucky
static void _saveCommonInfoToState(Map<String, dynamic> data) { static void _saveCommonInfoToState(Map<String, dynamic> data) {
final reveal = data['reveal'] as int?; final reveal = data['reveal'] as int?;
if (reveal != null) UserState.setCredits(reveal); if (reveal != null) UserState.setCredits(reveal);
@ -68,8 +94,18 @@ class AuthService {
try { try {
final surge = json.decode(surgeStr) as Map<String, dynamic>?; final surge = json.decode(surgeStr) as Map<String, dynamic>?;
if (surge != null) { if (surge != null) {
final enable = surge['enable_third_party_payment'] as bool?; final enable = surge['lucky'] as bool?;
UserState.setEnableThirdPartyPayment(enable); UserState.setEnableThirdPartyPayment(enable);
// extConfigneed_wait = Video safe_area = items = docs/extConfig.md
final needWait = surge['need_wait'] as bool?;
final safeArea = surge['safe_area'] as bool?;
final items = surge['items'] as List<dynamic>?;
UserState.setExtConfig(
needShowVideoMenuValue: needWait,
safeAreaValue: safeArea,
items: items,
);
_applyScreenSecure(safeArea);
} }
} catch (e) { } catch (e) {
_logMsg('surge JSON 解析失败: $e'); _logMsg('surge JSON 解析失败: $e');
@ -177,6 +213,7 @@ class AuthService {
asset: uid!, asset: uid!,
digest: crest ?? '', digest: crest ?? '',
origin: deviceId, origin: deviceId,
accolade: 'android_adjust',
); );
if (referrerRes.isSuccess) { if (referrerRes.isSuccess) {
_logMsg('referrer 上报成功'); _logMsg('referrer 上报成功');
@ -216,7 +253,10 @@ class AuthService {
_logMsg('init: 异常 $e'); _logMsg('init: 异常 $e');
_logMsg('init: 堆栈 $st'); _logMsg('init: 堆栈 $st');
} finally { } finally {
if (!completer.isCompleted) completer.complete(); if (!completer.isCompleted) {
completer.complete();
isLoginComplete.value = true;
}
} }
} }
} }

View File

@ -0,0 +1,30 @@
import '../api/api_config.dart';
import '../api/services/user_api.dart';
import '../auth/auth_service.dart';
import 'user_state.dart';
/// UserState
///
/// [updateProfile] true avatar userName Profile
Future<void> refreshAccount({bool updateProfile = false}) async {
final uid = UserState.userId.value;
if (uid == null || uid.isEmpty) return;
try {
await AuthService.loginComplete;
final res = await UserApi.getAccount(
sentinel: ApiConfig.appId,
asset: uid,
);
if (!res.isSuccess || res.data == null) return;
final data = res.data as Map<String, dynamic>?;
final credits = data?['reveal'] as int?;
if (credits != null) UserState.setCredits(credits);
if (updateProfile) {
final avatarUrl = data?['realm'] as String?;
UserState.setAvatar(
avatarUrl != null && avatarUrl.isNotEmpty ? avatarUrl : null);
final name = data?['terminal'] as String?;
UserState.setUserName(name != null && name.isNotEmpty ? name : null);
}
} catch (_) {}
}

View File

@ -10,10 +10,17 @@ class UserState {
static final ValueNotifier<String?> userName = ValueNotifier<String?>(null); static final ValueNotifier<String?> userName = ValueNotifier<String?>(null);
/// (navigate / countryCode) /// (navigate / countryCode)
static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null); static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null);
/// common_info surge.enable_third_party_payment /// common_info surge.lucky
static final ValueNotifier<bool?> enableThirdPartyPayment = static final ValueNotifier<bool?> enableThirdPartyPayment =
ValueNotifier<bool?>(null); ValueNotifier<bool?>(null);
/// Video common_info surge.need_wait docs/extConfig.md
static final ValueNotifier<bool?> needShowVideoMenu = ValueNotifier<bool?>(null);
/// common_info surge.safe_area docs/extConfig.md
static final ValueNotifier<bool?> safeArea = ValueNotifier<bool?>(null);
/// extConfig.items common_info surge.items
static final ValueNotifier<List<dynamic>?> extConfigItems = ValueNotifier<List<dynamic>?>(null);
static void setCredits(int? value) { static void setCredits(int? value) {
credits.value = value; credits.value = value;
} }
@ -38,6 +45,16 @@ class UserState {
enableThirdPartyPayment.value = value; enableThirdPartyPayment.value = value;
} }
static void setExtConfig({
bool? needShowVideoMenuValue,
bool? safeAreaValue,
List<dynamic>? items,
}) {
if (needShowVideoMenuValue != null) needShowVideoMenu.value = needShowVideoMenuValue;
if (safeAreaValue != null) safeArea.value = safeAreaValue;
if (items != null) extConfigItems.value = items;
}
static String formatCredits(int? value) { static String formatCredits(int? value) {
if (value == null) return '--'; if (value == null) return '--';
return value.toString().replaceAllMapped( return value.toString().replaceAllMapped(

View File

@ -8,7 +8,6 @@ import '../../core/api/services/image_api.dart';
import '../../core/auth/auth_service.dart'; import '../../core/auth/auth_service.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
import '../../core/user/user_state.dart';
import '../../shared/widgets/top_nav_bar.dart'; import '../../shared/widgets/top_nav_bar.dart';
import 'models/gallery_task_item.dart'; import 'models/gallery_task_item.dart';
@ -139,12 +138,10 @@ class _GalleryScreenState extends State<GalleryScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
appBar: PreferredSize( appBar: const PreferredSize(
preferredSize: const Size.fromHeight(56), preferredSize: Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'Gallery', title: 'My Gallery',
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
), ),
), ),
body: _loading body: _loading
@ -157,7 +154,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
Text( Text(
_error!, _error!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textSecondary), style: const TextStyle(color: AppColors.textSecondary),
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
TextButton( TextButton(
@ -176,10 +173,11 @@ class _GalleryScreenState extends State<GalleryScreen> {
onRefresh: () => _loadTasks(refresh: true), onRefresh: () => _loadTasks(refresh: true),
child: _gridItems.isEmpty && !_loading child: _gridItems.isEmpty && !_loading
? SingleChildScrollView( ? SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics:
const AlwaysScrollableScrollPhysics(),
child: SizedBox( child: SizedBox(
height: constraints.maxHeight - 100, height: constraints.maxHeight - 100,
child: Center( child: const Center(
child: Text( child: Text(
'No images yet', 'No images yet',
style: TextStyle( style: TextStyle(
@ -190,7 +188,8 @@ class _GalleryScreenState extends State<GalleryScreen> {
), ),
) )
: GridView.builder( : GridView.builder(
physics: const AlwaysScrollableScrollPhysics(), physics:
const AlwaysScrollableScrollPhysics(),
controller: _scrollController, controller: _scrollController,
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
AppSpacing.screenPadding, AppSpacing.screenPadding,
@ -206,8 +205,8 @@ class _GalleryScreenState extends State<GalleryScreen> {
mainAxisSpacing: AppSpacing.xl, mainAxisSpacing: AppSpacing.xl,
crossAxisSpacing: AppSpacing.xl, crossAxisSpacing: AppSpacing.xl,
), ),
itemCount: itemCount: _gridItems.length +
_gridItems.length + (_loadingMore ? 1 : 0), (_loadingMore ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= _gridItems.length) { if (index >= _gridItems.length) {
return const Center( return const Center(
@ -265,17 +264,20 @@ class _GalleryCard extends StatelessWidget {
color: AppColors.surfaceAlt, color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.border, width: 1), border: Border.all(color: AppColors.border, width: 1),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowMedium, color: AppColors.shadowMedium,
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: mediaItem.imageUrl != null child: mediaItem.isVideo
? _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!)
: (mediaItem.imageUrl != null &&
mediaItem.imageUrl!.isNotEmpty)
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: mediaItem.imageUrl!, imageUrl: mediaItem.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -286,7 +288,7 @@ class _GalleryCard extends StatelessWidget {
color: AppColors.surfaceAlt, color: AppColors.surfaceAlt,
), ),
) )
: _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!), : Container(color: AppColors.surfaceAlt),
), ),
); );
}, },

View File

@ -1,14 +1,16 @@
/// digitize=URLreconfigure=URL /// reconfigure=imgUrl reconnect(imgType) 0=1=
class GalleryMediaItem { class GalleryMediaItem {
const GalleryMediaItem({ const GalleryMediaItem({
this.imageUrl, this.imageUrl,
this.videoUrl, this.videoUrl,
}) : assert(imageUrl != null || videoUrl != null); }) : assert(imageUrl != null || videoUrl != null);
final String? imageUrl; // digitize final String? imageUrl;
final String? videoUrl; // reconfigure - final String? videoUrl; //
bool get isVideo => videoUrl != null && (imageUrl == null || imageUrl!.isEmpty); /// reconnect==0 1
bool get isVideo =>
videoUrl != null && (imageUrl == null || imageUrl!.isEmpty);
} }
/// V2 /// V2
@ -30,20 +32,49 @@ class GalleryTaskItem {
factory GalleryTaskItem.fromJson(Map<String, dynamic> json) { factory GalleryTaskItem.fromJson(Map<String, dynamic> json) {
final downsample = json['downsample'] as List<dynamic>? ?? []; final downsample = json['downsample'] as List<dynamic>? ?? [];
final items = <GalleryMediaItem>[]; final items = <GalleryMediaItem>[];
for (final item in downsample) { // downsample的array[0]
if (item is String) { if (downsample.isNotEmpty) {
items.add(GalleryMediaItem(imageUrl: item)); final first = downsample[0];
} else if (item is Map<String, dynamic>) { if (first is String) {
final digitize = item['digitize'] as String?; items.add(GalleryMediaItem(imageUrl: first));
final reconfigure = item['reconfigure'] as String?; } else if (first is Map<String, dynamic>) {
// digitize=, reconfigure= final reconfigure = first['reconfigure'] as String?;
if (digitize != null && digitize.isNotEmpty) { if (reconfigure != null && reconfigure.isNotEmpty) {
items.add(GalleryMediaItem(imageUrl: digitize)); final reconnect = first['reconnect'];
} else if (reconfigure != null && reconfigure.isNotEmpty) { final imgType = reconnect is int
? reconnect
: reconnect is num
? reconnect.toInt()
: 1;
if (imgType == 2) {
items.add(GalleryMediaItem(videoUrl: reconfigure)); items.add(GalleryMediaItem(videoUrl: reconfigure));
} else {
items.add(GalleryMediaItem(imageUrl: reconfigure));
} }
} }
} }
//
}
// for (final item in downsample) {
// if (item is String) {
// items.add(GalleryMediaItem(imageUrl: item));
// } else if (item is Map<String, dynamic>) {
// final reconfigure = item['reconfigure'] as String?;
// if (reconfigure == null || reconfigure.isEmpty) continue;
// // reconnect(imgType): 0=1=
// final reconnect = item['reconnect'];
// final imgType = reconnect is int
// ? reconnect
// : reconnect is num
// ? reconnect.toInt()
// : 1;
// if (imgType == 2) {
// items.add(GalleryMediaItem(videoUrl: reconfigure));
// } else {
// items.add(GalleryMediaItem(imageUrl: reconfigure));
// }
// }
// }
return GalleryTaskItem( return GalleryTaskItem(
taskId: (json['tree'] as num?)?.toInt() ?? 0, taskId: (json['tree'] as num?)?.toInt() ?? 0,
state: json['listing']?.toString() ?? '', state: json['listing']?.toString() ?? '',

View File

@ -18,7 +18,7 @@ import '../../features/gallery/models/gallery_task_item.dart';
import '../../shared/tab_selector_scope.dart'; import '../../shared/tab_selector_scope.dart';
import '../../shared/widgets/bottom_nav_bar.dart'; import '../../shared/widgets/bottom_nav_bar.dart';
/// Progress states: 1= 2= 3= 4= 5= 6= /// Progress states: 1=Queued 2=Processing 3=Completed 4=Timeout 5=Error 6=Aborted
/// Progress bar has 3 stages; states 36 are stage 3. /// Progress bar has 3 stages; states 36 are stage 3.
const _stateLabels = <int, String>{ const _stateLabels = <int, String>{
1: 'Queued', 1: 'Queued',
@ -38,27 +38,24 @@ double _progressForState(int? state) {
} }
/// Build GalleryMediaItem from /v1/image/progress response (data = sidekick). /// Build GalleryMediaItem from /v1/image/progress response (data = sidekick).
/// curate[].reconfigure = result URL; for img2video use as videoUrl. /// curate[].reconfigure = imgUrl, reconnect(imgType): 2=1=
GalleryMediaItem? _mediaItemFromProgressData(Map<String, dynamic> data) { GalleryMediaItem? _mediaItemFromProgressData(Map<String, dynamic> data) {
final curate = data['curate'] as List<dynamic>?; final curate = data['curate'] as List<dynamic>?;
if (curate == null || curate.isEmpty) return null; if (curate == null || curate.isEmpty) return null;
final first = curate.first; final first = curate.first;
if (first is! Map<String, dynamic>) return null; if (first is! Map<String, dynamic>) return null;
final reconfigure = first['reconfigure'] as String?; final reconfigure = first['reconfigure'] as String?;
final digitize = first['digitize'] as String?; if (reconfigure == null || reconfigure.isEmpty) return null;
final videoUrl = reconfigure?.isNotEmpty == true final reconnect = first['reconnect'];
? reconfigure final imgType = reconnect is int
: digitize?.isNotEmpty == true ? reconnect
? digitize : reconnect is num
: null; ? reconnect.toInt()
final imageUrl = digitize?.isNotEmpty == true ? digitize : null; : 1;
if (videoUrl != null) { if (imgType == 2) {
return GalleryMediaItem(videoUrl: videoUrl, imageUrl: imageUrl); return GalleryMediaItem(videoUrl: reconfigure);
} }
if (imageUrl != null) { return GalleryMediaItem(imageUrl: reconfigure);
return GalleryMediaItem(imageUrl: imageUrl);
}
return null;
} }
/// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label) /// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label)
@ -75,6 +72,7 @@ class GenerateProgressScreen extends StatefulWidget {
class _GenerateProgressScreenState extends State<GenerateProgressScreen> { class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
int? _state; int? _state;
Timer? _pollTimer; Timer? _pollTimer;
bool _isFetching = false;
double get _progress => _progressForState(_state); double get _progress => _progressForState(_state);
@ -107,7 +105,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
Future<void> _startPolling() async { Future<void> _startPolling() async {
await AuthService.loginComplete; await AuthService.loginComplete;
_pollTimer = Timer.periodic( _pollTimer = Timer.periodic(
const Duration(seconds: 1), const Duration(seconds: 5),
(_) => _fetchProgress(), (_) => _fetchProgress(),
); );
_fetchProgress(); _fetchProgress();
@ -115,7 +113,9 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
Future<void> _fetchProgress() async { Future<void> _fetchProgress() async {
if (widget.taskId == null) return; if (widget.taskId == null) return;
if (_isFetching) return;
_isFetching = true;
try { try {
final res = await ImageApi.getProgress( final res = await ImageApi.getProgress(
sentinel: ApiConfig.appId, sentinel: ApiConfig.appId,
@ -144,8 +144,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
asset: userId, asset: userId,
); );
if (accountRes.isSuccess && accountRes.data != null) { if (accountRes.isSuccess && accountRes.data != null) {
final accountData = final accountData = accountRes.data as Map<String, dynamic>?;
accountRes.data as Map<String, dynamic>?;
final credits = accountData?['reveal'] as int?; final credits = accountData?['reveal'] as int?;
if (credits != null) { if (credits != null) {
UserState.setCredits(credits); UserState.setCredits(credits);
@ -167,11 +166,14 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
break; break;
} }
} catch (_) {} } catch (_) {}
finally {
_isFetching = false;
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final labelText = _stateLabels[_state] ?? '队列中'; final labelText = _stateLabels[_state] ?? 'Queued';
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
@ -191,7 +193,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
_VideoPreview(imagePath: widget.imagePath), _VideoPreview(imagePath: widget.imagePath),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),
Text( Text(
'Video generation may take some time. Please wait patiently.', 'Generating may take some time. Please wait patiently.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary, color: AppColors.textSecondary,
@ -214,8 +216,9 @@ class _VideoPreview extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasImage = final hasImage = imagePath != null &&
imagePath != null && imagePath!.isNotEmpty && File(imagePath!).existsSync(); imagePath!.isNotEmpty &&
File(imagePath!).existsSync();
return Container( return Container(
height: 360, height: 360,
@ -235,14 +238,14 @@ class _VideoPreview extends StatelessWidget {
: Column( : Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
LucideIcons.film, LucideIcons.film,
size: 64, size: 64,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
Text( Text(
'Video Preview', 'Previewing',
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@ -253,7 +256,7 @@ class _VideoPreview extends StatelessWidget {
} }
} }
/// Progress bar: 3 stages. Label Eghqc shows state (|||||) /// Progress bar: 3 stages. Label shows state (Queued|Processing|Completed|Timeout|Error|Aborted)
class _ProgressSection extends StatelessWidget { class _ProgressSection extends StatelessWidget {
const _ProgressSection({required this.progress, required this.label}); const _ProgressSection({required this.progress, required this.label});
@ -276,8 +279,7 @@ class _ProgressSection extends StatelessWidget {
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final fillWidth = final fillWidth = constraints.maxWidth * progress.clamp(0.0, 1.0);
constraints.maxWidth * progress.clamp(0.0, 1.0);
return Stack( return Stack(
children: [ children: [
Container( Container(

View File

@ -13,13 +13,12 @@ import '../../core/log/app_logger.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart'; import '../../core/theme/app_typography.dart';
import '../../core/user/account_refresh.dart';
import '../../core/user/user_state.dart'; import '../../core/user/user_state.dart';
import '../../features/home/models/task_item.dart'; import '../../features/home/models/task_item.dart';
import '../../shared/widgets/top_nav_bar.dart'; import '../../shared/widgets/top_nav_bar.dart';
import '../../core/api/api_config.dart';
import '../../core/api/services/image_api.dart'; import '../../core/api/services/image_api.dart';
import '../../core/api/services/user_api.dart';
/// Generate Video screen - matches Pencil mmLB5 /// Generate Video screen - matches Pencil mmLB5
class GenerateVideoScreen extends StatefulWidget { class GenerateVideoScreen extends StatefulWidget {
@ -53,9 +52,15 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
String get _heatmap => String get _heatmap =>
_selectedResolution == _Resolution.p480 ? '480p' : '720p'; _selectedResolution == _Resolution.p480 ? '480p' : '720p';
bool get _hasVideo {
final url = widget.task?.previewVideoUrl;
return url != null && url.isNotEmpty;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
refreshAccount();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
GenerateVideoScreen._log.d('opened with task: ${widget.task}'); GenerateVideoScreen._log.d('opened with task: ${widget.task}');
}); });
@ -173,12 +178,13 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
throw Exception('User not logged in'); throw Exception('User not logged in');
} }
final templateName = widget.task?.templateName ?? '';
final createRes = await ImageApi.createTask( final createRes = await ImageApi.createTask(
asset: userId, asset: userId,
guild: filePath, guild: filePath,
allowance: false, allowance: false,
cipher: widget.task?.taskType ?? '', cipher: widget.task?.taskType ?? '',
congregation: widget.task?.templateName ?? '', congregation: templateName == 'BananaTask' ? null : templateName,
heatmap: _heatmap, heatmap: _heatmap,
ext: widget.task?.ext, ext: widget.task?.ext,
); );
@ -192,17 +198,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
final taskId = taskData?['tree']; final taskId = taskData?['tree'];
// //
final accountRes = await UserApi.getAccount( await refreshAccount();
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);
}
}
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pushReplacementNamed( Navigator.of(context).pushReplacementNamed(
@ -234,11 +230,9 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(56), preferredSize: const Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'Generate Video', title: 'Generate',
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
showBackButton: true, showBackButton: true,
onBack: () => Navigator.of(context).pop(), onBack: () => Navigator.of(context).pop(),
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
), ),
), ),
body: Column( body: Column(
@ -280,12 +274,12 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_hasVideo)
_ResolutionToggle( _ResolutionToggle(
selected: _selectedResolution, selected: _selectedResolution,
onChanged: (r) => onChanged: (r) => setState(() => _selectedResolution = r),
setState(() => _selectedResolution = r),
), ),
const SizedBox(height: AppSpacing.xxl), if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
_GenerateButton( _GenerateButton(
onGenerate: _onGenerateButtonTap, onGenerate: _onGenerateButtonTap,
isLoading: _isGenerating, isLoading: _isGenerating,
@ -329,7 +323,7 @@ class _CreditsCard extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface), const Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
const SizedBox(width: AppSpacing.md), const SizedBox(width: AppSpacing.md),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -428,13 +422,35 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isReady = final isReady = _controller != null && _controller!.value.isInitialized;
_controller != null && _controller!.value.isInitialized;
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
final hasImage = final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
// Aspect ratio: from video when ready, else 16:9 placeholder // =
if (!hasVideo && hasImage) {
return LayoutBuilder(
builder: (context, constraints) {
return Container(
width: constraints.maxWidth,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.border,
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: _AspectRatioImage(
imageUrl: widget.imageUrl!,
maxWidth: constraints.maxWidth,
),
);
},
);
}
// aspect ratio 16:9
final aspectRatio = isReady && final aspectRatio = isReady &&
_controller!.value.size.width > 0 && _controller!.value.size.width > 0 &&
_controller!.value.size.height > 0 _controller!.value.size.height > 0
@ -479,13 +495,15 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
fit: BoxFit.contain, fit: BoxFit.contain,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
placeholder: (_, __) => _LoadingOverlay(isLoading: true), placeholder: (_, __) =>
errorWidget: (_, __, ___) => _LoadingOverlay(isLoading: false), const _LoadingOverlay(isLoading: true),
errorWidget: (_, __, ___) =>
const _LoadingOverlay(isLoading: false),
) )
else else
_LoadingOverlay(isLoading: false), const _LoadingOverlay(isLoading: false),
if (hasVideo && !isReady) if (hasVideo && !isReady)
Positioned.fill( const Positioned.fill(
child: _LoadingOverlay(isLoading: true), child: _LoadingOverlay(isLoading: true),
), ),
], ],
@ -496,6 +514,95 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
} }
} }
/// =
class _AspectRatioImage extends StatefulWidget {
const _AspectRatioImage({
required this.imageUrl,
required this.maxWidth,
});
final String imageUrl;
final double maxWidth;
@override
State<_AspectRatioImage> createState() => _AspectRatioImageState();
}
class _AspectRatioImageState extends State<_AspectRatioImage> {
double? _aspectRatio;
ImageStream? _stream;
late ImageStreamListener _listener;
@override
void initState() {
super.initState();
_listener = ImageStreamListener(_onImageLoaded, onError: _onImageError);
_resolveImage();
}
@override
void didUpdateWidget(covariant _AspectRatioImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) {
_stream?.removeListener(_listener);
_aspectRatio = null;
_resolveImage();
}
}
void _resolveImage() {
final provider = CachedNetworkImageProvider(widget.imageUrl);
_stream = provider.resolve(const ImageConfiguration());
_stream!.addListener(_listener);
}
void _onImageLoaded(ImageInfo info, bool sync) {
if (!mounted) return;
final w = info.image.width.toDouble();
final h = info.image.height.toDouble();
if (w > 0 && h > 0) {
setState(() => _aspectRatio = w / h);
}
}
void _onImageError(dynamic exception, StackTrace? stackTrace) {
if (mounted) setState(() => _aspectRatio = 1);
}
@override
void dispose() {
_stream?.removeListener(_listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
final ratio = _aspectRatio ?? 1;
final height = widget.maxWidth / ratio;
return SizedBox(
width: widget.maxWidth,
height: height,
child: CachedNetworkImage(
imageUrl: widget.imageUrl,
fit: BoxFit.contain,
width: widget.maxWidth,
height: height,
placeholder: (_, __) => SizedBox(
width: widget.maxWidth,
height: widget.maxWidth,
child: const _LoadingOverlay(isLoading: true),
),
errorWidget: (_, __, ___) => SizedBox(
width: widget.maxWidth,
height: widget.maxWidth,
child: const _LoadingOverlay(isLoading: false),
),
),
);
}
}
class _LoadingOverlay extends StatelessWidget { class _LoadingOverlay extends StatelessWidget {
const _LoadingOverlay({this.isLoading = true}); const _LoadingOverlay({this.isLoading = true});
@ -515,7 +622,7 @@ class _LoadingOverlay extends StatelessWidget {
color: AppColors.primary, color: AppColors.primary,
), ),
) )
: Icon( : const Icon(
LucideIcons.video, LucideIcons.video,
size: 48, size: 48,
color: AppColors.textMuted, color: AppColors.textMuted,
@ -590,9 +697,8 @@ class _ResolutionOption extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? AppColors.primary : AppColors.surfaceAlt, color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: isSelected border:
? null isSelected ? null : Border.all(color: AppColors.border, width: 1),
: Border.all(color: AppColors.border, width: 1),
boxShadow: isSelected boxShadow: isSelected
? [ ? [
BoxShadow( BoxShadow(
@ -661,7 +767,7 @@ class _GenerateButton extends StatelessWidget {
) )
else else
Text( Text(
'Generate Video', 'Generate',
style: AppTypography.bodyMedium.copyWith( style: AppTypography.bodyMedium.copyWith(
color: AppColors.surface, color: AppColors.surface,
), ),
@ -680,7 +786,7 @@ class _GenerateButton extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( const Icon(
LucideIcons.sparkles, LucideIcons.sparkles,
size: 16, size: 16,
color: AppColors.surface, color: AppColors.surface,

View File

@ -6,9 +6,11 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:gal/gal.dart'; import 'package:gal/gal.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../core/api/services/feedback_api.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart'; import '../../core/theme/app_typography.dart';
@ -130,13 +132,13 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(56), preferredSize: const Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'Video Ready', title: 'Ready',
showBackButton: true, showBackButton: true,
onBack: () => Navigator.of(context).pop(), onBack: () => Navigator.of(context).pop(),
), ),
), ),
body: widget.mediaItem == null body: widget.mediaItem == null
? Center( ? const Center(
child: Text( child: Text(
'No media', 'No media',
style: TextStyle(color: AppColors.textSecondary), style: TextStyle(color: AppColors.textSecondary),
@ -192,7 +194,10 @@ class _MediaDisplay extends StatelessWidget {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: videoUrl != null && videoController != null child: Stack(
fit: StackFit.expand,
children: [
videoUrl != null && videoController != null
? _VideoPlayer( ? _VideoPlayer(
controller: videoController!, controller: videoController!,
) )
@ -208,7 +213,7 @@ class _MediaDisplay extends StatelessWidget {
? Container( ? Container(
color: AppColors.textPrimary, color: AppColors.textPrimary,
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: const Text(
'Load failed', 'Load failed',
style: TextStyle(color: AppColors.surface), style: TextStyle(color: AppColors.surface),
), ),
@ -221,6 +226,40 @@ class _MediaDisplay extends StatelessWidget {
errorWidget: (_, __, ___) => _Placeholder(), errorWidget: (_, __, ___) => _Placeholder(),
) )
: _Placeholder(), : _Placeholder(),
Positioned(
top: 16,
right: 20,
child: GestureDetector(
onTap: () => _ReportDialog.show(context),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Icon(
LucideIcons.triangle_alert,
size: 24,
color: AppColors.surface,
),
),
),
),
Positioned(
bottom: 20,
right: 20,
child: Text(
'PetsHero AI',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.surface.withValues(alpha: 0.6),
fontSize: 12,
),
),
),
],
),
), ),
); );
} }
@ -263,11 +302,15 @@ class _VideoPlayerState extends State<_VideoPlayer> {
); );
} }
return Stack( return GestureDetector(
fit: StackFit.expand, onTap: () {
alignment: Alignment.center, if (widget.controller.value.isPlaying) {
children: [ widget.controller.pause();
FittedBox( } else {
widget.controller.play();
}
},
child: FittedBox(
fit: BoxFit.contain, fit: BoxFit.contain,
child: SizedBox( child: SizedBox(
width: widget.controller.value.size.width > 0 width: widget.controller.value.size.width > 0
@ -279,33 +322,6 @@ class _VideoPlayerState extends State<_VideoPlayer> {
child: VideoPlayer(widget.controller), child: VideoPlayer(widget.controller),
), ),
), ),
Center(
child: GestureDetector(
onTap: () {
if (widget.controller.value.isPlaying) {
widget.controller.pause();
} else {
widget.controller.play();
}
},
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(
widget.controller.value.isPlaying
? LucideIcons.pause
: LucideIcons.play,
size: 32,
color: AppColors.textPrimary,
),
),
),
),
],
); );
} }
} }
@ -315,23 +331,13 @@ class _Placeholder extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
color: AppColors.textPrimary, color: AppColors.textPrimary,
child: Column( alignment: Alignment.center,
mainAxisAlignment: MainAxisAlignment.center, child: Text(
children: [ 'Your work is ready',
Icon(
LucideIcons.play,
size: 72,
color: AppColors.surface.withValues(alpha: 0.5),
),
const SizedBox(height: AppSpacing.lg),
Text(
'Your video is ready',
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface.withValues(alpha: 0.6), color: AppColors.surface.withValues(alpha: 0.6),
), ),
), ),
],
),
); );
} }
} }
@ -377,7 +383,8 @@ class _DownloadButton extends StatelessWidget {
), ),
) )
else else
Icon(LucideIcons.download, size: 20, color: AppColors.surface), const Icon(LucideIcons.download,
size: 20, color: AppColors.surface),
const SizedBox(width: AppSpacing.md), const SizedBox(width: AppSpacing.md),
Text( Text(
saving ? 'Saving...' : 'Save to Album', saving ? 'Saving...' : 'Save to Album',
@ -392,6 +399,292 @@ class _DownloadButton extends StatelessWidget {
} }
} }
/// Report dialog - matches Pencil 5qKUB
class _ReportDialog extends StatefulWidget {
const _ReportDialog({required this.parentContext});
final BuildContext parentContext;
static void show(BuildContext context) {
showDialog<void>(
context: context,
barrierColor: Colors.black54,
builder: (_) => _ReportDialog(parentContext: context),
);
}
@override
State<_ReportDialog> createState() => _ReportDialogState();
}
class _ReportDialogState extends State<_ReportDialog> {
final _controller = TextEditingController();
File? _pickedImage;
final _picker = ImagePicker();
bool _submitting = false;
String? _errorText;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _pickImage() async {
final x = await _picker.pickImage(source: ImageSource.gallery);
if (x != null && mounted) {
setState(() => _pickedImage = File(x.path));
}
}
Future<void> _submit() async {
final content = _controller.text.trim();
if (content.isEmpty) {
setState(() => _errorText = 'Please describe the issue');
return;
}
if (_pickedImage == null) {
setState(() => _errorText = 'Please upload an image');
return;
}
setState(() {
_errorText = null;
_submitting = true;
});
try {
final file = _pickedImage!;
final ext = file.path.split('.').last.toLowerCase();
final contentType = ext == 'png'
? 'image/png'
: ext == 'gif'
? 'image/gif'
: 'image/jpeg';
final fileName =
'feedback_${DateTime.now().millisecondsSinceEpoch}.$ext';
final presignedRes =
await FeedbackApi.getUploadPresignedUrl(fileName: fileName);
if (!presignedRes.isSuccess || presignedRes.data == null) {
throw Exception(
presignedRes.msg.isNotEmpty ? presignedRes.msg : 'Failed to get upload URL');
}
final data = presignedRes.data as Map<String, dynamic>;
final uploadUrl = data['shed'] as String?;
final filePath = data['hunt'] as String?;
if (uploadUrl == null ||
uploadUrl.isEmpty ||
filePath == null ||
filePath.isEmpty) {
throw Exception('Invalid presigned URL response');
}
final bytes = await file.readAsBytes();
final uploadResponse = await http.put(
Uri.parse(uploadUrl),
headers: {'Content-Type': contentType},
body: bytes,
);
if (uploadResponse.statusCode < 200 ||
uploadResponse.statusCode >= 300) {
throw Exception('Upload failed: ${uploadResponse.statusCode}');
}
final submitRes = await FeedbackApi.submit(
fileUrls: [filePath],
content: content,
contentType: 'text/plain',
);
if (!submitRes.isSuccess) {
throw Exception(
submitRes.msg.isNotEmpty ? submitRes.msg : 'Failed to submit report');
}
if (!mounted) return;
Navigator.of(context).pop();
if (widget.parentContext.mounted) {
ScaffoldMessenger.of(widget.parentContext).showSnackBar(
const SnackBar(
content: Text('Report submitted'),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (mounted) {
setState(() => _errorText = e.toString().replaceAll('Exception: ', ''));
}
} finally {
if (mounted) {
setState(() => _submitting = false);
}
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24),
child: Container(
width: 342,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Report',
style: AppTypography.bodyMedium.copyWith(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const SizedBox(
width: 40,
height: 40,
child: Icon(
LucideIcons.x,
size: 24,
color: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: 20),
Container(
height: 120,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: TextField(
controller: _controller,
maxLines: null,
decoration: const InputDecoration(
hintText: 'Describe the issue...',
hintStyle: TextStyle(color: AppColors.textMuted),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
),
),
const SizedBox(height: 20),
GestureDetector(
onTap: _pickImage,
child: Container(
height: 120,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFD4D4D8),
width: 2,
),
),
child: _pickedImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(
_pickedImage!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.image_plus,
size: 32,
color: AppColors.textMuted,
),
const SizedBox(height: 8),
Text(
'Tap to upload image',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
],
),
),
),
if (_errorText != null) ...[
const SizedBox(height: 12),
Text(
_errorText!,
style: TextStyle(
color: AppColors.accentOrange,
fontSize: 14,
),
),
],
const SizedBox(height: 20),
GestureDetector(
onTap: _submitting ? null : _submit,
child: Container(
height: 52,
decoration: BoxDecoration(
color: _submitting
? AppColors.textMuted
: const Color(0xFF7C3AED),
borderRadius: BorderRadius.circular(14),
),
alignment: Alignment.center,
child: _submitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.surface,
),
)
: Text(
'Submit',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.surface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
}
class _ShareButton extends StatelessWidget { class _ShareButton extends StatelessWidget {
const _ShareButton({required this.onShare}); const _ShareButton({required this.onShare});
@ -407,18 +700,18 @@ class _ShareButton extends StatelessWidget {
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border), border: Border.all(color: AppColors.border),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowLight, color: AppColors.shadowLight,
blurRadius: 6, blurRadius: 6,
offset: const Offset(0, 2), offset: Offset(0, 2),
), ),
], ],
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(LucideIcons.share_2, size: 20, color: AppColors.primary), const Icon(LucideIcons.share_2, size: 20, color: AppColors.primary),
const SizedBox(width: AppSpacing.md), const SizedBox(width: AppSpacing.md),
Text( Text(
'Share', 'Share',

View File

@ -1,18 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/api/services/image_api.dart'; import '../../core/api/services/image_api.dart';
import '../../core/user/user_state.dart';
import '../../core/auth/auth_service.dart'; import '../../core/auth/auth_service.dart';
import '../../core/user/account_refresh.dart';
import '../../core/user/user_state.dart';
import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_spacing.dart';
import '../../shared/widgets/top_nav_bar.dart'; import '../../shared/widgets/top_nav_bar.dart';
import 'models/category_item.dart'; import 'models/category_item.dart';
import 'models/ext_config_item.dart';
import 'models/task_item.dart'; import 'models/task_item.dart';
import 'widgets/home_tab_row.dart'; import 'widgets/home_tab_row.dart';
import 'widgets/video_card.dart'; import 'widgets/video_card.dart';
/// AI Video App home screen - tab Grid /// pets id extConfig.items
const int kExtCategoryId = -1;
/// AI Video App home screen - tab Grid extConfig.items
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key, this.isActive = true});
final bool isActive;
@override @override
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
@ -29,7 +36,88 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
UserState.needShowVideoMenu.addListener(_onExtConfigChanged);
UserState.extConfigItems.addListener(_onExtConfigChanged);
AuthService.isLoginComplete.addListener(_onExtConfigChanged);
_loadCategories(); _loadCategories();
if (widget.isActive) refreshAccount();
}
@override
void dispose() {
UserState.needShowVideoMenu.removeListener(_onExtConfigChanged);
UserState.extConfigItems.removeListener(_onExtConfigChanged);
AuthService.isLoginComplete.removeListener(_onExtConfigChanged);
super.dispose();
}
void _onExtConfigChanged() {
if (mounted) setState(() {});
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) {
refreshAccount();
}
}
/// need_wait === true Video false/null/
bool get _showVideoMenu =>
UserState.needShowVideoMenu.value == true;
List<ExtConfigItem> get _parsedExtItems {
final raw = UserState.extConfigItems.value;
if (raw == null || raw.isEmpty) return [];
return raw
.map((e) => e is Map<String, dynamic>
? ExtConfigItem.fromJson(e)
: ExtConfigItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
/// //extConfig
///
bool get _isListLoading {
if (!AuthService.isLoginComplete.value) return false;
if (_showVideoMenu) {
if (_categoriesLoading) return true;
if (_selectedCategory?.id == kExtCategoryId) {
return UserState.extConfigItems.value == null;
}
return _tasksLoading;
}
return UserState.extConfigItems.value == null;
}
///
bool get _showCategoriesLoading =>
AuthService.isLoginComplete.value && _categoriesLoading;
/// need_wait false extConfig.itemstrue extConfig.items _tasks
List<TaskItem> get _displayTasks {
if (!_showVideoMenu) {
return _extItemsToTaskItems(_parsedExtItems);
}
if (_selectedCategory?.id == kExtCategoryId) {
return _extItemsToTaskItems(_parsedExtItems);
}
return _tasks;
}
static List<TaskItem> _extItemsToTaskItems(List<ExtConfigItem> items) {
return items
.map((e) => TaskItem(
templateName: e.title,
title: e.title,
previewImageUrl: e.image,
previewVideoUrl: null,
taskType: e.title,
ext: e.detail,
credits480p: e.cost,
))
.toList();
} }
Future<void> _loadCategories() async { Future<void> _loadCategories() async {
@ -42,10 +130,20 @@ class _HomeScreenState extends State<HomeScreen> {
final list = (res.data as List) final list = (res.data as List)
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>)) .map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
if (UserState.needShowVideoMenu.value == true) {
list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null));
}
setState(() { setState(() {
_categories = list; _categories = list;
_selectedCategory = list.isNotEmpty ? list.first : null; _selectedCategory = list.isNotEmpty ? list.first : null;
if (_selectedCategory != null) _loadTasks(_selectedCategory!.id); if (_selectedCategory != null) {
if (_selectedCategory!.id == kExtCategoryId) {
_tasks = [];
_tasksLoading = false;
} else {
_loadTasks(_selectedCategory!.id);
}
}
}); });
} else { } else {
setState(() => _categories = []); setState(() => _categories = []);
@ -75,8 +173,15 @@ class _HomeScreenState extends State<HomeScreen> {
void _onTabChanged(CategoryItem c) { void _onTabChanged(CategoryItem c) {
setState(() => _selectedCategory = c); setState(() => _selectedCategory = c);
if (c.id == kExtCategoryId) {
setState(() {
_tasks = [];
_tasksLoading = false;
});
} else {
_loadTasks(c.id); _loadTasks(c.id);
} }
}
static const _placeholderImage = static const _placeholderImage =
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400'; 'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400';
@ -95,12 +200,14 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
body: Column( body: Column(
children: [ children: [
// need_wait == true Pencil: tabRow bK6o6
if (_showVideoMenu)
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding, horizontal: AppSpacing.screenPadding,
vertical: AppSpacing.xs, vertical: AppSpacing.xs,
), ),
child: _categoriesLoading child: _showCategoriesLoading
? const SizedBox( ? const SizedBox(
height: 40, height: 40,
child: Center(child: CircularProgressIndicator())) child: Center(child: CircularProgressIndicator()))
@ -111,10 +218,11 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
), ),
Expanded( Expanded(
child: _tasksLoading child: _isListLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: LayoutBuilder( : LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final tasks = _displayTasks;
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 390), constraints: const BoxConstraints(maxWidth: 390),
@ -132,9 +240,9 @@ class _HomeScreenState extends State<HomeScreen> {
mainAxisSpacing: AppSpacing.xl, mainAxisSpacing: AppSpacing.xl,
crossAxisSpacing: AppSpacing.xl, crossAxisSpacing: AppSpacing.xl,
), ),
itemCount: _tasks.length, itemCount: tasks.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final task = _tasks[index]; final task = tasks[index];
final credits = task.credits480p != null final credits = task.credits480p != null
? task.credits480p.toString() ? task.credits480p.toString()
: '50'; : '50';

View File

@ -0,0 +1,27 @@
/// extConfig.items common_info surge docs/extConfig.md
class ExtConfigItem {
const ExtConfigItem({
required this.image,
required this.cost,
required this.title,
required this.detail,
});
final String image;
final int cost;
final String title;
final String detail;
factory ExtConfigItem.fromJson(Map<String, dynamic> json) {
return ExtConfigItem(
image: json['image'] as String? ?? '',
cost: (json['cost'] is int)
? json['cost'] as int
: (json['cost'] is num)
? (json['cost'] as num).toInt()
: 0,
title: json['title'] as String? ?? '',
detail: json['detail'] as String? ?? '',
);
}
}

View File

@ -144,11 +144,11 @@ class _VideoCardState extends State<VideoCard> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent, color: Colors.transparent,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowMedium, color: AppColors.shadowMedium,
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@ -213,7 +213,7 @@ class _VideoCardState extends State<VideoCard> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
LucideIcons.sparkles, LucideIcons.sparkles,
size: 12, size: 12,
color: AppColors.surface, color: AppColors.surface,
@ -273,11 +273,11 @@ class _VideoCardState extends State<VideoCard> {
color: AppColors.primary, color: AppColors.primary,
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.primaryButtonShadow, color: AppColors.primaryButtonShadow,
blurRadius: 6, blurRadius: 6,
offset: const Offset(0, 2), offset: Offset(0, 2),
), ),
], ],
), ),

View File

@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../../core/api/api_client.dart';
import '../../core/api/api_config.dart'; import '../../core/api/api_config.dart';
import '../../core/api/services/user_api.dart'; import '../../core/api/services/user_api.dart';
import '../../core/auth/auth_service.dart'; import '../../core/user/account_refresh.dart';
import '../recharge/payment_webview_screen.dart'; import '../recharge/payment_webview_screen.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/user/user_state.dart'; import '../../core/user/user_state.dart';
@ -25,41 +26,17 @@ class _ProfileScreenState extends State<ProfileScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.isActive) _fetchAccount(); if (widget.isActive) refreshAccount(updateProfile: true);
} }
@override @override
void didUpdateWidget(covariant ProfileScreen oldWidget) { void didUpdateWidget(covariant ProfileScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) { if (widget.isActive && !oldWidget.isActive) {
_fetchAccount(); refreshAccount(updateProfile: true);
} }
} }
Future<void> _fetchAccount() async {
final uid = UserState.userId.value;
if (uid == null || uid.isEmpty) return;
try {
await AuthService.loginComplete;
final res = await UserApi.getAccount(
sentinel: ApiConfig.appId,
asset: uid,
);
if (!mounted) return;
if (res.isSuccess && res.data != null) {
final data = res.data as Map<String, dynamic>?;
final credits = data?['reveal'] as int?;
if (credits != null) UserState.setCredits(credits);
final avatarUrl = data?['realm'] as String?;
UserState.setAvatar(
avatarUrl != null && avatarUrl.isNotEmpty ? avatarUrl : null);
final name = data?['terminal'] as String?;
UserState.setUserName(
name != null && name.isNotEmpty ? name : null);
}
} catch (_) {}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -106,8 +83,8 @@ class _ProfileScreenState extends State<ProfileScreen> {
icon: LucideIcons.chevron_right, icon: LucideIcons.chevron_right,
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => PaymentWebViewScreen( builder: (_) => const PaymentWebViewScreen(
paymentUrl: 'http://www.petsheroai.xyz/privacy.html', paymentUrl: 'https://www.petsheroai.xyz/privacy.html',
title: 'Privacy Policy', title: 'Privacy Policy',
), ),
), ),
@ -118,13 +95,19 @@ class _ProfileScreenState extends State<ProfileScreen> {
icon: LucideIcons.chevron_right, icon: LucideIcons.chevron_right,
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => PaymentWebViewScreen( builder: (_) => const PaymentWebViewScreen(
paymentUrl: 'http://www.petsheroai.xyz/terms.html', paymentUrl: 'https://www.petsheroai.xyz/terms.html',
title: 'User Agreement', title: 'User Agreement',
), ),
), ),
), ),
), ),
_MenuItem(
title: 'Delete Account',
icon: LucideIcons.trash_2,
iconColor: const Color(0xFFDC2626),
onTap: () => _DeleteAccountDialog.show(context),
),
], ],
), ),
], ],
@ -169,18 +152,18 @@ class _ProfileHeader extends StatelessWidget {
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: avatarUrl!, imageUrl: avatarUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, __) => Icon( placeholder: (_, __) => const Icon(
LucideIcons.user, LucideIcons.user,
size: 40, size: 40,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
errorWidget: (_, __, ___) => Icon( errorWidget: (_, __, ___) => const Icon(
LucideIcons.user, LucideIcons.user,
size: 40, size: 40,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
) )
: Icon( : const Icon(
LucideIcons.user, LucideIcons.user,
size: 40, size: 40,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@ -188,7 +171,7 @@ class _ProfileHeader extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
Text( Text(
userName ?? 'Guest', userName ?? 'VIP',
style: AppTypography.bodyLarge.copyWith( style: AppTypography.bodyLarge.copyWith(
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
@ -236,11 +219,11 @@ class _BalanceCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowLight, color: AppColors.shadowLight,
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 2), offset: Offset(0, 2),
), ),
], ],
), ),
@ -322,11 +305,13 @@ class _MenuItem extends StatelessWidget {
required this.title, required this.title,
required this.icon, required this.icon,
required this.onTap, required this.onTap,
this.iconColor,
}); });
final String title; final String title;
final IconData icon; final IconData icon;
final VoidCallback onTap; final VoidCallback onTap;
final Color? iconColor;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -340,11 +325,11 @@ class _MenuItem extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowLight, color: AppColors.shadowLight,
blurRadius: 6, blurRadius: 6,
offset: const Offset(0, 2), offset: Offset(0, 2),
), ),
], ],
), ),
@ -354,13 +339,257 @@ class _MenuItem extends StatelessWidget {
Text( Text(
title, title,
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.textPrimary, color: iconColor ?? AppColors.textPrimary,
), ),
), ),
Icon(icon, size: 20, color: AppColors.textMuted), Icon(icon, size: 20, color: iconColor ?? AppColors.textMuted),
], ],
), ),
), ),
); );
} }
} }
/// Delete Account confirmation dialog - matches Pencil Xp6Qz
class _DeleteAccountDialog extends StatefulWidget {
const _DeleteAccountDialog({required this.parentContext});
final BuildContext parentContext;
static void show(BuildContext context) {
showDialog<void>(
context: context,
barrierColor: const Color(0x80000000),
builder: (_) => _DeleteAccountDialog(parentContext: context),
);
}
@override
State<_DeleteAccountDialog> createState() => _DeleteAccountDialogState();
}
class _DeleteAccountDialogState extends State<_DeleteAccountDialog> {
bool _deleting = false;
String? _errorText;
final _verifyController = TextEditingController();
static const _verifyCode = 'DELETE';
bool get _isVerifyMatch =>
_verifyController.text.trim().toUpperCase() == _verifyCode;
@override
void initState() {
super.initState();
_verifyController.addListener(() => setState(() {}));
}
@override
void dispose() {
_verifyController.dispose();
super.dispose();
}
Future<void> _onDelete() async {
setState(() {
_errorText = null;
_deleting = true;
});
try {
final res = await UserApi.deleteAccount(
sentinel: ApiConfig.appId,
asset: UserState.userId.value,
);
if (!mounted) return;
if (res.isSuccess) {
// Clear user state and token
UserState.setCredits(null);
UserState.setUserId(null);
UserState.setAvatar(null);
UserState.setUserName(null);
UserState.setNavigate(null);
ApiClient.instance.setUserToken(null);
Navigator.of(context).pop();
if (widget.parentContext.mounted) {
ScaffoldMessenger.of(widget.parentContext).showSnackBar(
const SnackBar(
content: Text('Account deleted'),
behavior: SnackBarBehavior.floating,
),
);
}
} else {
setState(() => _errorText = res.msg.isNotEmpty ? res.msg : 'Delete failed');
}
} catch (e) {
if (mounted) {
setState(() => _errorText = e.toString().replaceAll('Exception: ', ''));
}
} finally {
if (mounted) {
setState(() => _deleting = false);
}
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Material(
color: Colors.transparent,
child: Container(
width: 342,
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x26000000),
blurRadius: 24,
offset: Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Delete Account?',
style: AppTypography.bodyLarge.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
GestureDetector(
onTap: _deleting ? null : () => Navigator.of(context).pop(),
child: const SizedBox(
width: 40,
height: 40,
child: Icon(LucideIcons.x, size: 24, color: AppColors.textMuted),
),
),
],
),
const SizedBox(height: 20),
Text(
'This action cannot be undone. All your data will be permanently deleted.',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textMuted,
fontSize: 14,
),
),
const SizedBox(height: 20),
Text(
'Type $_verifyCode to confirm',
style: AppTypography.label.copyWith(
color: AppColors.textMuted,
fontSize: 12,
),
),
const SizedBox(height: 8),
TextField(
controller: _verifyController,
decoration: InputDecoration(
hintText: 'Type $_verifyCode here',
hintStyle: AppTypography.bodyRegular.copyWith(
color: AppColors.textMuted,
fontSize: 14,
),
filled: true,
fillColor: const Color(0xFFFAFAFA),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE4E4E7)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE4E4E7)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textPrimary,
fontSize: 14,
),
textCapitalization: TextCapitalization.characters,
),
if (_errorText != null) ...[
const SizedBox(height: 12),
Text(
_errorText!,
style: AppTypography.caption.copyWith(color: Colors.red),
),
],
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: _deleting ? null : () => Navigator.of(context).pop(),
child: Container(
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFFF4F4F5),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'Cancel',
style: AppTypography.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: (_deleting || !_isVerifyMatch) ? null : _onDelete,
child: Container(
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _isVerifyMatch
? const Color(0xFFDC2626)
: Color(0xFFDC2626).withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(14),
),
child: _deleting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
'Delete',
style: AppTypography.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
),
],
),
),
),
);
}
}

View File

@ -1,17 +1,332 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
import 'package:pets_hero_ai/core/api/api.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/log/app_logger.dart'; import '../../core/log/app_logger.dart';
import '../../core/user/account_refresh.dart';
import '../../core/user/user_state.dart';
import 'models/google_pay_purchase_result.dart';
import 'models/google_pay_verification_payload.dart';
import 'models/unacknowledged_google_pay_purchase.dart';
/// Google Play false 使 true ceremony==GooglePay launchPurchaseAndReturnData const String _kFederationMapKey = 'google_pay_google_order_to_federation';
/// Google Play
abstract final class GooglePlayPurchaseService { abstract final class GooglePlayPurchaseService {
static final _log = AppLogger('GooglePlayPurchase'); static final _log = AppLogger('GooglePlayPurchase');
/// GooglePay /v1/payment/googlepay /// Android queryPastPurchases
/// purchaseDataserverVerificationData/ null /// purchaseStream stream pending 使
static Future<String?> launchPurchaseAndReturnData(String productId) async { static final Map<String, PurchaseDetails> _pendingFromStream = {};
static StreamSubscription<List<PurchaseDetails>>? _pendingStreamSub;
/// Android purchaseStream complete
/// queryPastPurchases
static void startPendingPurchaseListener() {
if (defaultTargetPlatform != TargetPlatform.android) return;
if (_pendingStreamSub != null) return;
final iap = InAppPurchase.instance;
_pendingStreamSub =
iap.purchaseStream.listen((List<PurchaseDetails> purchases) {
for (final p in purchases) {
if (p is! GooglePlayPurchaseDetails) continue;
if (!p.pendingCompletePurchase) continue;
final orderId = p.billingClientPurchase.orderId;
if (orderId.isEmpty) continue;
_pendingFromStream[orderId] = p;
_log.d('purchaseStream 收到待处理订单 orderId=$orderId,已加入补单候选');
}
}, onError: (e) {
_log.w('purchaseStream 错误: $e');
});
_log.d('已订阅 purchaseStream用于补单时获取未确认订单');
}
/// Google orderId federation使
static Future<void> saveFederationForGoogleOrderId(
String googleOrderId, String federation) async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_kFederationMapKey);
final map = json != null
? Map<String, String>.from((jsonDecode(json) as Map)
.map((k, v) => MapEntry(k.toString(), v.toString())))
: <String, String>{};
map[googleOrderId] = federation;
await prefs.setString(_kFederationMapKey, jsonEncode(map));
} catch (e) {
_log.w('保存 federation 映射失败: $e');
}
}
/// Google orderId federation null
static Future<String?> getFederationForGoogleOrderId(
String googleOrderId) async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_kFederationMapKey);
if (json == null) return null;
final map = (jsonDecode(json) as Map)
.map((k, v) => MapEntry(k.toString(), v.toString()));
final v = map[googleOrderId]?.toString();
return (v != null && v.isNotEmpty) ? v : null;
} catch (e) {
_log.w('读取 federation 映射失败: $e');
return null;
}
}
///
static Future<void> removeFederationForGoogleOrderId(
String googleOrderId) async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_kFederationMapKey);
if (json == null) return;
final map = Map<String, String>.from((jsonDecode(json) as Map)
.map((k, v) => MapEntry(k.toString(), v.toString())));
map.remove(googleOrderId);
await prefs.setString(_kFederationMapKey, jsonEncode(map));
} catch (e) {
_log.w('移除 federation 映射失败: $e');
}
}
/// consume
/// 使 [queryPastPurchases] purchaseStream isAcknowledged Android
static Future<List<UnacknowledgedGooglePayPurchase>>
getUnacknowledgedPurchases() async {
if (defaultTargetPlatform != TargetPlatform.android) {
_log.d('非 Android无未核销订单');
return [];
}
final iap = InAppPurchase.instance;
if (!await iap.isAvailable()) {
_log.w('Billing 不可用');
return [];
}
try {
// stream queryPastPurchases Billing stream
startPendingPurchaseListener();
final androidAddition =
iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final response = await androidAddition.queryPastPurchases();
if (response.error != null) {
_log.w('queryPastPurchases 错误: ${response.error!.message}');
return [];
}
// response toString
_log.d(
'queryPastPurchases response contains ${response.pastPurchases.length} pastPurchases.');
for (var i = 0; i < response.pastPurchases.length; i++) {
final purchase = response.pastPurchases[i];
final b = purchase.billingClientPurchase;
_log.d('pastPurchase[$i]:');
_log.d(' productID: ${purchase.productID}');
_log.d(' purchaseID: ${purchase.purchaseID}');
_log.d(' transactionDate: ${purchase.transactionDate}');
_log.d(' status: ${purchase.status}');
_log.d(' error: ${purchase.error}');
_log.d(
' pendingCompletePurchase: ${purchase.pendingCompletePurchase}');
_log.d(' billingClientPurchase:');
_log.d(' orderId: ${b.orderId}');
_log.d(' purchaseToken: ${b.purchaseToken}');
_log.d(' packageName: ${b.packageName}');
_log.d(' productID: ${purchase.productID}');
_log.d(' purchaseState: ${b.purchaseState}');
_log.d(' isAcknowledged: ${b.isAcknowledged}');
_log.d(' isAutoRenewing: ${b.isAutoRenewing}');
_log.d(' originalJson (length): ${b.originalJson.length}');
_log.d(' signature (length): ${b.signature.length}');
}
for (final purchase in response.pastPurchases) {
final b = purchase.billingClientPurchase;
final transactionDate = purchase.transactionDate;
String? formattedDate;
if (transactionDate != null && transactionDate.isNotEmpty) {
try {
// transactionDate is milliseconds since epoch (string)
final millis = int.tryParse(transactionDate);
if (millis != null) {
final dt = DateTime.fromMillisecondsSinceEpoch(millis,
isUtc: false); // Android is local?
// Format as yyyy-MM-dd HH:mm:ss
formattedDate =
"${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} "
"${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}";
_log.d('订单日期: $formattedDate');
} else {
_log.d('订单日期解析失败: $transactionDate');
}
} catch (e) {
_log.d('订单日期格式化异常: $e, 原数据: $transactionDate');
}
} else {
_log.d('订单日期为空');
}
_log.d(
'订单: orderId=${b.orderId}, productId=${purchase.productID}, isAcknowledged=${b.isAcknowledged}, purchaseDataLength=${b.originalJson.length}, signatureLength=${b.signature.length}');
}
// isAcknowledged consume
final list = <UnacknowledgedGooglePayPurchase>[];
final orderIdsFromQuery = <String>{};
for (final p in response.pastPurchases) {
final b = p.billingClientPurchase;
orderIdsFromQuery.add(b.orderId);
list.add(UnacknowledgedGooglePayPurchase(
orderId: b.orderId,
productId: p.productID,
payload: GooglePayVerificationPayload(
purchaseData: b.originalJson,
signature: b.signature,
),
purchaseDetails: p,
));
}
// Android queryPastPurchases purchaseStream
// stream Billing
await Future<void>.delayed(const Duration(milliseconds: 1500));
for (final entry in _pendingFromStream.entries) {
if (orderIdsFromQuery.contains(entry.key)) continue;
final p = entry.value;
if (p is! GooglePlayPurchaseDetails) continue;
final b = p.billingClientPurchase;
list.add(UnacknowledgedGooglePayPurchase(
orderId: b.orderId,
productId: p.productID,
payload: GooglePayVerificationPayload(
purchaseData: b.originalJson,
signature: b.signature,
),
purchaseDetails: p,
));
_log.d('未核销订单(来自 stream) orderId=${b.orderId} 已合并');
}
_log.d(
'未核销订单数: ${list.length} (query: ${orderIdsFromQuery.length}, stream: ${_pendingFromStream.length})');
for (var i = 0; i < list.length; i++) {
final u = list[i];
_log.d('未核销[$i] orderId=${u.orderId} productId=${u.productId} '
'purchaseDataLength=${u.payload.purchaseData.length}');
logWithEmbeddedJson(jsonEncode({
'orderId': u.orderId,
'productId': u.productId,
'purchaseData': u.payload.purchaseData,
'signatureLength': u.payload.signature.length,
}));
}
return list;
} catch (e, st) {
_log.w('获取未核销订单失败: $e\n$st');
return [];
}
}
/// completePurchase Android consumePurchaseautoConsume: false
/// [GooglePayPurchaseResult.purchaseDetails]
static Future<bool> completeAndConsumePurchase(
PurchaseDetails purchaseDetails) async {
final iap = InAppPurchase.instance;
try {
iap.completePurchase(purchaseDetails);
_log.d('completePurchase 已执行');
if (defaultTargetPlatform == TargetPlatform.android) {
final androidAddition =
iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final result = await androidAddition.consumePurchase(purchaseDetails);
final ok = result.responseCode == BillingResponse.ok;
if (ok) {
_log.d('consumePurchase 已执行,可再次购买');
} else {
_log.w('consumePurchase 未成功 responseCode=${result.responseCode}');
}
return ok;
}
return true;
} catch (e, st) {
_log.w('completePurchase/consumePurchase 异常: $e\n$st');
return false;
}
}
/// completePurchase + consumePurchase
static Future<bool> _consumePurchase(
InAppPurchase iap, UnacknowledgedGooglePayPurchase p) async {
return completeAndConsumePurchase(p.purchaseDetails);
}
/// federation completePurchase federation completePurchase
/// Android
static Future<void> runOrderRecovery() async {
if (defaultTargetPlatform != TargetPlatform.android) return;
final userId = UserState.userId.value;
if (userId == null || userId.isEmpty) {
_log.d('补单跳过:未登录');
return;
}
final pending = await getUnacknowledgedPurchases();
if (pending.isEmpty) return;
_log.d('补单开始,待处理 ${pending.length}');
final iap = InAppPurchase.instance;
bool needRefresh = false;
for (final p in pending) {
try {
final federation = await getFederationForGoogleOrderId(p.orderId);
if (federation != null && federation.isNotEmpty) {
final res = await PaymentApi.googlepay(
sample: p.payload.signature,
merchant: p.payload.purchaseData,
federation: federation,
asset: userId,
);
if (!res.isSuccess) {
_log.w('补单失败 orderId=${p.orderId}: ${res.msg}');
continue;
}
final data = res.data is Map<String, dynamic>
? res.data as Map<String, dynamic>
: null;
final line = (data?['line']?.toString() ?? '').toUpperCase();
final status = (data?['status']?.toString() ?? '').toUpperCase();
final isSuccess = line == 'SUCCESS' || status == 'SUCCESS';
_log.d(
'补单响应 orderId=${p.orderId} data=$data line=$line status=$status isSuccess=$isSuccess');
if (isSuccess) {
if (await _consumePurchase(iap, p)) {
_pendingFromStream.remove(p.orderId);
await removeFederationForGoogleOrderId(p.orderId);
needRefresh = true;
_log.d('补单成功 orderId=${p.orderId} federation=$federation');
}
} else {
_log.w('补单服务端未成功 orderId=${p.orderId} line=$line status=$status');
}
} else {
// federation completePurchase + consumePurchase
_log.d('补单无 federation仅执行 consume 以解除「已拥有此内容」orderId=${p.orderId}');
if (await _consumePurchase(iap, p)) {
_pendingFromStream.remove(p.orderId);
needRefresh = true;
}
}
} catch (e, st) {
_log.w('补单异常 orderId=${p.orderId}: $e\n$st');
}
}
if (needRefresh) await refreshAccount();
}
/// [PurchaseDetails]
/// [GooglePayPurchaseResult] result.purchaseDetails [InAppPurchase.instance.completePurchase]
static Future<GooglePayPurchaseResult?> launchPurchaseAndReturnData(
String productId) async {
_log.d('谷歌支付请求商品 ID(helm): "$productId"'); _log.d('谷歌支付请求商品 ID(helm): "$productId"');
if (defaultTargetPlatform != TargetPlatform.android) { if (defaultTargetPlatform != TargetPlatform.android) {
_log.d('非 Android跳过内购'); _log.d('非 Android跳过内购');
@ -24,22 +339,69 @@ abstract final class GooglePlayPurchaseService {
} }
final response = await iap.queryProductDetails({productId}); final response = await iap.queryProductDetails({productId});
if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) { if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) {
_log.w('商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致区分大小写'); _log.w(
'商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致区分大小写');
return null; return null;
} }
final product = response.productDetails.first; final product = response.productDetails.first;
final completer = Completer<String?>(); final completer = Completer<GooglePayPurchaseResult?>();
StreamSubscription<List<PurchaseDetails>>? sub; StreamSubscription<List<PurchaseDetails>>? sub;
sub = iap.purchaseStream.listen( sub = iap.purchaseStream.listen(
(purchases) { (purchases) {
// purchases JSON 便
try {
final list = purchases.map((p) {
final base = <String, Object?>{
'productID': p.productID,
'status': p.status.toString(),
'transactionDate': p.transactionDate,
'verificationData': {
'serverVerificationData':
p.verificationData.serverVerificationData,
'localVerificationData':
p.verificationData.localVerificationData,
'source': p.verificationData.source,
},
'pendingCompletePurchase': p.pendingCompletePurchase,
'error': p.error?.message,
};
if (p is GooglePlayPurchaseDetails) {
final b = p.billingClientPurchase;
base['googlePlay'] = {
'orderId': b.orderId,
'packageName': b.packageName,
'purchaseTime': b.purchaseTime,
'purchaseToken': b.purchaseToken,
'signature': b.signature,
'originalJson': b.originalJson,
'isAcknowledged': b.isAcknowledged,
'purchaseState': b.purchaseState.toString(),
};
}
return base;
}).toList();
_log.d('Google Play purchases json: ${jsonEncode(list)}');
logWithEmbeddedJson(jsonEncode(list));
} catch (e) {
_log.w('序列化 purchases 失败: $e');
}
for (final p in purchases) { for (final p in purchases) {
if (p.productID != productId) continue; if (p.productID != productId) continue;
if (p.status == PurchaseStatus.purchased || if (p.status == PurchaseStatus.purchased ||
p.status == PurchaseStatus.restored) { p.status == PurchaseStatus.restored) {
if (!completer.isCompleted) { _log.d('购买成功: ${p.toString()}');
final data = p.verificationData.serverVerificationData; if (!completer.isCompleted && p is GooglePlayPurchaseDetails) {
iap.completePurchase(p); final b = p.billingClientPurchase;
completer.complete(data); _pendingFromStream[b.orderId] = p;
completer.complete(GooglePayPurchaseResult(
orderId: b.orderId,
payload: GooglePayVerificationPayload(
purchaseData: b.originalJson,
signature: b.signature,
),
purchaseDetails: p,
));
} }
sub?.cancel(); sub?.cancel();
return; return;
@ -52,13 +414,14 @@ abstract final class GooglePlayPurchaseService {
} }
} }
}, },
onError: (_) { onError: (e) {
if (!completer.isCompleted) completer.complete(null); if (!completer.isCompleted) completer.complete(null);
sub?.cancel(); sub?.cancel();
}, },
); );
final success = await iap.buyConsumable( final success = await iap.buyConsumable(
purchaseParam: PurchaseParam(productDetails: product), purchaseParam: PurchaseParam(productDetails: product),
autoConsume: false,
); );
if (!success) { if (!success) {
sub.cancel(); sub.cancel();
@ -72,76 +435,4 @@ abstract final class GooglePlayPurchaseService {
}, },
); );
} }
/// 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

@ -0,0 +1,23 @@
import 'package:in_app_purchase/in_app_purchase.dart';
import 'google_pay_verification_payload.dart';
///
/// line == 'SUCCESS' [purchaseDetails]
/// [InAppPurchase.instance.completePurchase]
class GooglePayPurchaseResult {
const GooglePayPurchaseResult({
required this.orderId,
required this.payload,
required this.purchaseDetails,
});
/// Google Play googlepay federation createPayment
final String orderId;
/// POST /v1/payment/googlepay sample/merchant
final GooglePayVerificationPayload payload;
/// [InAppPurchase.instance.completePurchase]
final PurchaseDetails purchaseDetails;
}

View File

@ -0,0 +1,14 @@
/// /v1/payment/googlepay body
/// purchaseData merchantsignature sample federation iduserId asset docs/googlepay.md
class GooglePayVerificationPayload {
const GooglePayVerificationPayload({
required this.purchaseData,
required this.signature,
});
/// JSON billingClientPurchase.originalJson
final String purchaseData;
/// purchaseData RSA billingClientPurchase.signature
final String signature;
}

View File

@ -12,7 +12,7 @@ class PaymentMethodItem {
final String? subPaymentMethod; // ceremony final String? subPaymentMethod; // ceremony
final String? name; // brigade final String? name; // brigade
final String? icon; // greylist URL final String? icon; // greylist URL
final bool recommend; // deny final bool recommend; // deny true Recommended
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) { factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
return PaymentMethodItem( return PaymentMethodItem(
@ -20,7 +20,7 @@ class PaymentMethodItem {
subPaymentMethod: json['ceremony']?.toString(), subPaymentMethod: json['ceremony']?.toString(),
name: json['brigade']?.toString(), name: json['brigade']?.toString(),
icon: json['greylist']?.toString(), icon: json['greylist']?.toString(),
recommend: json['deny'] != true, recommend: json['deny'] == true,
); );
} }

View File

@ -0,0 +1,28 @@
import 'package:in_app_purchase/in_app_purchase.dart';
import 'google_pay_verification_payload.dart';
///
/// [GooglePlayPurchaseService.getUnacknowledgedPurchases]
/// federation [orderId] createPayment
/// [purchaseDetails] [InAppPurchase.instance.completePurchase]
class UnacknowledgedGooglePayPurchase {
const UnacknowledgedGooglePayPurchase({
required this.orderId,
required this.productId,
required this.payload,
required this.purchaseDetails,
});
/// Google Play purchase orderId
final String orderId;
/// IDproductId
final String productId;
/// POST /v1/payment/googlepay sample/merchantfederation/asset
final GooglePayVerificationPayload payload;
/// [InAppPurchase.instance.completePurchase]
final PurchaseDetails purchaseDetails;
}

Some files were not shown because too many files have changed in this diff Show More