Compare commits
10 Commits
5cfb4c74af
...
cacb32c25f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cacb32c25f | ||
|
|
eb72fb4979 | ||
|
|
596fe05e09 | ||
|
|
066d8b7391 | ||
|
|
ea14c36d63 | ||
|
|
62f5c9a19b | ||
|
|
bde8db3673 | ||
|
|
d2e7c0ac8f | ||
|
|
bd6f8ba813 | ||
|
|
6ae2b55677 |
@ -1,7 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
<application
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
|
||||
BIN
android/app/src/main/res/drawable-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
android/app/src/main/res/drawable-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
android/app/src/main/res/drawable-night-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
android/app/src/main/res/drawable-night-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 601 KiB |
BIN
android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
@ -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>
|
||||
BIN
android/app/src/main/res/drawable-xhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 346 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 601 KiB |
BIN
android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
6
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 94 KiB |
22
android/app/src/main/res/values-night-v31/styles.xml
Normal 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>
|
||||
@ -1,7 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<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 name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
|
||||
22
android/app/src/main/res/values-v31/styles.xml
Normal 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>
|
||||
@ -1,7 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<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 name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
|
||||
BIN
assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 603 KiB |
BIN
assets/images/splash.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 893 KiB |
|
Before Width: | Height: | Size: 843 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 858 KiB |
BIN
design/images/generated-1773675899343.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@ -2293,6 +2293,52 @@
|
||||
"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",
|
||||
"id": "OKMsh",
|
||||
"name": "navSpacer",
|
||||
"id": "RGAdu",
|
||||
"name": "reportBtn",
|
||||
"width": 40,
|
||||
"height": 40
|
||||
"height": 40,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -4117,12 +4165,62 @@
|
||||
},
|
||||
"cornerRadius": 16,
|
||||
"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",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "a3uQs",
|
||||
"enabled": false,
|
||||
"width": 72,
|
||||
"height": 72,
|
||||
"iconFontName": "play",
|
||||
@ -4133,13 +4231,39 @@
|
||||
"type": "text",
|
||||
"id": "SuXL2",
|
||||
"fill": "#FFFFFF99",
|
||||
"content": "Your video is ready",
|
||||
"content": "your work is ready",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -70,7 +70,7 @@
|
||||
| 1 | `/v1/user/fast_login` | POST | 设备快速登录;body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 |
|
||||
| 2 | (保存 token、用户信息到 UserState;首次登录打点 register) | — | — |
|
||||
| 3 | `/v1/user/referrer` | POST | 归因上报;query: sentinel, asset(uid), portal;body: digest, origin。 |
|
||||
| 4 | `/v1/user/common_info` | GET | 获取用户通用信息;query: sentinel, asset(uid)。解析 data 写入 UserState,并解析 surge 中 enable_third_party_payment 等。 |
|
||||
| 4 | `/v1/user/common_info` | GET | 获取用户通用信息;query: sentinel, asset(uid)。解析 data 写入 UserState,并解析 surge 中 lucky 等。 |
|
||||
|
||||
**调用处**:`lib/core/auth/auth_service.dart`(init,登录成功后顺序执行)。
|
||||
|
||||
|
||||
16
docs/extConfig.md
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
// 下面的三个字段说明:A面都是 false,B面都是 true
|
||||
"need_wait": false, // 是否展示 Video 菜单
|
||||
"safe_area": false, // 是否防止截屏;为 true 时启用系统截屏/录屏防护(Android FLAG_SECURE,iOS 检测)
|
||||
"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
@ -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 }
|
||||
│
|
||||
▼
|
||||
提交成功,关闭弹窗
|
||||
```
|
||||
@ -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
|
||||
# 谷歌支付流程
|
||||
|
||||
# 购买
|
||||
从/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(有的话),其他那些先不管
|
||||
本文档描述 Android 上 Google Play 内购的完整流程,与 `recharge_screen.dart`、`GooglePlayPurchaseService`、`PaymentApi` 实现对应。
|
||||
|
||||
# 谷歌支付回调接口
|
||||
/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** | 购买凭据 JSON(purchaseData) | 同上 **billingClientPurchase** 的 **originalJson** |
|
||||
| **federation** | 支付/订单 id(id) | 创建订单接口 `createPayment` 返回的 **federation** |
|
||||
| **asset** | 用户 id(userId) | 当前登录用户 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 固定为当前商品的 **code(helm)**,与 Play 后台产品 ID 一致。
|
||||
3. **回调 googlepay**:body 为四字段 **sample**(signature)、**merchant**(purchaseData/originalJson)、**federation**(订单 id)、**asset**(userId);federation 为空则不回调,可按策略重试创建订单或提示失败。
|
||||
4. **未核销订单**:通过 `getUnacknowledgedPurchases()` 获取 `isAcknowledged == false` 的购买;每项含 `purchaseDetails` 用于补单成功后 `completePurchase`。
|
||||
5. **补单**:`runOrderRecovery()` 在应用启动(登录完成后)与进入充值页时执行;补单使用的 **federation** 为创建订单时的订单 id(内购前会持久化 Google orderId→federation,补单时按 Google orderId 取回);无保存 federation 的未核销订单会跳过。
|
||||
|
||||
28
docs/home.md
Normal file
@ -0,0 +1,28 @@
|
||||
# 主页 UI 显示逻辑
|
||||
|
||||
依赖接口:`GET /v1/user/common_info` 返回的 `data.surge`(JSON 字符串)解析为 extConfig,结构见 [extConfig.md](extConfig.md)。
|
||||
|
||||
## extConfig 与顶部分类栏
|
||||
|
||||
- **need_wait**(是否展示 Video 菜单)
|
||||
- 解析自 `surge.need_wait`,写入 `UserState.needShowVideoMenu`。
|
||||
- **仅 need_wait === true**:展示顶部分类栏(对应 Pencil 设计中的 tabRow,节点 bK6o6),行为见下。
|
||||
- **其他情况**(need_wait === false、未下发或未解析):不展示顶部分类栏,列表只展示 **extConfig.items** 的图片列表。
|
||||
|
||||
## need_wait === true 时的分类与列表
|
||||
|
||||
1. **分类栏**
|
||||
使用图转视频分类接口数据,在分类列表**末尾**增加一个固定分类「pets」。
|
||||
2. **列表内容**
|
||||
- 选中**固定分类 pets**:列表展示 **extConfig.items** 的图片列表(不请求视频任务接口)。
|
||||
- 选中**其他分类**:按原逻辑请求对应分类的视频任务接口,列表展示接口返回的视频任务。
|
||||
|
||||
## 数据流简述
|
||||
|
||||
1. 登录后请求 `common_info`,在 `AuthService._saveCommonInfoToState` 中解析 `data.surge`:
|
||||
- 写入 `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)。
|
||||
@ -9,7 +9,7 @@
|
||||
```
|
||||
用户点击 Buy
|
||||
│
|
||||
├─ enable_third_party_payment === true 且已登录
|
||||
├─ lucky === true 且已登录
|
||||
│ │
|
||||
│ ├─ getPaymentMethods(activityId)
|
||||
│ ├─ 弹窗选择支付方式(_PaymentMethodDialog)
|
||||
@ -23,7 +23,7 @@
|
||||
│ └─ 否则(其他支付方式)
|
||||
│ └─ 打开 createPayment 返回的 payUrl(convert)在外部浏览器完成支付
|
||||
│
|
||||
└─ enable_third_party_payment !== true 或未登录
|
||||
└─ lucky !== true 或未登录
|
||||
└─ 仅 Android:直接调起 Google Play 内购(productId = item.code),无 createPayment
|
||||
```
|
||||
|
||||
@ -31,10 +31,10 @@
|
||||
|
||||
## 2. 支付分支依据
|
||||
|
||||
- **数据来源**:`/v1/user/common_info` 响应中的 **surge**(JSON 字符串),解析得到 **enable_third_party_payment**。
|
||||
- **数据来源**:`/v1/user/common_info` 响应中的 **surge**(JSON 字符串),解析得到 **lucky**。
|
||||
- **客户端状态**:`UserState.enableThirdPartyPayment`(登录后由 AuthService 从 common_info 写入)。
|
||||
- **分支**:
|
||||
- **第三方支付**:`enable_third_party_payment == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。
|
||||
- **第三方支付**:`lucky == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。
|
||||
- **直接谷歌支付**:否则(未开三方或未登录)→ 仅 Android 下直接调起 Google Play 内购,不调 getPaymentMethods / createPayment。
|
||||
|
||||
---
|
||||
@ -58,7 +58,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 4. 第三方支付流程(enable_third_party_payment === true)
|
||||
## 4. 第三方支付流程(lucky === true)
|
||||
|
||||
### 4.1 步骤顺序
|
||||
|
||||
@ -80,16 +80,16 @@
|
||||
|
||||
- 条件:`_isGooglePay(paymentMethod, subPaymentMethod)` 为 true(即 paymentMethod 或 subPaymentMethod 转为小写后等于 `"googlepay"`)。
|
||||
- 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。
|
||||
- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**:
|
||||
- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**(所有内购统一使用):
|
||||
- productId 为当前商品的 **code**(helm)。
|
||||
- 成功返回 **serverVerificationData**(merchant),失败/取消返回 null。
|
||||
- 若有 merchant 且订单 ID(federation)存在,调用 **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 一致)。
|
||||
- 非 Android:提示 "Google Pay is only available on Android"。
|
||||
|
||||
@ -122,8 +122,8 @@
|
||||
| 支付分支与 Buy 入口 | recharge_screen.dart:_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay |
|
||||
| 第三方:获取支付方式 + 弹窗 | _runThirdPartyPayment:PaymentApi.getPaymentMethods → _PaymentMethodDialog |
|
||||
| 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrl:createPayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) |
|
||||
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase |
|
||||
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dart:launchPurchaseAndReturnData / launchPurchase;PaymentApi.googlepay |
|
||||
| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchaseAndReturnData |
|
||||
| 谷歌内购 + 凭据上报 | google_play_purchase_service.dart:launchPurchaseAndReturnData;PaymentApi.googlepay |
|
||||
| 商品未找到排查 | docs/google_pay_product_not_found.md |
|
||||
|
||||
---
|
||||
|
||||
@ -1297,7 +1297,7 @@ V2 完整响应体 (解密后):
|
||||
{
|
||||
"sentinel": "", // app — 应用标识(必填)
|
||||
"asset": "", // userId — 用户ID
|
||||
"accolade": "", // type — 类型
|
||||
"accolade": "android_adjust", // type — 类型,传 android_adjust
|
||||
"portal": "" // pkg — 应用包名(必填)
|
||||
}
|
||||
```
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
1. **设备快速登录**:`POST /v1/user/fast_login`,拿到 `userToken` 和用户信息。
|
||||
2. **保存登录态与用户信息**:保存 token、积分、userId、头像、昵称、国家码等;**首次登录**记录为注册日期。
|
||||
3. **归因上报**:`POST /v1/user/referrer`,将归因数据(如从 Adjust 获取的 digest)上报。
|
||||
4. **获取用户通用信息**:`GET /v1/user/common_info`,拉取通用配置并保存(含 `surge` 中 `enable_third_party_payment` 等)。
|
||||
4. **获取用户通用信息**:`GET /v1/user/common_info`,拉取通用配置并保存(含 `surge` 中 `lucky` 等)。
|
||||
|
||||
登录成功后,后续所有请求需在 Header 中携带 `knight`(即 `User_token` / userToken)。
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
|--------|----------------|------|
|
||||
| Query | sentinel | 应用标识(必填) |
|
||||
| Query | asset | 用户 ID(userId) |
|
||||
| Query | accolade | 类型(可选) |
|
||||
| Query | accolade | 类型(可选),传 `android_adjust` |
|
||||
| Query | portal | 应用包名(必填) |
|
||||
| Body | digest | **归因信息,从 Adjust 获取** |
|
||||
| Body | origin | 设备 ID(deviceId) |
|
||||
@ -93,13 +93,13 @@
|
||||
|
||||
- 与 fast_login 的 data 结构类似,含 **surge**(extConfig)、积分、用户信息等。
|
||||
- **surge**:字符串,为 JSON;需 **先 JSON 解析再使用**。
|
||||
- 解析后的对象中,包含 **enable_third_party_payment** 等字段,用于控制第三方支付等能力。
|
||||
- 解析后的对象中,包含 **lucky** 等字段,用于控制第三方支付等能力。
|
||||
- 其他字段(reveal、realm、terminal、navigate 等)按需保存到全局变量或状态。
|
||||
|
||||
### 客户端必做
|
||||
|
||||
- 调用 common_info 并将结果**保存到全局/状态**。
|
||||
- 对 **surge** 做 **JSON decode**,得到对象后读取并保存 **enable_third_party_payment** 等配置。
|
||||
- 对 **surge** 做 **JSON decode**,得到对象后读取并保存 **lucky** 等配置。
|
||||
|
||||
---
|
||||
|
||||
@ -117,7 +117,7 @@ APP 启动
|
||||
→ 成功后打日志
|
||||
4. GET /v1/user/common_info(query: sentinel, asset)
|
||||
→ 将结果保存到全局
|
||||
→ 对 data.surge 做 JSON decode,保存 enable_third_party_payment 等
|
||||
→ 对 data.surge 做 JSON decode,保存 lucky 等
|
||||
→ 之后所有请求 Header 带 knight = userToken
|
||||
```
|
||||
|
||||
|
||||
@ -4,8 +4,8 @@ FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_TARGET=lib/main.dart
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=1.0.2
|
||||
FLUTTER_BUILD_NUMBER=3
|
||||
FLUTTER_BUILD_NAME=1.0.9
|
||||
FLUTTER_BUILD_NUMBER=10
|
||||
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
||||
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
||||
DART_OBFUSCATION=false
|
||||
|
||||
@ -5,8 +5,8 @@ export "FLUTTER_APPLICATION_PATH=/Users/sven/Project/Workplace/app_client"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_TARGET=lib/main.dart"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=1.0.2"
|
||||
export "FLUTTER_BUILD_NUMBER=3"
|
||||
export "FLUTTER_BUILD_NAME=1.0.9"
|
||||
export "FLUTTER_BUILD_NUMBER=10"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1014 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 63 KiB |
21
ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png
vendored
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/background@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/background@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 247 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
38
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||
@ -18,6 +18,12 @@
|
||||
@import device_info_plus;
|
||||
#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>)
|
||||
#import <gal/GalPlugin.h>
|
||||
#else
|
||||
@ -36,6 +42,12 @@
|
||||
@import in_app_purchase_storekit;
|
||||
#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>)
|
||||
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
|
||||
#else
|
||||
@ -77,9 +89,11 @@
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]];
|
||||
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
||||
[FlutterNativeSplashPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterNativeSplashPlugin"]];
|
||||
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
||||
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
|
||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||
|
||||
@ -47,5 +47,7 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
31
lib/app.dart
@ -1,5 +1,7 @@
|
||||
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/user/user_state.dart';
|
||||
import 'features/gallery/gallery_screen.dart';
|
||||
@ -36,12 +38,37 @@ class _AppState extends State<App> {
|
||||
debugShowCheckedModeBanner: false,
|
||||
initialRoute: '/',
|
||||
builder: (context, child) {
|
||||
return SafeArea(
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SafeArea(
|
||||
top: true,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: false,
|
||||
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: {
|
||||
@ -90,7 +117,7 @@ class _MainScaffold extends StatelessWidget {
|
||||
body: IndexedStack(
|
||||
index: currentTab.index,
|
||||
children: [
|
||||
const HomeScreen(),
|
||||
HomeScreen(isActive: currentTab == NavTab.home),
|
||||
GalleryScreen(isActive: currentTab == NavTab.gallery),
|
||||
ProfileScreen(isActive: currentTab == NavTab.profile),
|
||||
],
|
||||
|
||||
@ -13,10 +13,10 @@ abstract final class ApiConfig {
|
||||
|
||||
/// 预发环境域名
|
||||
static const String preBaseUrl = 'https://pre-ai.petsheroai.xyz';
|
||||
//'https://ai.petsheroai.xyz'; //'https://pre-ai.petsheroai.xyz';
|
||||
|
||||
/// 生产环境域名
|
||||
static const String prodBaseUrl =
|
||||
'https://pre-ai.petsheroai.xyz'; //https://ai.petsheroai.xyz
|
||||
static const String prodBaseUrl = 'https://ai.petsheroai.xyz';
|
||||
|
||||
/// 代理入口路径
|
||||
static const String proxyPath = '/quester/defender/summoner';
|
||||
|
||||
@ -62,7 +62,8 @@ void _logLong(String text) {
|
||||
int chunkIndex = 0;
|
||||
for (final line in lines) {
|
||||
final lineWithNewline = buffer.isEmpty ? line : '\n$line';
|
||||
if (buffer.length + lineWithNewline.length > _maxLogChunk && buffer.isNotEmpty) {
|
||||
if (buffer.length + lineWithNewline.length > _maxLogChunk &&
|
||||
buffer.isNotEmpty) {
|
||||
chunkIndex++;
|
||||
_proxyLog.d('(part $chunkIndex)\n$buffer');
|
||||
buffer.clear();
|
||||
@ -74,7 +75,8 @@ void _logLong(String text) {
|
||||
}
|
||||
if (buffer.isNotEmpty) {
|
||||
chunkIndex++;
|
||||
_proxyLog.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString());
|
||||
_proxyLog
|
||||
.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,13 +254,9 @@ class ProxyClient {
|
||||
final paramsEncoded = jsonEncode(paramsMap);
|
||||
final v2BodyEncoded = jsonEncode(v2Body);
|
||||
|
||||
_log('========== 原始入参 ==========');
|
||||
_log('path: $path');
|
||||
_log('method: $method');
|
||||
_log('headers: $headersEncoded');
|
||||
_log('queryParams: $paramsEncoded');
|
||||
_log('body(sanctum): ${jsonEncode(sanctum)}');
|
||||
_log('v2Body: $v2BodyEncoded');
|
||||
final logStr =
|
||||
'========== 原始入参 ===========\npath: $path\nmethod: $method\nqueryParams: $paramsEncoded\nbody(sanctum): ${jsonEncode(sanctum)}';
|
||||
_log(logStr);
|
||||
|
||||
final petSpeciesEnc = ApiCrypto.encrypt(path);
|
||||
final powerLevelEnc = ApiCrypto.encrypt(method);
|
||||
@ -266,13 +264,6 @@ class ProxyClient {
|
||||
final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded);
|
||||
final loyaltyIndexEnc = ApiCrypto.encrypt(v2BodyEncoded);
|
||||
|
||||
_log('========== 加密后 ==========');
|
||||
_log('pet_species: $petSpeciesEnc');
|
||||
_log('power_level: $powerLevelEnc');
|
||||
_log('quest_rank: $questRankEnc');
|
||||
_log('battle_score: $battleScoreEnc');
|
||||
_log('loyalty_index: $loyaltyIndexEnc');
|
||||
|
||||
final proxyBody = {
|
||||
ProxyKeys.heroClass: ApiConfig.appId,
|
||||
ProxyKeys.petSpecies: petSpeciesEnc,
|
||||
@ -289,8 +280,6 @@ class ProxyClient {
|
||||
};
|
||||
|
||||
final url = '$_baseUrl${ApiConfig.proxyPath}';
|
||||
_log('========== 请求 URL ==========');
|
||||
_log('$url');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(url),
|
||||
@ -298,19 +287,17 @@ class ProxyClient {
|
||||
body: jsonEncode(proxyBody),
|
||||
);
|
||||
|
||||
_log('========== 响应 ==========');
|
||||
_log('statusCode: ${response.statusCode}');
|
||||
_log('body: ${response.body}');
|
||||
|
||||
return _parseResponse(response);
|
||||
}
|
||||
|
||||
ApiResponse _parseResponse(http.Response response) {
|
||||
try {
|
||||
// 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串
|
||||
var responseLogStr = '========== 响应 ===========';
|
||||
final decrypted = ApiCrypto.decrypt(response.body);
|
||||
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||
_log('json: $json');
|
||||
responseLogStr += jsonEncode(json);
|
||||
_log(responseLogStr);
|
||||
// 解析 helm=code, rampart=msg, sidekick=data
|
||||
final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum'];
|
||||
if (sanctum is Map<String, dynamic>) {
|
||||
|
||||
38
lib/core/api/services/feedback_api.dart
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
required String sample,
|
||||
required String merchant,
|
||||
required String federation,
|
||||
required String asset,
|
||||
String? sample,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/payment/googlepay',
|
||||
@ -103,10 +118,10 @@ abstract final class PaymentApi {
|
||||
'asset': asset,
|
||||
},
|
||||
body: {
|
||||
'sample': sample,
|
||||
'merchant': merchant,
|
||||
'federation': federation,
|
||||
'asset': asset,
|
||||
if (sample != null && sample.isNotEmpty) 'sample': sample,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:screen_secure/screen_secure.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../adjust/adjust_events.dart';
|
||||
@ -23,6 +24,9 @@ class AuthService {
|
||||
|
||||
static Future<void>? _loginFuture;
|
||||
|
||||
/// 登录是否已完成,用于 UI 控制(如登录中隐藏页面加载指示器)
|
||||
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
|
||||
|
||||
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
||||
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
||||
|
||||
@ -52,7 +56,29 @@ class AuthService {
|
||||
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) {
|
||||
final reveal = data['reveal'] as int?;
|
||||
if (reveal != null) UserState.setCredits(reveal);
|
||||
@ -68,8 +94,18 @@ class AuthService {
|
||||
try {
|
||||
final surge = json.decode(surgeStr) as Map<String, dynamic>?;
|
||||
if (surge != null) {
|
||||
final enable = surge['enable_third_party_payment'] as bool?;
|
||||
final enable = surge['lucky'] as bool?;
|
||||
UserState.setEnableThirdPartyPayment(enable);
|
||||
// extConfig:need_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) {
|
||||
_logMsg('surge JSON 解析失败: $e');
|
||||
@ -177,6 +213,7 @@ class AuthService {
|
||||
asset: uid!,
|
||||
digest: crest ?? '',
|
||||
origin: deviceId,
|
||||
accolade: 'android_adjust',
|
||||
);
|
||||
if (referrerRes.isSuccess) {
|
||||
_logMsg('referrer 上报成功');
|
||||
@ -216,7 +253,10 @@ class AuthService {
|
||||
_logMsg('init: 异常 $e');
|
||||
_logMsg('init: 堆栈 $st');
|
||||
} finally {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
isLoginComplete.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
lib/core/user/account_refresh.dart
Normal 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 (_) {}
|
||||
}
|
||||
@ -10,10 +10,17 @@ class UserState {
|
||||
static final ValueNotifier<String?> userName = ValueNotifier<String?>(null);
|
||||
/// 国家码 (navigate / countryCode)
|
||||
static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null);
|
||||
/// 是否启用第三方支付(来自 common_info surge.enable_third_party_payment)
|
||||
/// 是否启用第三方支付(来自 common_info surge.lucky)
|
||||
static final ValueNotifier<bool?> enableThirdPartyPayment =
|
||||
ValueNotifier<bool?>(null);
|
||||
|
||||
/// 是否展示 Video 分类栏(来自 common_info surge.need_wait,见 docs/extConfig.md)
|
||||
static final ValueNotifier<bool?> needShowVideoMenu = ValueNotifier<bool?>(null);
|
||||
/// 是否防止截屏(来自 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) {
|
||||
credits.value = value;
|
||||
}
|
||||
@ -38,6 +45,16 @@ class UserState {
|
||||
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) {
|
||||
if (value == null) return '--';
|
||||
return value.toString().replaceAllMapped(
|
||||
|
||||
@ -8,7 +8,6 @@ import '../../core/api/services/image_api.dart';
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
import 'models/gallery_task_item.dart';
|
||||
@ -139,12 +138,10 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
appBar: const PreferredSize(
|
||||
preferredSize: Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Gallery',
|
||||
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
title: 'My Gallery',
|
||||
),
|
||||
),
|
||||
body: _loading
|
||||
@ -157,7 +154,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
TextButton(
|
||||
@ -176,10 +173,11 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
onRefresh: () => _loadTasks(refresh: true),
|
||||
child: _gridItems.isEmpty && !_loading
|
||||
? SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: constraints.maxHeight - 100,
|
||||
child: Center(
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'No images yet',
|
||||
style: TextStyle(
|
||||
@ -190,7 +188,8 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
@ -206,8 +205,8 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
mainAxisSpacing: AppSpacing.xl,
|
||||
crossAxisSpacing: AppSpacing.xl,
|
||||
),
|
||||
itemCount:
|
||||
_gridItems.length + (_loadingMore ? 1 : 0),
|
||||
itemCount: _gridItems.length +
|
||||
(_loadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _gridItems.length) {
|
||||
return const Center(
|
||||
@ -265,17 +264,20 @@ class _GalleryCard extends StatelessWidget {
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowMedium,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: mediaItem.imageUrl != null
|
||||
child: mediaItem.isVideo
|
||||
? _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!)
|
||||
: (mediaItem.imageUrl != null &&
|
||||
mediaItem.imageUrl!.isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: mediaItem.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
@ -286,7 +288,7 @@ class _GalleryCard extends StatelessWidget {
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
)
|
||||
: _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!),
|
||||
: Container(color: AppColors.surfaceAlt),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
/// 媒体项:digitize=图片URL,reconfigure=视频URL(需生成封面)
|
||||
/// 媒体项:reconfigure=imgUrl,类型由 reconnect(imgType) 决定:0=视频,1=图片,其他当图片
|
||||
class GalleryMediaItem {
|
||||
const GalleryMediaItem({
|
||||
this.imageUrl,
|
||||
this.videoUrl,
|
||||
}) : assert(imageUrl != null || videoUrl != null);
|
||||
|
||||
final String? imageUrl; // digitize
|
||||
final String? videoUrl; // reconfigure - 视频地址,用于生成封面
|
||||
final String? imageUrl;
|
||||
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 字段映射)
|
||||
@ -30,20 +32,49 @@ class GalleryTaskItem {
|
||||
factory GalleryTaskItem.fromJson(Map<String, dynamic> json) {
|
||||
final downsample = json['downsample'] as List<dynamic>? ?? [];
|
||||
final items = <GalleryMediaItem>[];
|
||||
for (final item in downsample) {
|
||||
if (item is String) {
|
||||
items.add(GalleryMediaItem(imageUrl: item));
|
||||
} else if (item is Map<String, dynamic>) {
|
||||
final digitize = item['digitize'] as String?;
|
||||
final reconfigure = item['reconfigure'] as String?;
|
||||
// digitize=图片, reconfigure=视频;优先用图片,否则用视频生成封面
|
||||
if (digitize != null && digitize.isNotEmpty) {
|
||||
items.add(GalleryMediaItem(imageUrl: digitize));
|
||||
} else if (reconfigure != null && reconfigure.isNotEmpty) {
|
||||
// 只取downsample的array[0]
|
||||
if (downsample.isNotEmpty) {
|
||||
final first = downsample[0];
|
||||
if (first is String) {
|
||||
items.add(GalleryMediaItem(imageUrl: first));
|
||||
} else if (first is Map<String, dynamic>) {
|
||||
final reconfigure = first['reconfigure'] as String?;
|
||||
if (reconfigure != null && reconfigure.isNotEmpty) {
|
||||
final reconnect = first['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));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 后续下标均忽略
|
||||
}
|
||||
// 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(
|
||||
taskId: (json['tree'] as num?)?.toInt() ?? 0,
|
||||
state: json['listing']?.toString() ?? '',
|
||||
|
||||
@ -18,7 +18,7 @@ import '../../features/gallery/models/gallery_task_item.dart';
|
||||
import '../../shared/tab_selector_scope.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 3–6 are stage 3.
|
||||
const _stateLabels = <int, String>{
|
||||
1: 'Queued',
|
||||
@ -38,27 +38,24 @@ double _progressForState(int? state) {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
final curate = data['curate'] as List<dynamic>?;
|
||||
if (curate == null || curate.isEmpty) return null;
|
||||
final first = curate.first;
|
||||
if (first is! Map<String, dynamic>) return null;
|
||||
final reconfigure = first['reconfigure'] as String?;
|
||||
final digitize = first['digitize'] as String?;
|
||||
final videoUrl = reconfigure?.isNotEmpty == true
|
||||
? reconfigure
|
||||
: digitize?.isNotEmpty == true
|
||||
? digitize
|
||||
: null;
|
||||
final imageUrl = digitize?.isNotEmpty == true ? digitize : null;
|
||||
if (videoUrl != null) {
|
||||
return GalleryMediaItem(videoUrl: videoUrl, imageUrl: imageUrl);
|
||||
if (reconfigure == null || reconfigure.isEmpty) return null;
|
||||
final reconnect = first['reconnect'];
|
||||
final imgType = reconnect is int
|
||||
? reconnect
|
||||
: reconnect is num
|
||||
? reconnect.toInt()
|
||||
: 1;
|
||||
if (imgType == 2) {
|
||||
return GalleryMediaItem(videoUrl: reconfigure);
|
||||
}
|
||||
if (imageUrl != null) {
|
||||
return GalleryMediaItem(imageUrl: imageUrl);
|
||||
}
|
||||
return null;
|
||||
return GalleryMediaItem(imageUrl: reconfigure);
|
||||
}
|
||||
|
||||
/// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label)
|
||||
@ -75,6 +72,7 @@ class GenerateProgressScreen extends StatefulWidget {
|
||||
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
int? _state;
|
||||
Timer? _pollTimer;
|
||||
bool _isFetching = false;
|
||||
|
||||
double get _progress => _progressForState(_state);
|
||||
|
||||
@ -107,7 +105,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
Future<void> _startPolling() async {
|
||||
await AuthService.loginComplete;
|
||||
_pollTimer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
const Duration(seconds: 5),
|
||||
(_) => _fetchProgress(),
|
||||
);
|
||||
_fetchProgress();
|
||||
@ -115,7 +113,9 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
|
||||
Future<void> _fetchProgress() async {
|
||||
if (widget.taskId == null) return;
|
||||
if (_isFetching) return;
|
||||
|
||||
_isFetching = true;
|
||||
try {
|
||||
final res = await ImageApi.getProgress(
|
||||
sentinel: ApiConfig.appId,
|
||||
@ -144,8 +144,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
asset: userId,
|
||||
);
|
||||
if (accountRes.isSuccess && accountRes.data != null) {
|
||||
final accountData =
|
||||
accountRes.data as Map<String, dynamic>?;
|
||||
final accountData = accountRes.data as Map<String, dynamic>?;
|
||||
final credits = accountData?['reveal'] as int?;
|
||||
if (credits != null) {
|
||||
UserState.setCredits(credits);
|
||||
@ -167,11 +166,14 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
break;
|
||||
}
|
||||
} catch (_) {}
|
||||
finally {
|
||||
_isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labelText = _stateLabels[_state] ?? '队列中';
|
||||
final labelText = _stateLabels[_state] ?? 'Queued';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
@ -191,7 +193,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
_VideoPreview(imagePath: widget.imagePath),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
Text(
|
||||
'Video generation may take some time. Please wait patiently.',
|
||||
'Generating may take some time. Please wait patiently.',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
@ -214,8 +216,9 @@ class _VideoPreview extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasImage =
|
||||
imagePath != null && imagePath!.isNotEmpty && File(imagePath!).existsSync();
|
||||
final hasImage = imagePath != null &&
|
||||
imagePath!.isNotEmpty &&
|
||||
File(imagePath!).existsSync();
|
||||
|
||||
return Container(
|
||||
height: 360,
|
||||
@ -235,14 +238,14 @@ class _VideoPreview extends StatelessWidget {
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
LucideIcons.film,
|
||||
size: 64,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Video Preview',
|
||||
'Previewing',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
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 {
|
||||
const _ProgressSection({required this.progress, required this.label});
|
||||
|
||||
@ -276,8 +279,7 @@ class _ProgressSection extends StatelessWidget {
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final fillWidth =
|
||||
constraints.maxWidth * progress.clamp(0.0, 1.0);
|
||||
final fillWidth = constraints.maxWidth * progress.clamp(0.0, 1.0);
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
|
||||
@ -13,13 +13,12 @@ import '../../core/log/app_logger.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../core/user/account_refresh.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
import '../../features/home/models/task_item.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/user_api.dart';
|
||||
|
||||
/// Generate Video screen - matches Pencil mmLB5
|
||||
class GenerateVideoScreen extends StatefulWidget {
|
||||
@ -53,9 +52,15 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
String get _heatmap =>
|
||||
_selectedResolution == _Resolution.p480 ? '480p' : '720p';
|
||||
|
||||
bool get _hasVideo {
|
||||
final url = widget.task?.previewVideoUrl;
|
||||
return url != null && url.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
refreshAccount();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
GenerateVideoScreen._log.d('opened with task: ${widget.task}');
|
||||
});
|
||||
@ -173,12 +178,13 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
throw Exception('User not logged in');
|
||||
}
|
||||
|
||||
final templateName = widget.task?.templateName ?? '';
|
||||
final createRes = await ImageApi.createTask(
|
||||
asset: userId,
|
||||
guild: filePath,
|
||||
allowance: false,
|
||||
cipher: widget.task?.taskType ?? '',
|
||||
congregation: widget.task?.templateName ?? '',
|
||||
congregation: templateName == 'BananaTask' ? null : templateName,
|
||||
heatmap: _heatmap,
|
||||
ext: widget.task?.ext,
|
||||
);
|
||||
@ -192,17 +198,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
final taskId = taskData?['tree'];
|
||||
|
||||
// 创建任务成功后刷新用户账户信息(积分等)
|
||||
final accountRes = await UserApi.getAccount(
|
||||
sentinel: ApiConfig.appId,
|
||||
asset: userId,
|
||||
);
|
||||
if (accountRes.isSuccess && accountRes.data != null) {
|
||||
final accountData = accountRes.data as Map<String, dynamic>?;
|
||||
final credits = accountData?['reveal'] as int?;
|
||||
if (credits != null) {
|
||||
UserState.setCredits(credits);
|
||||
}
|
||||
}
|
||||
await refreshAccount();
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacementNamed(
|
||||
@ -234,11 +230,9 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Generate Video',
|
||||
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
||||
title: 'Generate',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
@ -280,12 +274,12 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_hasVideo)
|
||||
_ResolutionToggle(
|
||||
selected: _selectedResolution,
|
||||
onChanged: (r) =>
|
||||
setState(() => _selectedResolution = r),
|
||||
onChanged: (r) => setState(() => _selectedResolution = r),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
|
||||
_GenerateButton(
|
||||
onGenerate: _onGenerateButtonTap,
|
||||
isLoading: _isGenerating,
|
||||
@ -329,7 +323,7 @@ class _CreditsCard extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
|
||||
const Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -428,13 +422,35 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isReady =
|
||||
_controller != null && _controller!.value.isInitialized;
|
||||
final isReady = _controller != null && _controller!.value.isInitialized;
|
||||
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
|
||||
final hasImage =
|
||||
widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
|
||||
final hasImage = 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 &&
|
||||
_controller!.value.size.width > 0 &&
|
||||
_controller!.value.size.height > 0
|
||||
@ -479,13 +495,15 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) => _LoadingOverlay(isLoading: true),
|
||||
errorWidget: (_, __, ___) => _LoadingOverlay(isLoading: false),
|
||||
placeholder: (_, __) =>
|
||||
const _LoadingOverlay(isLoading: true),
|
||||
errorWidget: (_, __, ___) =>
|
||||
const _LoadingOverlay(isLoading: false),
|
||||
)
|
||||
else
|
||||
_LoadingOverlay(isLoading: false),
|
||||
const _LoadingOverlay(isLoading: false),
|
||||
if (hasVideo && !isReady)
|
||||
Positioned.fill(
|
||||
const Positioned.fill(
|
||||
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 {
|
||||
const _LoadingOverlay({this.isLoading = true});
|
||||
|
||||
@ -515,7 +622,7 @@ class _LoadingOverlay extends StatelessWidget {
|
||||
color: AppColors.primary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
: const Icon(
|
||||
LucideIcons.video,
|
||||
size: 48,
|
||||
color: AppColors.textMuted,
|
||||
@ -590,9 +697,8 @@ class _ResolutionOption extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: isSelected
|
||||
? null
|
||||
: Border.all(color: AppColors.border, width: 1),
|
||||
border:
|
||||
isSelected ? null : Border.all(color: AppColors.border, width: 1),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
@ -661,7 +767,7 @@ class _GenerateButton extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Generate Video',
|
||||
'Generate',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.surface,
|
||||
),
|
||||
@ -680,7 +786,7 @@ class _GenerateButton extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 16,
|
||||
color: AppColors.surface,
|
||||
|
||||
@ -6,9 +6,11 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.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_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
@ -130,13 +132,13 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Video Ready',
|
||||
title: 'Ready',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: widget.mediaItem == null
|
||||
? Center(
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No media',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
@ -192,7 +194,10 @@ class _MediaDisplay extends StatelessWidget {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: videoUrl != null && videoController != null
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
videoUrl != null && videoController != null
|
||||
? _VideoPlayer(
|
||||
controller: videoController!,
|
||||
)
|
||||
@ -208,7 +213,7 @@ class _MediaDisplay extends StatelessWidget {
|
||||
? Container(
|
||||
color: AppColors.textPrimary,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
child: const Text(
|
||||
'Load failed',
|
||||
style: TextStyle(color: AppColors.surface),
|
||||
),
|
||||
@ -221,6 +226,40 @@ class _MediaDisplay extends StatelessWidget {
|
||||
errorWidget: (_, __, ___) => _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(
|
||||
fit: StackFit.expand,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
FittedBox(
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.controller.value.isPlaying) {
|
||||
widget.controller.pause();
|
||||
} else {
|
||||
widget.controller.play();
|
||||
}
|
||||
},
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width: widget.controller.value.size.width > 0
|
||||
@ -279,33 +322,6 @@ class _VideoPlayerState extends State<_VideoPlayer> {
|
||||
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) {
|
||||
return Container(
|
||||
color: AppColors.textPrimary,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.play,
|
||||
size: 72,
|
||||
color: AppColors.surface.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
'Your video is ready',
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'Your work is ready',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -377,7 +383,8 @@ class _DownloadButton extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(LucideIcons.download, size: 20, color: AppColors.surface),
|
||||
const Icon(LucideIcons.download,
|
||||
size: 20, color: AppColors.surface),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Text(
|
||||
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 {
|
||||
const _ShareButton({required this.onShare});
|
||||
|
||||
@ -407,18 +700,18 @@ class _ShareButton extends StatelessWidget {
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
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),
|
||||
Text(
|
||||
'Share',
|
||||
|
||||
@ -1,18 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/api/services/image_api.dart';
|
||||
import '../../core/user/user_state.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 '../../shared/widgets/top_nav_bar.dart';
|
||||
import 'models/category_item.dart';
|
||||
import 'models/ext_config_item.dart';
|
||||
import 'models/task_item.dart';
|
||||
import 'widgets/home_tab_row.dart';
|
||||
import 'widgets/video_card.dart';
|
||||
|
||||
/// AI Video App home screen - tab 来自分类接口,Grid 来自任务列表接口
|
||||
/// 固定「pets」分类 id,用于展示 extConfig.items
|
||||
const int kExtCategoryId = -1;
|
||||
|
||||
/// AI Video App home screen - tab 来自分类接口,Grid 来自任务列表或 extConfig.items
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
const HomeScreen({super.key, this.isActive = true});
|
||||
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
@ -29,7 +36,88 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
UserState.needShowVideoMenu.addListener(_onExtConfigChanged);
|
||||
UserState.extConfigItems.addListener(_onExtConfigChanged);
|
||||
AuthService.isLoginComplete.addListener(_onExtConfigChanged);
|
||||
_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.items;true 且选中固定分类时用 extConfig.items;否则用 _tasks
|
||||
List<TaskItem> get _displayTasks {
|
||||
if (!_showVideoMenu) {
|
||||
return _extItemsToTaskItems(_parsedExtItems);
|
||||
}
|
||||
if (_selectedCategory?.id == kExtCategoryId) {
|
||||
return _extItemsToTaskItems(_parsedExtItems);
|
||||
}
|
||||
return _tasks;
|
||||
}
|
||||
|
||||
static List<TaskItem> _extItemsToTaskItems(List<ExtConfigItem> items) {
|
||||
return items
|
||||
.map((e) => TaskItem(
|
||||
templateName: e.title,
|
||||
title: e.title,
|
||||
previewImageUrl: e.image,
|
||||
previewVideoUrl: null,
|
||||
taskType: e.title,
|
||||
ext: e.detail,
|
||||
credits480p: e.cost,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _loadCategories() async {
|
||||
@ -42,10 +130,20 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final list = (res.data as List)
|
||||
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
if (UserState.needShowVideoMenu.value == true) {
|
||||
list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null));
|
||||
}
|
||||
setState(() {
|
||||
_categories = list;
|
||||
_selectedCategory = list.isNotEmpty ? list.first : null;
|
||||
if (_selectedCategory != null) _loadTasks(_selectedCategory!.id);
|
||||
if (_selectedCategory != null) {
|
||||
if (_selectedCategory!.id == kExtCategoryId) {
|
||||
_tasks = [];
|
||||
_tasksLoading = false;
|
||||
} else {
|
||||
_loadTasks(_selectedCategory!.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setState(() => _categories = []);
|
||||
@ -75,8 +173,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
void _onTabChanged(CategoryItem c) {
|
||||
setState(() => _selectedCategory = c);
|
||||
if (c.id == kExtCategoryId) {
|
||||
setState(() {
|
||||
_tasks = [];
|
||||
_tasksLoading = false;
|
||||
});
|
||||
} else {
|
||||
_loadTasks(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
static const _placeholderImage =
|
||||
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400';
|
||||
@ -95,12 +200,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 仅 need_wait == true 时展示顶部分类栏(Pencil: tabRow bK6o6)
|
||||
if (_showVideoMenu)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
child: _categoriesLoading
|
||||
child: _showCategoriesLoading
|
||||
? const SizedBox(
|
||||
height: 40,
|
||||
child: Center(child: CircularProgressIndicator()))
|
||||
@ -111,10 +218,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _tasksLoading
|
||||
child: _isListLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tasks = _displayTasks;
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 390),
|
||||
@ -132,9 +240,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
mainAxisSpacing: AppSpacing.xl,
|
||||
crossAxisSpacing: AppSpacing.xl,
|
||||
),
|
||||
itemCount: _tasks.length,
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = _tasks[index];
|
||||
final task = tasks[index];
|
||||
final credits = task.credits480p != null
|
||||
? task.credits480p.toString()
|
||||
: '50';
|
||||
|
||||
27
lib/features/home/models/ext_config_item.dart
Normal file
@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -144,11 +144,11 @@ class _VideoCardState extends State<VideoCard> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowMedium,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -213,7 +213,7 @@ class _VideoCardState extends State<VideoCard> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 12,
|
||||
color: AppColors.surface,
|
||||
@ -273,11 +273,11 @@ class _VideoCardState extends State<VideoCard> {
|
||||
color: AppColors.primary,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryButtonShadow,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.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/services/user_api.dart';
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../core/user/account_refresh.dart';
|
||||
import '../recharge/payment_webview_screen.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
@ -25,41 +26,17 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.isActive) _fetchAccount();
|
||||
if (widget.isActive) refreshAccount(updateProfile: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ProfileScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -106,8 +83,8 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
icon: LucideIcons.chevron_right,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PaymentWebViewScreen(
|
||||
paymentUrl: 'http://www.petsheroai.xyz/privacy.html',
|
||||
builder: (_) => const PaymentWebViewScreen(
|
||||
paymentUrl: 'https://www.petsheroai.xyz/privacy.html',
|
||||
title: 'Privacy Policy',
|
||||
),
|
||||
),
|
||||
@ -118,13 +95,19 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
icon: LucideIcons.chevron_right,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PaymentWebViewScreen(
|
||||
paymentUrl: 'http://www.petsheroai.xyz/terms.html',
|
||||
builder: (_) => const PaymentWebViewScreen(
|
||||
paymentUrl: 'https://www.petsheroai.xyz/terms.html',
|
||||
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(
|
||||
imageUrl: avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Icon(
|
||||
placeholder: (_, __) => const Icon(
|
||||
LucideIcons.user,
|
||||
size: 40,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
errorWidget: (_, __, ___) => const Icon(
|
||||
LucideIcons.user,
|
||||
size: 40,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
: const Icon(
|
||||
LucideIcons.user,
|
||||
size: 40,
|
||||
color: AppColors.textSecondary,
|
||||
@ -188,7 +171,7 @@ class _ProfileHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
userName ?? 'Guest',
|
||||
userName ?? 'VIP',
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
@ -236,11 +219,11 @@ class _BalanceCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -322,11 +305,13 @@ class _MenuItem extends StatelessWidget {
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color? iconColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -340,11 +325,11 @@ class _MenuItem extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -354,13 +339,257 @@ class _MenuItem extends StatelessWidget {
|
||||
Text(
|
||||
title,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,332 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.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/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 {
|
||||
static final _log = AppLogger('GooglePlayPurchase');
|
||||
|
||||
/// 发起购买并返回服务器凭据(用于三方支付选 GooglePay 时上报 /v1/payment/googlepay)。
|
||||
/// 成功返回 purchaseData(serverVerificationData),取消/失败返回 null。
|
||||
static Future<String?> launchPurchaseAndReturnData(String productId) async {
|
||||
/// 未确认的购买在 Android 上可能不会出现在 queryPastPurchases 中,但会在应用启动时通过
|
||||
/// purchaseStream 重新下发。此处缓存来自 stream 的 pending 购买,供补单合并使用。
|
||||
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 上显式调用 consumePurchase(autoConsume: 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"');
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
_log.d('非 Android,跳过内购');
|
||||
@ -24,22 +339,69 @@ abstract final class GooglePlayPurchaseService {
|
||||
}
|
||||
final response = await iap.queryProductDetails({productId});
|
||||
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;
|
||||
}
|
||||
final product = response.productDetails.first;
|
||||
final completer = Completer<String?>();
|
||||
final completer = Completer<GooglePayPurchaseResult?>();
|
||||
StreamSubscription<List<PurchaseDetails>>? sub;
|
||||
sub = iap.purchaseStream.listen(
|
||||
(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) {
|
||||
if (p.productID != productId) continue;
|
||||
if (p.status == PurchaseStatus.purchased ||
|
||||
p.status == PurchaseStatus.restored) {
|
||||
if (!completer.isCompleted) {
|
||||
final data = p.verificationData.serverVerificationData;
|
||||
iap.completePurchase(p);
|
||||
completer.complete(data);
|
||||
_log.d('购买成功: ${p.toString()}');
|
||||
if (!completer.isCompleted && p is GooglePlayPurchaseDetails) {
|
||||
final b = p.billingClientPurchase;
|
||||
_pendingFromStream[b.orderId] = p;
|
||||
completer.complete(GooglePayPurchaseResult(
|
||||
orderId: b.orderId,
|
||||
payload: GooglePayVerificationPayload(
|
||||
purchaseData: b.originalJson,
|
||||
signature: b.signature,
|
||||
),
|
||||
purchaseDetails: p,
|
||||
));
|
||||
}
|
||||
sub?.cancel();
|
||||
return;
|
||||
@ -52,13 +414,14 @@ abstract final class GooglePlayPurchaseService {
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
onError: (e) {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
sub?.cancel();
|
||||
},
|
||||
);
|
||||
final success = await iap.buyConsumable(
|
||||
purchaseParam: PurchaseParam(productDetails: product),
|
||||
autoConsume: false,
|
||||
);
|
||||
if (!success) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
23
lib/features/recharge/models/google_pay_purchase_result.dart
Normal 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;
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
/// 谷歌支付成功后用于构造 /v1/payment/googlepay 的 body。
|
||||
/// 本类提供 purchaseData(→ merchant)、signature(→ sample);调用方填入 federation(订单 id)、userId(→ 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;
|
||||
}
|
||||
@ -12,7 +12,7 @@ class PaymentMethodItem {
|
||||
final String? subPaymentMethod; // ceremony
|
||||
final String? name; // brigade 展示名称
|
||||
final String? icon; // greylist 图标 URL
|
||||
final bool recommend; // deny 取反为推荐
|
||||
final bool recommend; // deny 为 true 时显示 Recommended
|
||||
|
||||
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
|
||||
return PaymentMethodItem(
|
||||
@ -20,7 +20,7 @@ class PaymentMethodItem {
|
||||
subPaymentMethod: json['ceremony']?.toString(),
|
||||
name: json['brigade']?.toString(),
|
||||
icon: json['greylist']?.toString(),
|
||||
recommend: json['deny'] != true,
|
||||
recommend: json['deny'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
/// 商品 ID(productId)
|
||||
final String productId;
|
||||
|
||||
/// 凭据,用于 POST /v1/payment/googlepay 的 sample/merchant;federation/asset 由调用方填入。
|
||||
final GooglePayVerificationPayload payload;
|
||||
|
||||
/// 原始购买详情,补单成功后用于 [InAppPurchase.instance.completePurchase]。
|
||||
final PurchaseDetails purchaseDetails;
|
||||
}
|
||||