优化:根据新APP优化框架代码
This commit is contained in:
parent
bdab80b2bd
commit
b55faf0db5
@ -7,6 +7,12 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "2.12"
|
"languageVersion": "2.12"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "archive",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/archive-4.0.9",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "args",
|
"name": "args",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/args-2.7.0",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/args-2.7.0",
|
||||||
@ -37,6 +43,12 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.4"
|
"languageVersion": "3.4"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "code_assets",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/code_assets-1.0.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.9"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "collection",
|
"name": "collection",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/collection-1.19.1",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/collection-1.19.1",
|
||||||
@ -91,6 +103,18 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.9"
|
"languageVersion": "3.9"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "glob",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/glob-2.1.3",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hooks",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/hooks-1.0.2",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.10"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "http",
|
"name": "http",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/http-1.6.0",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/http-1.6.0",
|
||||||
@ -103,6 +127,12 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.4"
|
"languageVersion": "3.4"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/image-4.8.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "in_app_purchase",
|
"name": "in_app_purchase",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/in_app_purchase-3.2.3",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/in_app_purchase-3.2.3",
|
||||||
@ -127,6 +157,18 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.9"
|
"languageVersion": "3.9"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "jni",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/jni-1.0.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "jni_flutter",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.3"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "js",
|
"name": "js",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/js-0.7.2",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/js-0.7.2",
|
||||||
@ -145,6 +187,12 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "2.17"
|
"languageVersion": "2.17"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "logging",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/logging-1.3.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.4"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "material_color_utilities",
|
"name": "material_color_utilities",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/material_color_utilities-0.13.0",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/material_color_utilities-0.13.0",
|
||||||
@ -157,12 +205,48 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.5"
|
"languageVersion": "3.5"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "native_toolchain_c",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/native_toolchain_c-0.17.6",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "objective_c",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/objective_c-9.3.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "package_config",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/package_config-2.2.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.4"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "path",
|
"name": "path",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path-1.9.1",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path-1.9.1",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.4"
|
"languageVersion": "3.4"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider-2.1.5",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider_android",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_android-2.3.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider_foundation",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.10"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "path_provider_linux",
|
"name": "path_provider_linux",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1",
|
||||||
@ -181,6 +265,12 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.2"
|
"languageVersion": "3.2"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "petitparser",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/petitparser-7.0.2",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.8"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "platform",
|
"name": "platform",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/platform-3.1.6",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/platform-3.1.6",
|
||||||
@ -205,6 +295,18 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.2"
|
"languageVersion": "3.2"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "posix",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/posix-6.5.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pub_semver",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.4"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shared_preferences",
|
"name": "shared_preferences",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/shared_preferences-2.5.4",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/shared_preferences-2.5.4",
|
||||||
@ -283,6 +385,12 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.1"
|
"languageVersion": "3.1"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "video_thumbnail",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/video_thumbnail-0.5.6",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "2.16"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/web-1.1.1",
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/web-1.1.1",
|
||||||
@ -295,6 +403,18 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.3"
|
"languageVersion": "3.3"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "xml",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/xml-6.6.1",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "yaml",
|
||||||
|
"rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/yaml-3.1.3",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.4"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "client_proxy_framework",
|
"name": "client_proxy_framework",
|
||||||
"rootUri": "../",
|
"rootUri": "../",
|
||||||
|
|||||||
@ -13,14 +13,36 @@
|
|||||||
"facebook_app_events",
|
"facebook_app_events",
|
||||||
"flutter",
|
"flutter",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"in_app_purchase",
|
"in_app_purchase",
|
||||||
"in_app_purchase_android",
|
"in_app_purchase_android",
|
||||||
"logger",
|
"logger",
|
||||||
|
"path_provider",
|
||||||
"play_install_referrer",
|
"play_install_referrer",
|
||||||
"shared_preferences"
|
"shared_preferences",
|
||||||
|
"video_thumbnail"
|
||||||
],
|
],
|
||||||
"devDependencies": []
|
"devDependencies": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "video_thumbnail",
|
||||||
|
"version": "0.5.6",
|
||||||
|
"dependencies": [
|
||||||
|
"flutter"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider",
|
||||||
|
"version": "2.1.5",
|
||||||
|
"dependencies": [
|
||||||
|
"flutter",
|
||||||
|
"path_provider_android",
|
||||||
|
"path_provider_foundation",
|
||||||
|
"path_provider_linux",
|
||||||
|
"path_provider_platform_interface",
|
||||||
|
"path_provider_windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shared_preferences",
|
"name": "shared_preferences",
|
||||||
"version": "2.5.4",
|
"version": "2.5.4",
|
||||||
@ -124,6 +146,56 @@
|
|||||||
"vector_math"
|
"vector_math"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider_windows",
|
||||||
|
"version": "2.3.0",
|
||||||
|
"dependencies": [
|
||||||
|
"ffi",
|
||||||
|
"flutter",
|
||||||
|
"path",
|
||||||
|
"path_provider_platform_interface"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider_platform_interface",
|
||||||
|
"version": "2.1.2",
|
||||||
|
"dependencies": [
|
||||||
|
"flutter",
|
||||||
|
"platform",
|
||||||
|
"plugin_platform_interface"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider_linux",
|
||||||
|
"version": "2.2.1",
|
||||||
|
"dependencies": [
|
||||||
|
"ffi",
|
||||||
|
"flutter",
|
||||||
|
"path",
|
||||||
|
"path_provider_platform_interface",
|
||||||
|
"xdg_directories"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider_foundation",
|
||||||
|
"version": "2.6.0",
|
||||||
|
"dependencies": [
|
||||||
|
"ffi",
|
||||||
|
"flutter",
|
||||||
|
"objective_c",
|
||||||
|
"path_provider_platform_interface"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path_provider_android",
|
||||||
|
"version": "2.3.0",
|
||||||
|
"dependencies": [
|
||||||
|
"flutter",
|
||||||
|
"jni",
|
||||||
|
"jni_flutter",
|
||||||
|
"path_provider_platform_interface"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shared_preferences_windows",
|
"name": "shared_preferences_windows",
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
@ -286,30 +358,70 @@
|
|||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "path_provider_windows",
|
|
||||||
"version": "2.3.0",
|
|
||||||
"dependencies": [
|
|
||||||
"ffi",
|
|
||||||
"flutter",
|
|
||||||
"path",
|
|
||||||
"path_provider_platform_interface"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path_provider_platform_interface",
|
|
||||||
"version": "2.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"flutter",
|
|
||||||
"platform",
|
|
||||||
"plugin_platform_interface"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "path",
|
"name": "path",
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ffi",
|
||||||
|
"version": "2.2.0",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "plugin_platform_interface",
|
||||||
|
"version": "2.1.8",
|
||||||
|
"dependencies": [
|
||||||
|
"meta"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "platform",
|
||||||
|
"version": "3.1.6",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "xdg_directories",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"dependencies": [
|
||||||
|
"meta",
|
||||||
|
"path"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "objective_c",
|
||||||
|
"version": "9.3.0",
|
||||||
|
"dependencies": [
|
||||||
|
"code_assets",
|
||||||
|
"collection",
|
||||||
|
"ffi",
|
||||||
|
"hooks",
|
||||||
|
"logging",
|
||||||
|
"native_toolchain_c",
|
||||||
|
"pub_semver"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "jni_flutter",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"dependencies": [
|
||||||
|
"flutter",
|
||||||
|
"jni"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "jni",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": [
|
||||||
|
"args",
|
||||||
|
"collection",
|
||||||
|
"ffi",
|
||||||
|
"meta",
|
||||||
|
"package_config",
|
||||||
|
"path",
|
||||||
|
"plugin_platform_interface"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "file",
|
"name": "file",
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
@ -325,24 +437,6 @@
|
|||||||
"flutter"
|
"flutter"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "plugin_platform_interface",
|
|
||||||
"version": "2.1.8",
|
|
||||||
"dependencies": [
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path_provider_linux",
|
|
||||||
"version": "2.2.1",
|
|
||||||
"dependencies": [
|
|
||||||
"ffi",
|
|
||||||
"flutter",
|
|
||||||
"path",
|
|
||||||
"path_provider_platform_interface",
|
|
||||||
"xdg_directories"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "json_annotation",
|
"name": "json_annotation",
|
||||||
"version": "4.11.0",
|
"version": "4.11.0",
|
||||||
@ -379,20 +473,53 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ffi",
|
"name": "pub_semver",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "platform",
|
|
||||||
"version": "3.1.6",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "xdg_directories",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
"collection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "native_toolchain_c",
|
||||||
|
"version": "0.17.6",
|
||||||
|
"dependencies": [
|
||||||
|
"code_assets",
|
||||||
|
"glob",
|
||||||
|
"hooks",
|
||||||
|
"logging",
|
||||||
"meta",
|
"meta",
|
||||||
|
"pub_semver"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "logging",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hooks",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"dependencies": [
|
||||||
|
"collection",
|
||||||
|
"crypto",
|
||||||
|
"logging",
|
||||||
|
"meta",
|
||||||
|
"pub_semver",
|
||||||
|
"yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "code_assets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": [
|
||||||
|
"collection",
|
||||||
|
"hooks"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "package_config",
|
||||||
|
"version": "2.2.0",
|
||||||
|
"dependencies": [
|
||||||
"path"
|
"path"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -400,6 +527,69 @@
|
|||||||
"name": "term_glyph",
|
"name": "term_glyph",
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "glob",
|
||||||
|
"version": "2.1.3",
|
||||||
|
"dependencies": [
|
||||||
|
"async",
|
||||||
|
"collection",
|
||||||
|
"file",
|
||||||
|
"path",
|
||||||
|
"string_scanner"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "yaml",
|
||||||
|
"version": "3.1.3",
|
||||||
|
"dependencies": [
|
||||||
|
"collection",
|
||||||
|
"source_span",
|
||||||
|
"string_scanner"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"version": "4.8.0",
|
||||||
|
"dependencies": [
|
||||||
|
"archive",
|
||||||
|
"meta",
|
||||||
|
"xml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "archive",
|
||||||
|
"version": "4.0.9",
|
||||||
|
"dependencies": [
|
||||||
|
"path",
|
||||||
|
"posix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "posix",
|
||||||
|
"version": "6.5.0",
|
||||||
|
"dependencies": [
|
||||||
|
"ffi",
|
||||||
|
"meta",
|
||||||
|
"path"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "xml",
|
||||||
|
"version": "6.6.1",
|
||||||
|
"dependencies": [
|
||||||
|
"collection",
|
||||||
|
"meta",
|
||||||
|
"petitparser"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "petitparser",
|
||||||
|
"version": "7.0.2",
|
||||||
|
"dependencies": [
|
||||||
|
"collection",
|
||||||
|
"meta"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configVersion": 1
|
"configVersion": 1
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package com.funymee.client_proxy_framework
|
package com.funymee.client_proxy_framework
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
import com.facebook.appevents.AppEventsLogger
|
import com.facebook.appevents.AppEventsLogger
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
@ -9,6 +11,7 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
/** Facebook App Events:在引擎侧注册固定 Channel,供 Dart 触发 activateApp。 */
|
/** Facebook App Events:在引擎侧注册固定 Channel,供 Dart 触发 activateApp。 */
|
||||||
class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
private var channel: MethodChannel? = null
|
private var channel: MethodChannel? = null
|
||||||
|
private var deviceMemoryChannel: MethodChannel? = null
|
||||||
private var applicationContext: android.content.Context? = null
|
private var applicationContext: android.content.Context? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
@ -16,11 +19,35 @@ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandle
|
|||||||
val ch = MethodChannel(binding.binaryMessenger, CHANNEL_NAME)
|
val ch = MethodChannel(binding.binaryMessenger, CHANNEL_NAME)
|
||||||
channel = ch
|
channel = ch
|
||||||
ch.setMethodCallHandler(this)
|
ch.setMethodCallHandler(this)
|
||||||
|
|
||||||
|
val memCh = MethodChannel(binding.binaryMessenger, DEVICE_MEMORY_CHANNEL_NAME)
|
||||||
|
deviceMemoryChannel = memCh
|
||||||
|
memCh.setMethodCallHandler { call, memResult ->
|
||||||
|
when (call.method) {
|
||||||
|
"getTotalPhysicalMemoryBytes" -> {
|
||||||
|
try {
|
||||||
|
val ctx = applicationContext ?: run {
|
||||||
|
memResult.error("NO_CONTEXT", "applicationContext null", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
val am = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
|
val mi = ActivityManager.MemoryInfo()
|
||||||
|
am.getMemoryInfo(mi)
|
||||||
|
memResult.success(mi.totalMem.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
memResult.error("MEMORY", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> memResult.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
channel?.setMethodCallHandler(null)
|
channel?.setMethodCallHandler(null)
|
||||||
channel = null
|
channel = null
|
||||||
|
deviceMemoryChannel?.setMethodCallHandler(null)
|
||||||
|
deviceMemoryChannel = null
|
||||||
applicationContext = null
|
applicationContext = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,5 +72,6 @@ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandle
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_NAME = "client_proxy_framework/facebook_sdk"
|
const val CHANNEL_NAME = "client_proxy_framework/facebook_sdk"
|
||||||
|
const val DEVICE_MEMORY_CHANNEL_NAME = "client_proxy_framework/device_memory"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,12 +16,17 @@ export 'src/api/proxy_client.dart';
|
|||||||
export 'src/bootstrap/client_bootstrap.dart';
|
export 'src/bootstrap/client_bootstrap.dart';
|
||||||
export 'src/config/app_config.dart';
|
export 'src/config/app_config.dart';
|
||||||
export 'src/config/attribution_config.dart';
|
export 'src/config/attribution_config.dart';
|
||||||
|
export 'src/config/ext_config_key_schema.dart';
|
||||||
|
export 'src/config/ext_config_models.dart';
|
||||||
|
export 'src/config/ext_config_runtime.dart';
|
||||||
|
export 'src/config/video_home_runtime.dart';
|
||||||
export 'src/config/skin_config.dart';
|
export 'src/config/skin_config.dart';
|
||||||
export 'src/config/field_mapping.dart';
|
export 'src/config/field_mapping.dart';
|
||||||
export 'src/config/default_field_mapping.dart';
|
export 'src/config/default_field_mapping.dart';
|
||||||
export 'src/entities/entities.dart';
|
export 'src/entities/entities.dart';
|
||||||
export 'src/log/app_logger.dart';
|
export 'src/log/app_logger.dart';
|
||||||
export 'src/log/sdk_reminder_log.dart';
|
export 'src/log/sdk_reminder_log.dart';
|
||||||
|
export 'src/media/video_thumbnail_cache.dart';
|
||||||
export 'src/services/adjust_service.dart';
|
export 'src/services/adjust_service.dart';
|
||||||
export 'src/services/analytics_attribution_callbacks.dart';
|
export 'src/services/analytics_attribution_callbacks.dart';
|
||||||
export 'src/services/analytics_service.dart';
|
export 'src/services/analytics_service.dart';
|
||||||
@ -29,6 +34,14 @@ export 'src/services/auth_service.dart';
|
|||||||
export 'src/services/facebook_service.dart';
|
export 'src/services/facebook_service.dart';
|
||||||
export 'src/services/feedback_api.dart';
|
export 'src/services/feedback_api.dart';
|
||||||
export 'src/services/image_api.dart';
|
export 'src/services/image_api.dart';
|
||||||
|
export 'src/services/image_progress_poll.dart';
|
||||||
|
export 'src/services/image_compress.dart';
|
||||||
|
export 'src/services/image_presigned_upload_create_flow.dart';
|
||||||
|
export 'src/services/image_task_history.dart';
|
||||||
|
export 'src/services/task_upload_cover_store.dart';
|
||||||
|
export 'src/services/user_account_refresh.dart';
|
||||||
export 'src/services/payment_api.dart';
|
export 'src/services/payment_api.dart';
|
||||||
|
export 'src/services/payment_flow/payment_flow.dart';
|
||||||
export 'src/services/payment_service.dart';
|
export 'src/services/payment_service.dart';
|
||||||
export 'src/services/user_api.dart';
|
export 'src/services/user_api.dart';
|
||||||
|
export 'src/util/device_memory_profile.dart';
|
||||||
|
|||||||
@ -97,14 +97,14 @@ class ProxyClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送代理请求(返回原始字段的 Map)
|
/// 发送代理请求(返回逻辑字段的 Map)
|
||||||
///
|
///
|
||||||
/// [headers]、[queryParams]、[body] 使用**原始字段名**,
|
/// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**,
|
||||||
/// 框架会按 [AppConfig.fieldMapping] 转为 V2 字段名后发送。
|
/// 框架会按 [AppConfig.fieldMapping] 转为线网字段名后发送。
|
||||||
/// 响应 data 会自动从 V2 转回原始字段名。
|
/// 响应 data 会自动从线网转回逻辑字段名。
|
||||||
///
|
///
|
||||||
/// **请求头(自动注入)**
|
/// **请求头(自动注入)**
|
||||||
/// - [AppConfig.packageName] → 原始字段名 `pkg`(经映射后的请求头键,如 `stakeholder`)
|
/// - [AppConfig.packageName] → 逻辑字段名 `pkg`(再映射为线网请求头键)
|
||||||
/// - 若已 [userToken] 且 [includeUserTokenInHeader] 为 true,则注入 `User_token`
|
/// - 若已 [userToken] 且 [includeUserTokenInHeader] 为 true,则注入 `User_token`
|
||||||
///
|
///
|
||||||
/// 与文档一致:**设备快速登录等无需登录态接口**应传 `includeUserTokenInHeader: false`,
|
/// 与文档一致:**设备快速登录等无需登录态接口**应传 `includeUserTokenInHeader: false`,
|
||||||
@ -176,7 +176,7 @@ class ProxyClient {
|
|||||||
|
|
||||||
/// 发送代理请求并返回实体
|
/// 发送代理请求并返回实体
|
||||||
///
|
///
|
||||||
/// [headers]、[queryParams]、[body] 使用**原始字段名**。
|
/// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**。
|
||||||
/// [entityFactory] 用于将映射后的 data 转换为实体对象。
|
/// [entityFactory] 用于将映射后的 data 转换为实体对象。
|
||||||
///
|
///
|
||||||
/// 参见 [request] 的 [includeUserTokenInHeader] 说明。
|
/// 参见 [request] 的 [includeUserTokenInHeader] 说明。
|
||||||
@ -198,8 +198,18 @@ class ProxyClient {
|
|||||||
includeUserTokenInHeader: includeUserTokenInHeader,
|
includeUserTokenInHeader: includeUserTokenInHeader,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.isSuccess && response.data is Map<String, dynamic>) {
|
if (response.isSuccess) {
|
||||||
final entity = entityFactory(response.data as Map<String, dynamic>);
|
final raw = response.data;
|
||||||
|
if (raw is Map<String, dynamic>) {
|
||||||
|
final entity = entityFactory(raw);
|
||||||
|
return EntityResponse<T>(
|
||||||
|
code: response.code,
|
||||||
|
msg: response.msg,
|
||||||
|
data: entity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 成功但 `data` 为 null、或非对象(如 `true`)时仍构造实体,避免误判为失败。
|
||||||
|
final entity = entityFactory(<String, dynamic>{});
|
||||||
return EntityResponse<T>(
|
return EntityResponse<T>(
|
||||||
code: response.code,
|
code: response.code,
|
||||||
msg: response.msg,
|
msg: response.msg,
|
||||||
|
|||||||
@ -1,59 +1,32 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'default_field_mapping.dart';
|
import 'ext_config_key_schema.dart';
|
||||||
import 'field_mapping.dart';
|
import 'field_mapping.dart';
|
||||||
|
|
||||||
/// 代理请求体字段名配置
|
/// 代理请求最外层 JSON 的**键名**(值为密文或明文;具体线网名由 `skin_config.json` 的 `proxyKeys` 配置)。
|
||||||
class ProxyKeysConfig {
|
class ProxyKeysConfig {
|
||||||
const ProxyKeysConfig({
|
const ProxyKeysConfig({
|
||||||
/// 代理请求体最外层「应用明文」字段的 **键名**(值由框架写入 [AppConfig.appName],与后端约定一致)
|
/// 应用标识明文字段名(值一般为 [AppConfig.appName])
|
||||||
this.appIdField = 'hero_class',
|
this.appIdField = 'appId',
|
||||||
|
this.pathField = 'path',
|
||||||
/// 原始 path 字段名
|
this.methodField = 'method',
|
||||||
this.pathField = 'pet_species',
|
this.headerField = 'headers',
|
||||||
|
this.paramsField = 'params',
|
||||||
/// "POST" 或 "GET" 字段名
|
this.bodyField = 'body',
|
||||||
this.methodField = 'power_level',
|
|
||||||
|
|
||||||
/// 映射后的 Header 字段名(构造 JSON)
|
|
||||||
this.headerField = 'quest_rank',
|
|
||||||
|
|
||||||
/// 映射后的 URL 参数字段名(构造 JSON)
|
|
||||||
this.paramsField = 'battle_score',
|
|
||||||
|
|
||||||
/// V2 包装后的业务数据字段名
|
|
||||||
this.bodyField = 'loyalty_index',
|
|
||||||
|
|
||||||
/// 噪音字段名列表
|
|
||||||
this.noiseKeys = const [
|
this.noiseKeys = const [
|
||||||
'billing_addr',
|
'noise1',
|
||||||
'utm_term',
|
'noise2',
|
||||||
'cluster_id',
|
'noise3',
|
||||||
'lsn_value',
|
'noise4',
|
||||||
'accuracy_val',
|
|
||||||
'dir_path'
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 代理请求体应用明文字段键名(值 = appName)
|
|
||||||
final String appIdField;
|
final String appIdField;
|
||||||
|
|
||||||
/// 原始 path 字段名
|
|
||||||
final String pathField;
|
final String pathField;
|
||||||
|
|
||||||
/// "POST" 或 "GET" 字段名
|
|
||||||
final String methodField;
|
final String methodField;
|
||||||
|
|
||||||
/// 映射后的 Header 字段名(构造 JSON)
|
|
||||||
final String headerField;
|
final String headerField;
|
||||||
|
|
||||||
/// 映射后的 URL 参数字段名(构造 JSON)
|
|
||||||
final String paramsField;
|
final String paramsField;
|
||||||
|
|
||||||
/// V2 包装后的业务数据字段名
|
|
||||||
final String bodyField;
|
final String bodyField;
|
||||||
|
|
||||||
/// 噪音字段名列表
|
|
||||||
final List<String> noiseKeys;
|
final List<String> noiseKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,26 +77,37 @@ abstract class AppConfig {
|
|||||||
/// 代理请求体字段名
|
/// 代理请求体字段名
|
||||||
ProxyKeysConfig get proxyKeys => const ProxyKeysConfig();
|
ProxyKeysConfig get proxyKeys => const ProxyKeysConfig();
|
||||||
|
|
||||||
/// V2 层级路径
|
/// V2 包装:业务数据嵌套路径(键名由 `skin_config.json` 的 `v2.sanctumPath` 配置)
|
||||||
List<String> get v2SanctumPath =>
|
List<String> get v2SanctumPath =>
|
||||||
const ['vault', 'tome', 'codex', 'grimoire', 'sanctum'];
|
const ['wrapper', 'layer', 'payload'];
|
||||||
|
|
||||||
/// V2 包装:噪音字段名
|
/// V2 包装:噪音字段名
|
||||||
List<String> get v2NoiseKeys =>
|
List<String> get v2NoiseKeys =>
|
||||||
const ['roar', 'clash', 'thunder', 'rumble', 'howl', 'growl'];
|
const ['n1', 'n2', 'n3', 'n4', 'n5', 'n6'];
|
||||||
|
|
||||||
/// V2 层级字段名
|
/// V2 最外层层级字段名(与 [v2LevelFixedValue] 组成固定壳)
|
||||||
String get v2LevelField => 'arsenal';
|
String get v2LevelField => 'level';
|
||||||
|
|
||||||
/// V2 层级固定值
|
/// V2 层级固定值
|
||||||
int get v2LevelFixedValue => 4;
|
int get v2LevelFixedValue => 1;
|
||||||
|
|
||||||
/// V2 层级固定值
|
/// V2 层级固定值
|
||||||
Map<String, dynamic> get v2FixedValues {
|
Map<String, dynamic> get v2FixedValues {
|
||||||
return {v2LevelField: v2LevelFixedValue};
|
return {v2LevelField: v2LevelFixedValue};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 字段映射表(原始字段 ↔ V2 字段)
|
/// 字段映射(逻辑名 ↔ 线网名);未换皮时为 [kIdentityFieldMapping],**正式包应在 `skin_config.json` 配置**。
|
||||||
/// 换皮应用若后端 V2 字段名不同,覆盖此方法返回自己的映射
|
FieldMapping get fieldMapping => kIdentityFieldMapping;
|
||||||
FieldMapping get fieldMapping => petsHeroAIFieldMapping;
|
|
||||||
|
/// `common_info.extConfig` 的 JSON 键名(由 `skin_config.extConfig` 配置)。
|
||||||
|
ExtConfigKeySchema get extConfigKeySchema => ExtConfigKeySchema.defaults();
|
||||||
|
|
||||||
|
/// 合并进服务器 extConfig 之前的本地默认值;null 表示不设默认。
|
||||||
|
Map<String, dynamic>? get extConfigDefaults => null;
|
||||||
|
|
||||||
|
/// 视频首页:合并 [ExtConfigData.items] 的 Tab 文案(与接口分类并列;见 `skin_config.videoHome`)。
|
||||||
|
String get videoHomeImagesTabLabel => 'Images';
|
||||||
|
|
||||||
|
/// 为 true 时「images」Tab 排在服务端分类之前;默认 `false`(images 在列表末尾)。
|
||||||
|
bool get videoHomeImagesTabFirst => false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,105 +1,5 @@
|
|||||||
import 'field_mapping.dart';
|
import 'field_mapping.dart';
|
||||||
|
|
||||||
/// petsHeroAI 默认字段映射(原始字段 → 后端字段)
|
/// 历史兼容:曾内置 petsHero 映射表;**线网字段名请只在 `skin_config.json` 的 `fieldMapping` 中维护**。
|
||||||
///
|
@Deprecated('Use kIdentityFieldMapping; configure skin_config.json fieldMapping.')
|
||||||
/// 单一映射表,后端文档给的格式通常与此一致。
|
const FieldMapping petsHeroAIFieldMapping = kIdentityFieldMapping;
|
||||||
/// 不同应用若后端字段名不同,只需提供新的 [FieldMapping] 覆盖此表。
|
|
||||||
const FieldMapping petsHeroAIFieldMapping = FieldMapping({
|
|
||||||
// === 请求头 ===
|
|
||||||
'pkg': 'portal',
|
|
||||||
'User_token': 'knight',
|
|
||||||
|
|
||||||
// === 响应字段 ===
|
|
||||||
'code': 'helm',
|
|
||||||
'msg': 'rampart',
|
|
||||||
'data': 'sidekick',
|
|
||||||
|
|
||||||
// === 通用 query ===
|
|
||||||
'app': 'sentinel',
|
|
||||||
'userId': 'asset',
|
|
||||||
'ch': 'crest',
|
|
||||||
'type': 'accolade',
|
|
||||||
|
|
||||||
// === fast_login body ===
|
|
||||||
'referer': 'digest',
|
|
||||||
'sign': 'resolution',
|
|
||||||
'deviceId': 'origin',
|
|
||||||
|
|
||||||
// === 支付 ===
|
|
||||||
'activityId': 'warrior',
|
|
||||||
'country': 'vambrace',
|
|
||||||
'client': 'filter',
|
|
||||||
'id': 'timing',
|
|
||||||
'filterStatus': 'quest',
|
|
||||||
'orderId': 'federation',
|
|
||||||
'payUrl': 'convert',
|
|
||||||
'productId': 'helm',
|
|
||||||
'paymentMethod': 'resource',
|
|
||||||
'paymentType': 'ceremony',
|
|
||||||
'signature': 'sample',
|
|
||||||
'purchaseData': 'merchant',
|
|
||||||
|
|
||||||
// === 图片/视频 ===
|
|
||||||
'categoryId': 'insignia',
|
|
||||||
'taskId': 'tree',
|
|
||||||
'prompt': 'ledger',
|
|
||||||
'resolution': 'guild',
|
|
||||||
'srcImgUrls': 'commission',
|
|
||||||
'fileName1': 'gateway',
|
|
||||||
'fileName2': 'action',
|
|
||||||
'contentType': 'pauldron',
|
|
||||||
'expectedSize': 'stronghold',
|
|
||||||
'page': 'trophy',
|
|
||||||
'pageSize': 'heatmap',
|
|
||||||
'cursor': 'platoon',
|
|
||||||
'declaration': 'declaration',
|
|
||||||
'quest': 'quest',
|
|
||||||
'imgCount': 'indicator',
|
|
||||||
'aspectRatio': 'caption',
|
|
||||||
'ext': 'nexus',
|
|
||||||
'imgUrl': 'congregation',
|
|
||||||
'poseId': 'profit',
|
|
||||||
'templateId': 'compendium',
|
|
||||||
'notification': 'notification',
|
|
||||||
'allowance': 'allowance',
|
|
||||||
'cosmos': 'cosmos',
|
|
||||||
|
|
||||||
// === 响应 data(后端字段 → 原始字段,通过取反自动生效)===
|
|
||||||
'userToken': 'reevaluate',
|
|
||||||
'credits': 'reveal',
|
|
||||||
'avatar': 'realm',
|
|
||||||
'userName': 'terminal',
|
|
||||||
'countryCode': 'navigate',
|
|
||||||
'extConfig': 'surge',
|
|
||||||
'appFbConfig': 'evolve',
|
|
||||||
'usign': 'retrospect',
|
|
||||||
'creditsRecordUrl': 'conquer',
|
|
||||||
'tgId': 'concession',
|
|
||||||
'tgName': 'defer',
|
|
||||||
'email': 'galaxy',
|
|
||||||
'forcePayCenter': 'upgrade',
|
|
||||||
't2IConfig': 'regulate',
|
|
||||||
'h5UrlConfig': 'pursue',
|
|
||||||
'payCenterUrl': 'switch',
|
|
||||||
'freeBlurTimes': 'vow',
|
|
||||||
'firstRegister': 'equip',
|
|
||||||
'isVip': 'generate',
|
|
||||||
'tags': 'rally',
|
|
||||||
'freeTimes': 'decree',
|
|
||||||
'subScribeValidTime': 'tokenize',
|
|
||||||
'status': 'line',
|
|
||||||
'productList': 'summon',
|
|
||||||
'paymentMethods': 'renew',
|
|
||||||
'guardian': 'guardian',
|
|
||||||
'curriculum': 'curriculum',
|
|
||||||
'forge': 'forge',
|
|
||||||
'tag': 'constrain',
|
|
||||||
'img': 'revenue',
|
|
||||||
'url': 'digitize',
|
|
||||||
'launchImgUrl': 'launchImgUrl',
|
|
||||||
|
|
||||||
// === 反馈 ===
|
|
||||||
'fileName': 'layer',
|
|
||||||
'fileUrls': 'inventory',
|
|
||||||
'content': 'cloak',
|
|
||||||
});
|
|
||||||
|
|||||||
295
lib/src/config/ext_config_key_schema.dart
Normal file
295
lib/src/config/ext_config_key_schema.dart
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// `skin_config.json` 中 `extConfig.keys` / `extConfig.itemKeys` 的描述:
|
||||||
|
/// **逻辑字段** ↔ 远端/common_info 下发的 **JSON 键名列表**(按顺序首个存在的键生效,便于兼容多版本键名)。
|
||||||
|
///
|
||||||
|
/// 可选的 [itemKeysHome] / [itemKeysTask]:同一逻辑名可在**首页展示**与**创建任务/生图**两侧映射到不同线网键(值结构与 [itemKeys] 相同:逻辑名 → 键名列表)。
|
||||||
|
/// 未配置时回退到 [itemKeys] 中对应项(即与旧版单表行为一致)。
|
||||||
|
///
|
||||||
|
/// [taskItemMapping]:`extConfig.items` 每一项在展示/生图前要**对齐** [GET /v1/image/img2video/tasks] 经 [FieldMapping.mapResponse] 后的单项结构([TaskItem.fromJson] 的输入)。
|
||||||
|
/// **键**为任务项上的**目标路径**(点号嵌套,如 `previewVideo.url`、`resolution720p.credits`),**值**为在源对象上按顺序尝试的**源路径**列表(点号表示嵌套)。
|
||||||
|
/// 若未配置或为空,则仍按旧逻辑(先 mapResponse,再猜测 Task 形态或走 [ExtConfigItem.fromJson])。
|
||||||
|
///
|
||||||
|
/// [defaultItemTitleWhenEmpty]:`extConfig.items` 解析后 [ExtConfigItem.title] 仍为空(仅空白)时填入的占位文案(`extConfig.defaultItemTitle`)。
|
||||||
|
///
|
||||||
|
/// 「固定字段」指除兜底图 [imageFixKeys] 外的首页策略与列表项语义字段;`image_fix` 仅走 [itemImageFixKeys],默认 `["image_fix"]`,可在 JSON 中改写。
|
||||||
|
class ExtConfigKeySchema {
|
||||||
|
const ExtConfigKeySchema({
|
||||||
|
required this.showVideoMenuKeys,
|
||||||
|
required this.allowScreenshotKeys,
|
||||||
|
required this.blockScreenshotKeys,
|
||||||
|
required this.allowThirdPartyPaymentKeys,
|
||||||
|
required this.privacyUrlKeys,
|
||||||
|
required this.agreementUrlKeys,
|
||||||
|
required this.itemsKeys,
|
||||||
|
required this.itemImageKeys,
|
||||||
|
required this.itemImageFixKeys,
|
||||||
|
required this.itemImgNeedKeys,
|
||||||
|
required this.itemCostKeys,
|
||||||
|
required this.itemCost480pKeys,
|
||||||
|
required this.itemCost720pKeys,
|
||||||
|
required this.itemTitleKeys,
|
||||||
|
required this.itemTemplateNameKeys,
|
||||||
|
required this.itemTaskTypeKeys,
|
||||||
|
required this.itemParamsKeys,
|
||||||
|
required this.itemDetailKeys,
|
||||||
|
required this.itemVideoUrlKeys,
|
||||||
|
this.itemKeysHome,
|
||||||
|
this.itemKeysTask,
|
||||||
|
this.taskItemMapping,
|
||||||
|
this.itemsApplyFieldMappingBeforeTaskMapping = true,
|
||||||
|
this.defaultItemTitleWhenEmpty = '-',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 是否展示顶部 Video/分类 Tab,并把 items 固定为最后一格 Tab。
|
||||||
|
final List<String> showVideoMenuKeys;
|
||||||
|
|
||||||
|
/// 为 `true` 时表示**允许**截屏(直接读取这些键的布尔值)。
|
||||||
|
final List<String> allowScreenshotKeys;
|
||||||
|
|
||||||
|
/// 为 `true` 时表示**禁止**截屏(与 [allowScreenshotKeys] 互斥推导:本键为 true ⇒ 不允许截屏)。
|
||||||
|
final List<String> blockScreenshotKeys;
|
||||||
|
|
||||||
|
/// 是否允许第三方支付。
|
||||||
|
final List<String> allowThirdPartyPaymentKeys;
|
||||||
|
|
||||||
|
final List<String> privacyUrlKeys;
|
||||||
|
final List<String> agreementUrlKeys;
|
||||||
|
|
||||||
|
/// items 数组所在键名。
|
||||||
|
final List<String> itemsKeys;
|
||||||
|
|
||||||
|
final List<String> itemImageKeys;
|
||||||
|
final List<String> itemImageFixKeys;
|
||||||
|
final List<String> itemImgNeedKeys;
|
||||||
|
final List<String> itemCostKeys;
|
||||||
|
|
||||||
|
/// 生图分辨率 480p 对应积分(可选;缺省由业务侧按 [itemCostKeys] 推导)。
|
||||||
|
final List<String> itemCost480pKeys;
|
||||||
|
|
||||||
|
/// 生图分辨率 720p 对应积分(可选;缺省用 [itemCostKeys] / `cost`)。
|
||||||
|
final List<String> itemCost720pKeys;
|
||||||
|
|
||||||
|
final List<String> itemTitleKeys;
|
||||||
|
|
||||||
|
/// 创建任务用的模板标识(`POST /v1/image/create-task` 的 `templateName` / 换皮 `itinerary`);与展示用 [itemTitleKeys] 分离。
|
||||||
|
final List<String> itemTemplateNameKeys;
|
||||||
|
|
||||||
|
/// 创建任务时的任务类型(`POST /v1/image/create-task` 的 `cipher` / 列表项换皮 `liaison` ↔ 逻辑 `taskType`)。
|
||||||
|
final List<String> itemTaskTypeKeys;
|
||||||
|
|
||||||
|
/// 新业务扩展串(如 animal_expression),与 [itemDetailKeys] 并列用于 [ExtConfigItem.taskExt]。
|
||||||
|
final List<String> itemParamsKeys;
|
||||||
|
final List<String> itemDetailKeys;
|
||||||
|
|
||||||
|
/// 预览/背景视频地址;非空时 [ExtConfigItem.isVideoItem] 为 true(与 [itemImageKeys] 并列)。
|
||||||
|
final List<String> itemVideoUrlKeys;
|
||||||
|
|
||||||
|
/// 首页/列表展示侧:封面图、标题、预览视频等线网键覆盖(逻辑名与 [itemKeys] 一致)。
|
||||||
|
final Map<String, List<String>>? itemKeysHome;
|
||||||
|
|
||||||
|
/// 创建任务/生图侧:模板名、任务类型、params/detail、张数、计费等线网键覆盖。
|
||||||
|
final Map<String, List<String>>? itemKeysTask;
|
||||||
|
|
||||||
|
/// 将 `extConfig.items` 单项**组装**成与 [TaskItem.fromJson] 一致的结构(与 `/v1/image/img2video/tasks` 单项在 mapResponse 后同形)。
|
||||||
|
/// 非空时**优先**于 [itemKeys] 扁平解析。
|
||||||
|
final Map<String, List<String>>? taskItemMapping;
|
||||||
|
|
||||||
|
/// 为 `true`(默认)时:先对单项做 [FieldMapping.mapResponse],再按 [taskItemMapping] 从**逻辑/已换皮**键上取值;为 `false` 时从**原始**单项上按源路径取值(源路径需为线网键名)。
|
||||||
|
final bool itemsApplyFieldMappingBeforeTaskMapping;
|
||||||
|
|
||||||
|
/// `extConfig.items` 解析出 [ExtConfigItem] 后,若 [ExtConfigItem.title] 仅空白,则替换为该字符串(见 `extConfig.defaultItemTitle`)。
|
||||||
|
final String defaultItemTitleWhenEmpty;
|
||||||
|
|
||||||
|
/// 首页展示用线网键:[itemKeysHome] 中该逻辑名非空则用之,否则 [fallbackFromItemKeys](通常为 [itemKeys] 解析结果)。
|
||||||
|
List<String> homeWireKeysFor(
|
||||||
|
String logical,
|
||||||
|
List<String> fallbackFromItemKeys,
|
||||||
|
) {
|
||||||
|
final o = itemKeysHome?[logical];
|
||||||
|
if (o != null && o.isNotEmpty) return o;
|
||||||
|
return fallbackFromItemKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生图/创建任务用线网键:[itemKeysTask] 优先,否则 [fallbackFromItemKeys]。
|
||||||
|
List<String> taskWireKeysFor(
|
||||||
|
String logical,
|
||||||
|
List<String> fallbackFromItemKeys,
|
||||||
|
) {
|
||||||
|
final o = itemKeysTask?[logical];
|
||||||
|
if (o != null && o.isNotEmpty) return o;
|
||||||
|
return fallbackFromItemKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与当前框架内置默认一致(go_run / need_wait、screen、safe_area、san_fang、lucky 等)。
|
||||||
|
factory ExtConfigKeySchema.defaults() {
|
||||||
|
return const ExtConfigKeySchema(
|
||||||
|
showVideoMenuKeys: ['go_run', 'need_wait'],
|
||||||
|
allowScreenshotKeys: ['screen'],
|
||||||
|
blockScreenshotKeys: ['safe_area'],
|
||||||
|
allowThirdPartyPaymentKeys: ['san_fang', 'lucky'],
|
||||||
|
privacyUrlKeys: ['privacy'],
|
||||||
|
agreementUrlKeys: ['agreement'],
|
||||||
|
itemsKeys: ['items'],
|
||||||
|
itemImageKeys: ['image'],
|
||||||
|
itemImageFixKeys: ['image_fix'],
|
||||||
|
itemImgNeedKeys: ['img_need'],
|
||||||
|
itemCostKeys: ['cost'],
|
||||||
|
itemCost480pKeys: ['cost_480p', 'cost480p', 'cost_480'],
|
||||||
|
itemCost720pKeys: ['cost_720p', 'cost720p', 'cost_720'],
|
||||||
|
itemTitleKeys: ['title'],
|
||||||
|
itemTemplateNameKeys: ['templateName', 'template_name'],
|
||||||
|
itemTaskTypeKeys: ['taskType'],
|
||||||
|
itemParamsKeys: ['params'],
|
||||||
|
itemDetailKeys: ['detail'],
|
||||||
|
itemVideoUrlKeys: ['video', 'video_url', 'videoUrl', 'preview_video'],
|
||||||
|
itemKeysHome: null,
|
||||||
|
itemKeysTask: null,
|
||||||
|
taskItemMapping: null,
|
||||||
|
itemsApplyFieldMappingBeforeTaskMapping: true,
|
||||||
|
defaultItemTitleWhenEmpty: '-',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `skin_config` 的 `extConfig` 对象解析;缺少的子块用 [defaults] 补全。
|
||||||
|
factory ExtConfigKeySchema.fromSkinExtConfigJson(Map<String, dynamic>? ext) {
|
||||||
|
if (ext == null) return ExtConfigKeySchema.defaults();
|
||||||
|
final d = ExtConfigKeySchema.defaults();
|
||||||
|
final keys = ext['keys'];
|
||||||
|
final itemKeys = ext['itemKeys'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
List<String> rootList(Map<String, dynamic>? m, String k, List<String> fallback) {
|
||||||
|
if (m == null) return fallback;
|
||||||
|
final raw = m[k];
|
||||||
|
final parsed = _stringList(raw);
|
||||||
|
return parsed.isEmpty ? fallback : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? keyMap;
|
||||||
|
if (keys is Map<String, dynamic>) {
|
||||||
|
keyMap = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> itemField(
|
||||||
|
String k,
|
||||||
|
List<String> fallback,
|
||||||
|
) {
|
||||||
|
if (itemKeys == null) return fallback;
|
||||||
|
return rootList(itemKeys, k, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtConfigKeySchema(
|
||||||
|
showVideoMenuKeys: rootList(keyMap, 'showVideoMenu', d.showVideoMenuKeys),
|
||||||
|
allowScreenshotKeys:
|
||||||
|
rootList(keyMap, 'allowScreenshot', d.allowScreenshotKeys),
|
||||||
|
blockScreenshotKeys:
|
||||||
|
rootList(keyMap, 'blockScreenshot', d.blockScreenshotKeys),
|
||||||
|
allowThirdPartyPaymentKeys: rootList(
|
||||||
|
keyMap,
|
||||||
|
'allowThirdPartyPayment',
|
||||||
|
d.allowThirdPartyPaymentKeys,
|
||||||
|
),
|
||||||
|
privacyUrlKeys: rootList(keyMap, 'privacyUrl', d.privacyUrlKeys),
|
||||||
|
agreementUrlKeys: rootList(keyMap, 'agreementUrl', d.agreementUrlKeys),
|
||||||
|
itemsKeys: rootList(keyMap, 'items', d.itemsKeys),
|
||||||
|
itemImageKeys: itemField('image', d.itemImageKeys),
|
||||||
|
itemImageFixKeys: itemField('imageFix', d.itemImageFixKeys),
|
||||||
|
itemImgNeedKeys: itemField('imgNeed', d.itemImgNeedKeys),
|
||||||
|
itemCostKeys: itemField('cost', d.itemCostKeys),
|
||||||
|
itemCost480pKeys: itemField('cost480p', d.itemCost480pKeys),
|
||||||
|
itemCost720pKeys: itemField('cost720p', d.itemCost720pKeys),
|
||||||
|
itemTitleKeys: itemField('title', d.itemTitleKeys),
|
||||||
|
itemTemplateNameKeys:
|
||||||
|
itemField('templateName', d.itemTemplateNameKeys),
|
||||||
|
itemTaskTypeKeys: itemField('taskType', d.itemTaskTypeKeys),
|
||||||
|
itemParamsKeys: itemField('params', d.itemParamsKeys),
|
||||||
|
itemDetailKeys: itemField('detail', d.itemDetailKeys),
|
||||||
|
itemVideoUrlKeys: itemField('videoUrl', d.itemVideoUrlKeys),
|
||||||
|
itemKeysHome: _optionalLogicalWireMap(ext['itemKeysHome']),
|
||||||
|
itemKeysTask: _optionalLogicalWireMap(ext['itemKeysTask']),
|
||||||
|
taskItemMapping: _optionalLogicalWireMap(ext['taskItemMapping']),
|
||||||
|
itemsApplyFieldMappingBeforeTaskMapping:
|
||||||
|
ext['itemsApplyFieldMappingBeforeTaskMapping'] is bool
|
||||||
|
? ext['itemsApplyFieldMappingBeforeTaskMapping'] as bool
|
||||||
|
: true,
|
||||||
|
defaultItemTitleWhenEmpty: _parseDefaultItemTitle(
|
||||||
|
ext['defaultItemTitle'],
|
||||||
|
d.defaultItemTitleWhenEmpty,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _parseDefaultItemTitle(dynamic raw, String fallback) {
|
||||||
|
if (raw is String && raw.trim().isNotEmpty) return raw.trim();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非空 Map;若解析结果为空则返回 null(与「未配置」一致)。
|
||||||
|
static Map<String, List<String>>? _optionalLogicalWireMap(dynamic raw) {
|
||||||
|
if (raw is! Map) return null;
|
||||||
|
final out = <String, List<String>>{};
|
||||||
|
for (final e in raw.entries) {
|
||||||
|
final k = e.key.toString();
|
||||||
|
final list = _stringList(e.value);
|
||||||
|
if (list.isNotEmpty) out[k] = list;
|
||||||
|
}
|
||||||
|
return out.isEmpty ? null : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _stringList(dynamic raw) {
|
||||||
|
if (raw == null) return [];
|
||||||
|
if (raw is String) return raw.isEmpty ? [] : [raw];
|
||||||
|
if (raw is List) {
|
||||||
|
return raw.map((e) => e.toString()).where((s) => s.isNotEmpty).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `skin_config` 根下 `extConfig` 节:键模式 + 可选默认 JSON(wire 键名,与 common_info 一致)。
|
||||||
|
class SkinExtConfigSection {
|
||||||
|
SkinExtConfigSection({
|
||||||
|
required this.keySchema,
|
||||||
|
this.defaults,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ExtConfigKeySchema keySchema;
|
||||||
|
|
||||||
|
/// 本地默认 extConfig 对象(与线上下发同一套 **键名**);`common_info` 成功后与服务器对象 **浅合并**,服务器键覆盖同名键。
|
||||||
|
final Map<String, dynamic>? defaults;
|
||||||
|
|
||||||
|
static SkinExtConfigSection? fromRootJson(Map<String, dynamic>? ext) {
|
||||||
|
if (ext == null) return null;
|
||||||
|
final schema = ExtConfigKeySchema.fromSkinExtConfigJson(ext);
|
||||||
|
Map<String, dynamic>? def;
|
||||||
|
final raw = ext['defaults'];
|
||||||
|
if (raw is Map<String, dynamic>) {
|
||||||
|
def = Map<String, dynamic>.from(raw);
|
||||||
|
} else if (raw is String && raw.trim().isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final dec = json.decode(raw);
|
||||||
|
if (dec is Map<String, dynamic>) def = dec;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return SkinExtConfigSection(keySchema: schema, defaults: def);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 浅合并:[server] 中非 null 的顶层键覆盖 [base]。
|
||||||
|
static Map<String, dynamic>? mergeDefaults({
|
||||||
|
Map<String, dynamic>? base,
|
||||||
|
Map<String, dynamic>? server,
|
||||||
|
}) {
|
||||||
|
if (base == null || base.isEmpty) {
|
||||||
|
if (server == null || server.isEmpty) return null;
|
||||||
|
return Map<String, dynamic>.from(server);
|
||||||
|
}
|
||||||
|
if (server == null || server.isEmpty) {
|
||||||
|
return Map<String, dynamic>.from(base);
|
||||||
|
}
|
||||||
|
final out = Map<String, dynamic>.from(base);
|
||||||
|
server.forEach((k, v) {
|
||||||
|
out[k] = v;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
521
lib/src/config/ext_config_models.dart
Normal file
521
lib/src/config/ext_config_models.dart
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../entities/image_entities.dart';
|
||||||
|
import 'ext_config_key_schema.dart';
|
||||||
|
import 'field_mapping.dart';
|
||||||
|
|
||||||
|
/// `common_info.extConfig`(解密后的业务 JSON)解析后的**逻辑**视图。
|
||||||
|
///
|
||||||
|
/// JSON **键名**由 [ExtConfigKeySchema](来自 `skin_config.extConfig`)决定;缺省使用 [ExtConfigKeySchema.defaults]。
|
||||||
|
///
|
||||||
|
/// `items` 数组:
|
||||||
|
/// - 若配置了 [ExtConfigKeySchema.taskItemMapping],则按该表把每项**组装**成与 [GET /v1/image/img2video/tasks] 单项(经 [FieldMapping.mapResponse] 后)一致的结构,再 [TaskItem.fromJson] → [ExtConfigItem.fromTaskItem]。
|
||||||
|
/// - 否则先 [FieldMapping.mapResponse];若与 [TaskItem] 形态一致则走 [ExtConfigItem.fromTaskItem],否则 [ExtConfigItem.fromJson](扁平 `image`/`title` 等)。
|
||||||
|
class ExtConfigData {
|
||||||
|
const ExtConfigData({
|
||||||
|
this.showVideoMenu,
|
||||||
|
this.allowScreenshot,
|
||||||
|
this.allowThirdPartyPayment,
|
||||||
|
this.privacyUrl,
|
||||||
|
this.agreementUrl,
|
||||||
|
this.items = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool? showVideoMenu;
|
||||||
|
final bool? allowScreenshot;
|
||||||
|
final bool? allowThirdPartyPayment;
|
||||||
|
final String? privacyUrl;
|
||||||
|
final String? agreementUrl;
|
||||||
|
final List<ExtConfigItem> items;
|
||||||
|
|
||||||
|
bool? get shouldPreventCapture {
|
||||||
|
final a = allowScreenshot;
|
||||||
|
if (a == null) return null;
|
||||||
|
return !a;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ExtConfigData empty() => const ExtConfigData();
|
||||||
|
|
||||||
|
static ExtConfigData parse(
|
||||||
|
String? extConfigJson, {
|
||||||
|
ExtConfigKeySchema? schema,
|
||||||
|
FieldMapping fieldMapping = kIdentityFieldMapping,
|
||||||
|
}) {
|
||||||
|
final map = parseRawMap(extConfigJson);
|
||||||
|
if (map == null) return empty();
|
||||||
|
return fromJson(
|
||||||
|
map,
|
||||||
|
schema: schema ?? ExtConfigKeySchema.defaults(),
|
||||||
|
fieldMapping: fieldMapping,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic>? parseRawMap(String? extConfigJson) {
|
||||||
|
if (extConfigJson == null || extConfigJson.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final decoded = json.decode(extConfigJson);
|
||||||
|
if (decoded is Map<String, dynamic>) return decoded;
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ExtConfigData fromJson(
|
||||||
|
Map<String, dynamic> map, {
|
||||||
|
required ExtConfigKeySchema schema,
|
||||||
|
FieldMapping fieldMapping = kIdentityFieldMapping,
|
||||||
|
}) {
|
||||||
|
final showVideo = _readBoolFromKeys(map, schema.showVideoMenuKeys);
|
||||||
|
|
||||||
|
bool? allowShot;
|
||||||
|
allowShot = _readBoolFromKeys(map, schema.allowScreenshotKeys);
|
||||||
|
if (allowShot == null && schema.blockScreenshotKeys.isNotEmpty) {
|
||||||
|
for (final k in schema.blockScreenshotKeys) {
|
||||||
|
if (!map.containsKey(k)) continue;
|
||||||
|
final block = _readBool(map, k) == true;
|
||||||
|
allowShot = !block;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final third =
|
||||||
|
_readBoolFromKeys(map, schema.allowThirdPartyPaymentKeys);
|
||||||
|
|
||||||
|
final privacy = _readStringFromKeys(map, schema.privacyUrlKeys);
|
||||||
|
final agreement = _readStringFromKeys(map, schema.agreementUrlKeys);
|
||||||
|
|
||||||
|
final rawItems = _firstListFromKeys(map, schema.itemsKeys);
|
||||||
|
final List<ExtConfigItem> items = [];
|
||||||
|
if (rawItems != null) {
|
||||||
|
for (final e in rawItems) {
|
||||||
|
if (e is! Map) continue;
|
||||||
|
final rawItem = Map<String, dynamic>.from(e);
|
||||||
|
final mappedItem = fieldMapping.mapResponse(rawItem);
|
||||||
|
final imgNeedFromMap = ExtConfigItem._readIntFromKeys(
|
||||||
|
mappedItem,
|
||||||
|
schema.taskWireKeysFor('imgNeed', schema.itemImgNeedKeys),
|
||||||
|
);
|
||||||
|
final taskMap = schema.taskItemMapping;
|
||||||
|
if (taskMap != null && taskMap.isNotEmpty) {
|
||||||
|
final src = schema.itemsApplyFieldMappingBeforeTaskMapping
|
||||||
|
? mappedItem
|
||||||
|
: rawItem;
|
||||||
|
final taskJson =
|
||||||
|
buildTaskItemShapeFromExtItemMap(src, taskMap);
|
||||||
|
final task = TaskItem.fromJson(taskJson);
|
||||||
|
items.add(
|
||||||
|
ExtConfigItem.fromTaskItem(
|
||||||
|
task,
|
||||||
|
categoryLabel: '',
|
||||||
|
imgNeedOverride: imgNeedFromMap,
|
||||||
|
).withDefaultTitleIfEmpty(schema),
|
||||||
|
);
|
||||||
|
} else if (_itemMapLooksLikeTaskPayload(mappedItem)) {
|
||||||
|
final task = TaskItem.fromJson(mappedItem);
|
||||||
|
items.add(
|
||||||
|
ExtConfigItem.fromTaskItem(
|
||||||
|
task,
|
||||||
|
categoryLabel: '',
|
||||||
|
imgNeedOverride: imgNeedFromMap,
|
||||||
|
).withDefaultTitleIfEmpty(schema),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
items.add(
|
||||||
|
ExtConfigItem.fromJson(mappedItem, schema: schema)
|
||||||
|
.withDefaultTitleIfEmpty(schema),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtConfigData(
|
||||||
|
showVideoMenu: showVideo,
|
||||||
|
allowScreenshot: allowShot,
|
||||||
|
allowThirdPartyPayment: third,
|
||||||
|
privacyUrl: privacy,
|
||||||
|
agreementUrl: agreement,
|
||||||
|
items: items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与 [ImageApi._parseTasksPayload] 中单项一致:线网嵌套(anchor/slice/factor/span…)映射后的逻辑结构。
|
||||||
|
static bool _itemMapLooksLikeTaskPayload(Map<String, dynamic> m) {
|
||||||
|
return m.containsKey('previewVideo') ||
|
||||||
|
m.containsKey('previewImage') ||
|
||||||
|
m.containsKey('resolution480p') ||
|
||||||
|
m.containsKey('resolution720p') ||
|
||||||
|
m.containsKey('imageUrl') ||
|
||||||
|
m.containsKey('templateUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool? _readBoolFromKeys(Map<String, dynamic> map, List<String> keys) {
|
||||||
|
for (final k in keys) {
|
||||||
|
if (!map.containsKey(k)) continue;
|
||||||
|
return _readBool(map, k);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _readStringFromKeys(
|
||||||
|
Map<String, dynamic> map,
|
||||||
|
List<String> keys,
|
||||||
|
) {
|
||||||
|
for (final k in keys) {
|
||||||
|
if (!map.containsKey(k)) continue;
|
||||||
|
final s = _readString(map, k);
|
||||||
|
if (s != null && s.isNotEmpty) return s;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<dynamic>? _firstListFromKeys(
|
||||||
|
Map<String, dynamic> map,
|
||||||
|
List<String> keys,
|
||||||
|
) {
|
||||||
|
for (final k in keys) {
|
||||||
|
if (!map.containsKey(k)) continue;
|
||||||
|
final v = map[k];
|
||||||
|
if (v is List) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool? _readBool(Map<String, dynamic> map, String key) {
|
||||||
|
final v = map[key];
|
||||||
|
if (v is bool) return v;
|
||||||
|
if (v is String) {
|
||||||
|
if (v == 'true') return true;
|
||||||
|
if (v == 'false') return false;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _readString(Map<String, dynamic> map, String key) {
|
||||||
|
final v = map[key];
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v.isEmpty ? null : v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `extConfig.items` 单项。
|
||||||
|
class ExtConfigItem {
|
||||||
|
const ExtConfigItem({
|
||||||
|
required this.image,
|
||||||
|
this.imageFix,
|
||||||
|
this.imgNeed,
|
||||||
|
required this.cost,
|
||||||
|
this.cost480p,
|
||||||
|
this.cost720p,
|
||||||
|
required this.title,
|
||||||
|
this.templateName,
|
||||||
|
this.taskType,
|
||||||
|
this.params,
|
||||||
|
this.detail,
|
||||||
|
this.videoUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String image;
|
||||||
|
final String? imageFix;
|
||||||
|
final int? imgNeed;
|
||||||
|
final int cost;
|
||||||
|
|
||||||
|
/// 480p 输出预估积分;与 [cost720p] 均为空时由业务按 [cost] 推导。
|
||||||
|
final int? cost480p;
|
||||||
|
|
||||||
|
/// 720p 输出预估积分;为空时业务可用 [cost] 作为 720p。
|
||||||
|
final int? cost720p;
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// 创建任务时传给后端的模板名(与 [app_client] `TaskItem.templateName` → `congregation` 一致);为空时业务侧可回退为 [title]。
|
||||||
|
final String? templateName;
|
||||||
|
|
||||||
|
/// 创建任务时的任务类型(`cipher`;列表项 `liaison` 映射为逻辑 `taskType`)。
|
||||||
|
final String? taskType;
|
||||||
|
final String? params;
|
||||||
|
final String? detail;
|
||||||
|
|
||||||
|
/// 预览视频地址(common_info 或业务自定义键,见 [ExtConfigKeySchema.itemVideoUrlKeys])。
|
||||||
|
/// 与 [image] 并列:有值则优先作全屏循环播放。
|
||||||
|
final String? videoUrl;
|
||||||
|
|
||||||
|
/// 是否按视频展示:`videoUrl` 非空,或 [image]/[imageFix] URL 明显为视频资源。
|
||||||
|
bool get isVideoItem {
|
||||||
|
final v = videoUrl?.trim();
|
||||||
|
if (v != null && v.isNotEmpty) return true;
|
||||||
|
if (_urlLooksLikeVideo(image)) return true;
|
||||||
|
final fix = imageFix?.trim();
|
||||||
|
if (fix != null && fix.isNotEmpty && _urlLooksLikeVideo(fix)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首页 / 顶栏 images Tab 是否计入该项:不要求填满 [TaskItem] 所有字段;
|
||||||
|
/// 至少存在可展示的标题、封面、兜底图、预览视频,或可用于生图的任务标识(模板/类型/params)之一即可。
|
||||||
|
bool get isUsableOnHome {
|
||||||
|
if (title.trim().isNotEmpty) return true;
|
||||||
|
if (image.trim().isNotEmpty) return true;
|
||||||
|
final v = videoUrl?.trim();
|
||||||
|
if (v != null && v.isNotEmpty) return true;
|
||||||
|
final f = imageFix?.trim();
|
||||||
|
if (f != null && f.isNotEmpty) return true;
|
||||||
|
if (templateName?.trim().isNotEmpty ?? false) return true;
|
||||||
|
if (taskType?.trim().isNotEmpty ?? false) return true;
|
||||||
|
if (params?.trim().isNotEmpty ?? false) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅用于 [ExtConfigData.fromJson] 解析 `items`:无展示标题时填入 [ExtConfigKeySchema.defaultItemTitleWhenEmpty]。
|
||||||
|
ExtConfigItem withDefaultTitleIfEmpty(ExtConfigKeySchema schema) {
|
||||||
|
if (title.trim().isNotEmpty) return this;
|
||||||
|
final fill = schema.defaultItemTitleWhenEmpty;
|
||||||
|
if (fill.isEmpty) return this;
|
||||||
|
return ExtConfigItem(
|
||||||
|
image: image,
|
||||||
|
imageFix: imageFix,
|
||||||
|
imgNeed: imgNeed,
|
||||||
|
cost: cost,
|
||||||
|
cost480p: cost480p,
|
||||||
|
cost720p: cost720p,
|
||||||
|
title: fill,
|
||||||
|
templateName: templateName,
|
||||||
|
taskType: taskType,
|
||||||
|
params: params,
|
||||||
|
detail: detail,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _urlLooksLikeVideo(String url) {
|
||||||
|
final u = url.trim().toLowerCase();
|
||||||
|
if (u.isEmpty) return false;
|
||||||
|
const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv'];
|
||||||
|
return hints.any((h) => u.contains(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
String get taskExt => (params != null && params!.isNotEmpty)
|
||||||
|
? params!
|
||||||
|
: (detail ?? '');
|
||||||
|
|
||||||
|
/// 与 [VideoHomeRuntime] 中网关模板列表 → [ExtConfigItem] 的转换一致(便于 `extConfig.defaults.items` 与接口同形)。
|
||||||
|
factory ExtConfigItem.fromTaskItem(
|
||||||
|
TaskItem t, {
|
||||||
|
String categoryLabel = '',
|
||||||
|
int? imgNeedOverride,
|
||||||
|
}) {
|
||||||
|
final name = (t.title ?? t.templateName ?? t.name ?? '').trim();
|
||||||
|
final cat = categoryLabel.trim();
|
||||||
|
final display = cat.isEmpty
|
||||||
|
? (name.isNotEmpty ? name : cat)
|
||||||
|
: (name.isNotEmpty ? '$cat · $name' : cat);
|
||||||
|
final img = (t.imageUrl ?? t.templateUrl ?? '').trim();
|
||||||
|
final v = t.previewVideoUrl?.trim();
|
||||||
|
final backendTemplate = (t.templateName ?? t.name ?? '').trim();
|
||||||
|
final tt = t.taskType?.trim();
|
||||||
|
final extOnly = t.ext?.trim();
|
||||||
|
final c480 = t.credits480p;
|
||||||
|
final c720 = t.credits720p;
|
||||||
|
return ExtConfigItem(
|
||||||
|
image: img,
|
||||||
|
imageFix: null,
|
||||||
|
imgNeed: imgNeedOverride ?? 1,
|
||||||
|
cost: c720 ?? c480 ?? 0,
|
||||||
|
cost480p: c480,
|
||||||
|
cost720p: c720,
|
||||||
|
title: display,
|
||||||
|
templateName:
|
||||||
|
backendTemplate.isNotEmpty ? backendTemplate : null,
|
||||||
|
taskType: tt != null && tt.isNotEmpty ? tt : null,
|
||||||
|
params: extOnly != null && extOnly.isNotEmpty ? extOnly : null,
|
||||||
|
detail: null,
|
||||||
|
videoUrl: v != null && v.isNotEmpty ? v : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线网键解析:[ExtConfigKeySchema.homeWireKeysFor] / [ExtConfigKeySchema.taskWireKeysFor];
|
||||||
|
/// 积分类为 task → home → [itemKeys] 回退链。
|
||||||
|
factory ExtConfigItem.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
required ExtConfigKeySchema schema,
|
||||||
|
}) {
|
||||||
|
final costKeys = schema.taskWireKeysFor(
|
||||||
|
'cost',
|
||||||
|
schema.homeWireKeysFor('cost', schema.itemCostKeys),
|
||||||
|
);
|
||||||
|
final cost480Keys = schema.taskWireKeysFor(
|
||||||
|
'cost480p',
|
||||||
|
schema.homeWireKeysFor('cost480p', schema.itemCost480pKeys),
|
||||||
|
);
|
||||||
|
final cost720Keys = schema.taskWireKeysFor(
|
||||||
|
'cost720p',
|
||||||
|
schema.homeWireKeysFor('cost720p', schema.itemCost720pKeys),
|
||||||
|
);
|
||||||
|
return ExtConfigItem(
|
||||||
|
image: _readStringFromKeys(
|
||||||
|
json,
|
||||||
|
schema.homeWireKeysFor('image', schema.itemImageKeys),
|
||||||
|
) ??
|
||||||
|
'',
|
||||||
|
imageFix: _readStringFromKeysOptional(
|
||||||
|
json,
|
||||||
|
schema.homeWireKeysFor('imageFix', schema.itemImageFixKeys),
|
||||||
|
),
|
||||||
|
imgNeed: _readIntFromKeys(
|
||||||
|
json,
|
||||||
|
schema.taskWireKeysFor('imgNeed', schema.itemImgNeedKeys),
|
||||||
|
),
|
||||||
|
cost: _readIntFromKeys(json, costKeys) ?? 0,
|
||||||
|
cost480p: _readIntFromKeys(json, cost480Keys),
|
||||||
|
cost720p: _readIntFromKeys(json, cost720Keys),
|
||||||
|
title: _readStringFromKeys(
|
||||||
|
json,
|
||||||
|
schema.homeWireKeysFor('title', schema.itemTitleKeys),
|
||||||
|
) ??
|
||||||
|
'',
|
||||||
|
templateName: _readStringFromKeysOptional(
|
||||||
|
json,
|
||||||
|
schema.taskWireKeysFor('templateName', schema.itemTemplateNameKeys),
|
||||||
|
),
|
||||||
|
taskType: _readStringFromKeysOptional(
|
||||||
|
json,
|
||||||
|
schema.taskWireKeysFor('taskType', schema.itemTaskTypeKeys),
|
||||||
|
),
|
||||||
|
params: _readStringFromKeysOptional(
|
||||||
|
json,
|
||||||
|
schema.taskWireKeysFor('params', schema.itemParamsKeys),
|
||||||
|
),
|
||||||
|
detail: _readStringFromKeysOptional(
|
||||||
|
json,
|
||||||
|
schema.taskWireKeysFor('detail', schema.itemDetailKeys),
|
||||||
|
),
|
||||||
|
videoUrl: _readStringFromKeysOptional(
|
||||||
|
json,
|
||||||
|
schema.homeWireKeysFor('videoUrl', schema.itemVideoUrlKeys),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _readStringFromKeys(
|
||||||
|
Map<String, dynamic> map,
|
||||||
|
List<String> keys,
|
||||||
|
) {
|
||||||
|
for (final k in keys) {
|
||||||
|
if (!map.containsKey(k)) continue;
|
||||||
|
final v = map[k];
|
||||||
|
if (v == null) continue;
|
||||||
|
if (v is String) return v.isEmpty ? null : v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _readStringFromKeysOptional(
|
||||||
|
Map<String, dynamic> map,
|
||||||
|
List<String> keys,
|
||||||
|
) {
|
||||||
|
if (keys.isEmpty) return null;
|
||||||
|
for (final k in keys) {
|
||||||
|
if (!map.containsKey(k)) continue;
|
||||||
|
final v = map[k];
|
||||||
|
if (v == null) continue;
|
||||||
|
if (v is String) return v.isEmpty ? null : v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _readIntFromKeys(Map<String, dynamic> map, List<String> keys) {
|
||||||
|
for (final k in keys) {
|
||||||
|
if (!map.containsKey(k)) continue;
|
||||||
|
return _toInt(map[k]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _toInt(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
if (v is String && v.isNotEmpty) return int.tryParse(v);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 `skin_config.extConfig.taskItemMapping` 从 `ext` 单项拼出 [TaskItem.fromJson] 可用的 Map(与 `/v1/image/img2video/tasks` 单项在 mapResponse 后同形)。
|
||||||
|
Map<String, dynamic> buildTaskItemShapeFromExtItemMap(
|
||||||
|
Map<String, dynamic> src,
|
||||||
|
Map<String, List<String>> mapping,
|
||||||
|
) {
|
||||||
|
final out = <String, dynamic>{};
|
||||||
|
for (final e in mapping.entries) {
|
||||||
|
final targetPath = e.key.trim();
|
||||||
|
if (targetPath.isEmpty) continue;
|
||||||
|
final sources = e.value
|
||||||
|
.map((s) => s.toString().trim())
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (sources.isEmpty) continue;
|
||||||
|
dynamic chosen;
|
||||||
|
for (final p in sources) {
|
||||||
|
final v = _mapReadDotPath(src, p);
|
||||||
|
if (v == null) continue;
|
||||||
|
if (v is String && v.trim().isEmpty) continue;
|
||||||
|
chosen = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (chosen == null) continue;
|
||||||
|
final coerced = targetPath.endsWith('.credits')
|
||||||
|
? _coerceCreditsValue(chosen)
|
||||||
|
: chosen;
|
||||||
|
if (coerced == null) continue;
|
||||||
|
_mapSetAtDotPath(out, targetPath, coerced);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _coerceCreditsValue(dynamic v) {
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
if (v is String && v.isNotEmpty) return int.tryParse(v.trim());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _mapReadDotPath(Map<String, dynamic> root, String dotPath) {
|
||||||
|
final parts = dotPath.split('.').where((s) => s.isNotEmpty).toList();
|
||||||
|
if (parts.isEmpty) return null;
|
||||||
|
dynamic cur = root;
|
||||||
|
for (final p in parts) {
|
||||||
|
if (cur is! Map) return null;
|
||||||
|
final m = Map<String, dynamic>.from(cur);
|
||||||
|
cur = m[p];
|
||||||
|
if (cur == null) return null;
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _mapSetAtDotPath(Map<String, dynamic> root, String dotPath, dynamic value) {
|
||||||
|
final parts = dotPath.split('.').where((s) => s.isNotEmpty).toList();
|
||||||
|
if (parts.isEmpty) return;
|
||||||
|
Map<String, dynamic> cur = root;
|
||||||
|
for (var i = 0; i < parts.length - 1; i++) {
|
||||||
|
final p = parts[i];
|
||||||
|
final next = cur[p];
|
||||||
|
if (next is! Map<String, dynamic>) {
|
||||||
|
cur[p] = <String, dynamic>{};
|
||||||
|
}
|
||||||
|
cur = cur[p]! as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
cur[parts.last] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int kExtConfigItemsCategoryId = -1;
|
||||||
|
|
||||||
|
List<T> mergeHomeTabsWithExtConfigItems<T>({
|
||||||
|
required List<T> apiTabs,
|
||||||
|
required ExtConfigData? ext,
|
||||||
|
required T staticTab,
|
||||||
|
}) {
|
||||||
|
if (ext?.showVideoMenu != true) return apiTabs;
|
||||||
|
return [...apiTabs, staticTab];
|
||||||
|
}
|
||||||
63
lib/src/config/ext_config_runtime.dart
Normal file
63
lib/src/config/ext_config_runtime.dart
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
import '../entities/user_entities.dart';
|
||||||
|
import 'ext_config_key_schema.dart';
|
||||||
|
import 'ext_config_models.dart';
|
||||||
|
|
||||||
|
/// [common_info] 拉取成功后的 `extConfig` 运行时视图。
|
||||||
|
///
|
||||||
|
/// 由 [FrameworkAuthService] 在成功解析 [CommonInfoResponse] 后自动更新;
|
||||||
|
/// 宿主用 [data]、[commonInfoSucceeded] 驱动首页 Tab、支付开关、防截屏策略等。
|
||||||
|
abstract final class ExtConfigRuntime {
|
||||||
|
/// 最近一次根据 `extConfig` 解析出的数据;解析失败或尚未拉到 common_info 时为 `null`。
|
||||||
|
static final ValueNotifier<ExtConfigData?> data = ValueNotifier<ExtConfigData?>(null);
|
||||||
|
|
||||||
|
/// `null`:本轮会话尚未结束 common_info 请求;`true`:成功(含 extConfig 解析);`false`:接口失败。
|
||||||
|
///
|
||||||
|
/// **建议**:仅当 `FrameworkAuthService.isLoginComplete` 为 true 且本值为 `true` 时,
|
||||||
|
/// 再展示依赖后台下发的核心业务界面(与用户约定「common_info 成功后才展示应用内容」一致)。
|
||||||
|
static final ValueNotifier<bool?> commonInfoSucceeded =
|
||||||
|
ValueNotifier<bool?>(null);
|
||||||
|
|
||||||
|
/// 在 [FrameworkAuthService] 内调用:common_info 成功。
|
||||||
|
static void applyCommonInfoSuccess(CommonInfoResponse response) {
|
||||||
|
commonInfoSucceeded.value = true;
|
||||||
|
final cfg = ApiClient.instance.config;
|
||||||
|
final schema = cfg.extConfigKeySchema;
|
||||||
|
final defaults = cfg.extConfigDefaults;
|
||||||
|
final serverMap = ExtConfigData.parseRawMap(response.extConfig);
|
||||||
|
final merged = SkinExtConfigSection.mergeDefaults(
|
||||||
|
base: defaults,
|
||||||
|
server: serverMap,
|
||||||
|
);
|
||||||
|
if (merged == null || merged.isEmpty) {
|
||||||
|
data.value = ExtConfigData.empty();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
data.value = ExtConfigData.fromJson(
|
||||||
|
merged,
|
||||||
|
schema: schema,
|
||||||
|
fieldMapping: cfg.fieldMapping,
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ExtConfigRuntime] fromJson failed: $e\n$st');
|
||||||
|
}
|
||||||
|
data.value = ExtConfigData.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 [FrameworkAuthService] 内调用:common_info 失败或无效响应。
|
||||||
|
static void applyCommonInfoFailure() {
|
||||||
|
commonInfoSucceeded.value = false;
|
||||||
|
data.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单元测试或宿主调试时重置会话态。
|
||||||
|
static void resetForTest() {
|
||||||
|
data.value = null;
|
||||||
|
commonInfoSucceeded.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,12 @@
|
|||||||
/// 字段映射配置
|
/// 字段映射配置
|
||||||
///
|
///
|
||||||
/// 单一映射表:**原始字段名 → 后端字段名**(canonical → V2)。
|
/// 单一映射表:**业务逻辑字段名 → 线网(V2)字段名**。
|
||||||
/// 请求时按此表转换,响应时自动取反(V2 → 原始)。
|
/// 请求 [mapRequest]:逻辑名 → 线网名;响应 [mapResponse]:线网名 → 逻辑名。
|
||||||
/// 后端给的映射表通常也不区分方向,直接填入即可。
|
/// 全量表由各宿主 `skin_config.json` 的 `fieldMapping` 提供;未配置时使用 [kIdentityFieldMapping]。
|
||||||
class FieldMapping {
|
class FieldMapping {
|
||||||
const FieldMapping(this.mapping);
|
const FieldMapping(this.mapping);
|
||||||
|
|
||||||
/// 原始字段名 → 后端字段名
|
/// 业务逻辑字段名 → 线网字段名
|
||||||
/// 如 {'deviceId': 'origin', 'userId': 'asset', 'userToken': 'reevaluate'}
|
|
||||||
final Map<String, String> mapping;
|
final Map<String, String> mapping;
|
||||||
|
|
||||||
Map<String, String> get _inverse {
|
Map<String, String> get _inverse {
|
||||||
@ -16,10 +15,10 @@ class FieldMapping {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取映射后的字段名,如果不存在则返回原始字段名
|
/// 逻辑字段名 → 线网字段名;无映射则不变
|
||||||
String mapField(String original) => mapping[original] ?? original;
|
String mapField(String original) => mapping[original] ?? original;
|
||||||
|
|
||||||
/// 获取反向映射的字段名(V2 → 原始),如果不存在则返回原始字段名
|
/// 线网字段名 → 逻辑字段名;无映射则不变
|
||||||
String reverseMapField(String v2) => _inverse[v2] ?? v2;
|
String reverseMapField(String v2) => _inverse[v2] ?? v2;
|
||||||
|
|
||||||
/// 将 Map 的 key 从原始名转为后端名(请求)
|
/// 将 Map 的 key 从原始名转为后端名(请求)
|
||||||
@ -81,3 +80,6 @@ class FieldMapping {
|
|||||||
/// 请求头:用户 token 字段名
|
/// 请求头:用户 token 字段名
|
||||||
String get headerUserTokenField => mapField('User_token');
|
String get headerUserTokenField => mapField('User_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 恒等映射:未配置 `skin_config.json` 的 `fieldMapping` 时使用(逻辑名与线网名相同)。
|
||||||
|
const FieldMapping kIdentityFieldMapping = FieldMapping({});
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import '../log/sdk_reminder_log.dart';
|
|||||||
import '../services/analytics_service.dart';
|
import '../services/analytics_service.dart';
|
||||||
import 'app_config.dart';
|
import 'app_config.dart';
|
||||||
import 'attribution_config.dart';
|
import 'attribution_config.dart';
|
||||||
import 'default_field_mapping.dart';
|
import 'ext_config_key_schema.dart';
|
||||||
import 'field_mapping.dart';
|
import 'field_mapping.dart';
|
||||||
|
|
||||||
/// JSON 换皮配置(单文件描述 API、归因、字段映射等)。
|
/// JSON 换皮配置(单文件描述 API、归因、字段映射等)。
|
||||||
@ -36,9 +36,20 @@ class SkinConfig implements AppConfig {
|
|||||||
required this.fieldMapping,
|
required this.fieldMapping,
|
||||||
required Map<String, String> adjustEvents,
|
required Map<String, String> adjustEvents,
|
||||||
this.analyticsJson,
|
this.analyticsJson,
|
||||||
}) : _adjustEvents = adjustEvents;
|
SkinExtConfigSection? skinExtConfig,
|
||||||
|
required this.videoHomeImagesTabLabel,
|
||||||
|
required this.videoHomeImagesTabFirst,
|
||||||
|
}) : _skinExtConfig = skinExtConfig,
|
||||||
|
_adjustEvents = adjustEvents;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String videoHomeImagesTabLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final bool videoHomeImagesTabFirst;
|
||||||
|
|
||||||
final Map<String, dynamic>? analyticsJson;
|
final Map<String, dynamic>? analyticsJson;
|
||||||
|
final SkinExtConfigSection? _skinExtConfig;
|
||||||
final Map<String, String> _adjustEvents;
|
final Map<String, String> _adjustEvents;
|
||||||
|
|
||||||
/// 从 JSON 文本解析(如 `rootBundle.loadString` 的结果)。
|
/// 从 JSON 文本解析(如 `rootBundle.loadString` 的结果)。
|
||||||
@ -97,22 +108,16 @@ class SkinConfig implements AppConfig {
|
|||||||
|
|
||||||
final proxyKeys = _proxyKeysFromJson(json['proxyKeys']);
|
final proxyKeys = _proxyKeysFromJson(json['proxyKeys']);
|
||||||
final v2 = json['v2'];
|
final v2 = json['v2'];
|
||||||
String v2Level = 'arsenal';
|
String v2Level = 'level';
|
||||||
int v2Fixed = 4;
|
int v2Fixed = 1;
|
||||||
List<String> sanctum = const [
|
List<String> sanctum = const ['wrapper', 'layer', 'payload'];
|
||||||
'vault',
|
|
||||||
'tome',
|
|
||||||
'codex',
|
|
||||||
'grimoire',
|
|
||||||
'sanctum',
|
|
||||||
];
|
|
||||||
List<String> v2Noise = const [
|
List<String> v2Noise = const [
|
||||||
'roar',
|
'n1',
|
||||||
'clash',
|
'n2',
|
||||||
'thunder',
|
'n3',
|
||||||
'rumble',
|
'n4',
|
||||||
'howl',
|
'n5',
|
||||||
'growl',
|
'n6',
|
||||||
];
|
];
|
||||||
if (v2 is Map<String, dynamic>) {
|
if (v2 is Map<String, dynamic>) {
|
||||||
v2Level = v2['levelField'] as String? ?? v2Level;
|
v2Level = v2['levelField'] as String? ?? v2Level;
|
||||||
@ -130,10 +135,10 @@ class SkinConfig implements AppConfig {
|
|||||||
FieldMapping mapping;
|
FieldMapping mapping;
|
||||||
final fmRaw = json['fieldMapping'];
|
final fmRaw = json['fieldMapping'];
|
||||||
if (fmRaw == null) {
|
if (fmRaw == null) {
|
||||||
mapping = petsHeroAIFieldMapping;
|
mapping = kIdentityFieldMapping;
|
||||||
} else if (fmRaw is Map<String, dynamic>) {
|
} else if (fmRaw is Map<String, dynamic>) {
|
||||||
if (fmRaw.isEmpty) {
|
if (fmRaw.isEmpty) {
|
||||||
mapping = petsHeroAIFieldMapping;
|
mapping = kIdentityFieldMapping;
|
||||||
} else {
|
} else {
|
||||||
final m = <String, String>{};
|
final m = <String, String>{};
|
||||||
for (final e in fmRaw.entries) {
|
for (final e in fmRaw.entries) {
|
||||||
@ -156,6 +161,23 @@ class SkinConfig implements AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SkinExtConfigSection? skinExt;
|
||||||
|
final extCfg = json['extConfig'];
|
||||||
|
if (extCfg is Map<String, dynamic>) {
|
||||||
|
skinExt = SkinExtConfigSection.fromRootJson(extCfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
var videoHomeImagesTabLabel = 'Images';
|
||||||
|
var videoHomeImagesTabFirst = false;
|
||||||
|
final vh = json['videoHome'];
|
||||||
|
if (vh is Map<String, dynamic>) {
|
||||||
|
final rawLabel = vh['imagesTabLabel'];
|
||||||
|
if (rawLabel is String && rawLabel.trim().isNotEmpty) {
|
||||||
|
videoHomeImagesTabLabel = rawLabel.trim();
|
||||||
|
}
|
||||||
|
videoHomeImagesTabFirst = vh['imagesTabFirst'] as bool? ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
return SkinConfig._(
|
return SkinConfig._(
|
||||||
appName: name,
|
appName: name,
|
||||||
appId: id,
|
appId: id,
|
||||||
@ -176,6 +198,9 @@ class SkinConfig implements AppConfig {
|
|||||||
fieldMapping: mapping,
|
fieldMapping: mapping,
|
||||||
adjustEvents: events,
|
adjustEvents: events,
|
||||||
analyticsJson: json['analytics'] as Map<String, dynamic>?,
|
analyticsJson: json['analytics'] as Map<String, dynamic>?,
|
||||||
|
skinExtConfig: skinExt,
|
||||||
|
videoHomeImagesTabLabel: videoHomeImagesTabLabel,
|
||||||
|
videoHomeImagesTabFirst: videoHomeImagesTabFirst,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,25 +216,18 @@ class SkinConfig implements AppConfig {
|
|||||||
if (v is! Map<String, dynamic>) {
|
if (v is! Map<String, dynamic>) {
|
||||||
throw FormatException('proxyKeys must be a JSON object');
|
throw FormatException('proxyKeys must be a JSON object');
|
||||||
}
|
}
|
||||||
List<String> noise = const [
|
List<String> noise = const ['noise1', 'noise2', 'noise3', 'noise4'];
|
||||||
'billing_addr',
|
|
||||||
'utm_term',
|
|
||||||
'cluster_id',
|
|
||||||
'lsn_value',
|
|
||||||
'accuracy_val',
|
|
||||||
'dir_path',
|
|
||||||
];
|
|
||||||
final nk = v['noiseKeys'];
|
final nk = v['noiseKeys'];
|
||||||
if (nk is List && nk.isNotEmpty) {
|
if (nk is List && nk.isNotEmpty) {
|
||||||
noise = nk.map((e) => e.toString()).toList();
|
noise = nk.map((e) => e.toString()).toList();
|
||||||
}
|
}
|
||||||
return ProxyKeysConfig(
|
return ProxyKeysConfig(
|
||||||
appIdField: v['appIdField'] as String? ?? 'hero_class',
|
appIdField: v['appIdField'] as String? ?? 'appId',
|
||||||
pathField: v['pathField'] as String? ?? 'pet_species',
|
pathField: v['pathField'] as String? ?? 'path',
|
||||||
methodField: v['methodField'] as String? ?? 'power_level',
|
methodField: v['methodField'] as String? ?? 'method',
|
||||||
headerField: v['headerField'] as String? ?? 'quest_rank',
|
headerField: v['headerField'] as String? ?? 'headers',
|
||||||
paramsField: v['paramsField'] as String? ?? 'battle_score',
|
paramsField: v['paramsField'] as String? ?? 'params',
|
||||||
bodyField: v['bodyField'] as String? ?? 'loyalty_index',
|
bodyField: v['bodyField'] as String? ?? 'body',
|
||||||
noiseKeys: noise,
|
noiseKeys: noise,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -275,6 +293,13 @@ class SkinConfig implements AppConfig {
|
|||||||
@override
|
@override
|
||||||
final FieldMapping fieldMapping;
|
final FieldMapping fieldMapping;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ExtConfigKeySchema get extConfigKeySchema =>
|
||||||
|
_skinExtConfig?.keySchema ?? ExtConfigKeySchema.defaults();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? get extConfigDefaults => _skinExtConfig?.defaults;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> get v2FixedValues => {v2LevelField: v2LevelFixedValue};
|
Map<String, dynamic> get v2FixedValues => {v2LevelField: v2LevelFixedValue};
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,10 @@
|
|||||||
"iosAppType": "HIOS",
|
"iosAppType": "HIOS",
|
||||||
"androidAppType": "HAndroid"
|
"androidAppType": "HAndroid"
|
||||||
},
|
},
|
||||||
|
"videoHome": {
|
||||||
|
"imagesTabLabel": "Images",
|
||||||
|
"imagesTabFirst": false
|
||||||
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"preBaseUrl": "https://pre-api.example.com",
|
"preBaseUrl": "https://pre-api.example.com",
|
||||||
"prodBaseUrl": "https://api.example.com",
|
"prodBaseUrl": "https://api.example.com",
|
||||||
@ -17,24 +21,24 @@
|
|||||||
"aesKey": "1234567890123456"
|
"aesKey": "1234567890123456"
|
||||||
},
|
},
|
||||||
"proxyKeys": {
|
"proxyKeys": {
|
||||||
"appIdField": "hero_class",
|
"appIdField": "appId",
|
||||||
"pathField": "pet_species",
|
"pathField": "path",
|
||||||
"methodField": "power_level",
|
"methodField": "method",
|
||||||
"headerField": "quest_rank",
|
"headerField": "headers",
|
||||||
"paramsField": "battle_score",
|
"paramsField": "params",
|
||||||
"bodyField": "loyalty_index",
|
"bodyField": "body",
|
||||||
"noiseKeys": ["billing_addr", "utm_term", "cluster_id", "lsn_value", "accuracy_val", "dir_path"]
|
"noiseKeys": ["noise1", "noise2", "noise3", "noise4"]
|
||||||
},
|
},
|
||||||
"v2": {
|
"v2": {
|
||||||
"levelField": "arsenal",
|
"levelField": "level",
|
||||||
"levelFixedValue": 4,
|
"levelFixedValue": 1,
|
||||||
"sanctumPath": ["vault", "tome", "codex", "grimoire", "sanctum"],
|
"sanctumPath": ["wrapper", "layer", "payload"],
|
||||||
"noiseKeys": ["roar", "clash", "thunder", "rumble", "howl", "growl"]
|
"noiseKeys": ["n1", "n2", "n3", "n4", "n5", "n6"]
|
||||||
},
|
},
|
||||||
"fieldMapping": {
|
"fieldMapping": {
|
||||||
"code": "helm",
|
"code": "httpCode",
|
||||||
"msg": "rampart",
|
"msg": "message",
|
||||||
"data": "sidekick"
|
"data": "payload"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"debugLogs": false,
|
"debugLogs": false,
|
||||||
@ -53,5 +57,49 @@
|
|||||||
"adjustEvents": {
|
"adjustEvents": {
|
||||||
"register": "abc123",
|
"register": "abc123",
|
||||||
"purchase": "def456"
|
"purchase": "def456"
|
||||||
|
},
|
||||||
|
"extConfig": {
|
||||||
|
"keys": {
|
||||||
|
"showVideoMenu": ["go_run", "need_wait"],
|
||||||
|
"allowScreenshot": ["screen"],
|
||||||
|
"blockScreenshot": ["safe_area"],
|
||||||
|
"allowThirdPartyPayment": ["san_fang", "lucky"],
|
||||||
|
"privacyUrl": ["privacy"],
|
||||||
|
"agreementUrl": ["agreement"],
|
||||||
|
"items": ["items"]
|
||||||
|
},
|
||||||
|
"itemKeys": {
|
||||||
|
"image": ["image"],
|
||||||
|
"imageFix": ["image_fix"],
|
||||||
|
"imgNeed": ["img_need"],
|
||||||
|
"cost": ["cost"],
|
||||||
|
"title": ["title"],
|
||||||
|
"params": ["params"],
|
||||||
|
"detail": ["detail"],
|
||||||
|
"videoUrl": ["video", "video_url", "videoUrl", "preview_video"]
|
||||||
|
},
|
||||||
|
"itemKeysHome": {},
|
||||||
|
"itemKeysTask": {},
|
||||||
|
"itemsApplyFieldMappingBeforeTaskMapping": true,
|
||||||
|
"defaultItemTitle": "-",
|
||||||
|
"taskItemMapping": {
|
||||||
|
"imageUrl": ["imageUrl", "image"],
|
||||||
|
"previewImage.url": ["previewImage.url", "image"],
|
||||||
|
"previewVideo.url": ["previewVideo.url", "anchor.altitude"],
|
||||||
|
"title": ["title", "interval"],
|
||||||
|
"templateName": ["templateName", "itinerary"],
|
||||||
|
"taskType": ["taskType", "liaison"],
|
||||||
|
"ext": ["ext", "profile"],
|
||||||
|
"resolution480p.credits": ["resolution480p.credits", "span.padding", "cost"],
|
||||||
|
"resolution720p.credits": ["resolution720p.credits", "factor.padding", "cost"]
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"go_run": false,
|
||||||
|
"screen": true,
|
||||||
|
"san_fang": false,
|
||||||
|
"privacy": "https://example.com/privacy",
|
||||||
|
"agreement": "https://example.com/terms",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
319
lib/src/config/video_home_runtime.dart
Normal file
319
lib/src/config/video_home_runtime.dart
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
import '../entities/image_entities.dart';
|
||||||
|
import '../services/image_api.dart';
|
||||||
|
import 'ext_config_models.dart';
|
||||||
|
import 'ext_config_runtime.dart';
|
||||||
|
|
||||||
|
/// 与接口返回的分类并列的「虚拟」Tab:对应 [ExtConfigData.items](文案见 [AppConfig.videoHomeImagesTabLabel])。
|
||||||
|
class VideoHomeTab {
|
||||||
|
const VideoHomeTab._({required this.label, this.categoryId});
|
||||||
|
|
||||||
|
factory VideoHomeTab.images({required String label}) =>
|
||||||
|
VideoHomeTab._(label: label, categoryId: null);
|
||||||
|
|
||||||
|
factory VideoHomeTab.network({
|
||||||
|
required String label,
|
||||||
|
required int categoryId,
|
||||||
|
}) =>
|
||||||
|
VideoHomeTab._(label: label, categoryId: categoryId);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// `null` 表示 images(extConfig.items);非 null 为服务端分类 id,需请求模板列表。
|
||||||
|
final int? categoryId;
|
||||||
|
|
||||||
|
bool get isImages => categoryId == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `common_info` 成功且 [ExtConfigData.showVideoMenu] 为 true 时:拉取分类列表,合并 extConfig.items 为 images Tab,
|
||||||
|
/// 默认选中第一个**服务端**分类;非 images Tab 在切换时按需请求 [ImageApi.getImg2VideoTasks](`GET /v1/image/img2video/tasks`)。
|
||||||
|
///
|
||||||
|
/// 顶栏需 `go_run` / `need_wait` 等为 true(见 `extConfig.keys.showVideoMenu`)才会 [hydrateAfterCommonInfo];否则 [reset] 且无 images Tab。
|
||||||
|
abstract final class VideoHomeRuntime {
|
||||||
|
VideoHomeRuntime._();
|
||||||
|
|
||||||
|
static final ValueNotifier<VideoHomeSnapshot> snapshot =
|
||||||
|
ValueNotifier<VideoHomeSnapshot>(const VideoHomeSnapshot());
|
||||||
|
|
||||||
|
/// 当前选中的**分类** Tab 索引(与 [VideoHomeSnapshot.tabs] 对齐)。
|
||||||
|
static final ValueNotifier<int> selectedTabIndex = ValueNotifier<int>(0);
|
||||||
|
|
||||||
|
static String? _lastUserId;
|
||||||
|
static String? _lastApp;
|
||||||
|
|
||||||
|
/// 在 [ExtConfigRuntime.applyCommonInfoSuccess] 之后由 [FrameworkAuthService] 调用。
|
||||||
|
static Future<void> hydrateAfterCommonInfo({
|
||||||
|
required String userId,
|
||||||
|
required String app,
|
||||||
|
}) async {
|
||||||
|
final ext = ExtConfigRuntime.data.value;
|
||||||
|
if (ext?.showVideoMenu != true || userId.isEmpty) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUserId = userId;
|
||||||
|
_lastApp = app;
|
||||||
|
|
||||||
|
final cfg = ApiClient.instance.config;
|
||||||
|
final imagesLabel = cfg.videoHomeImagesTabLabel;
|
||||||
|
final imagesFirst = cfg.videoHomeImagesTabFirst;
|
||||||
|
|
||||||
|
final prev = snapshot.value;
|
||||||
|
/// [hydrateAfterCommonInfo] 开始时顶栏分类下标;用于区分「首次默认」与用户在 `await` 期间已切换 tab。
|
||||||
|
final selectedIndexBeforeHydrate = selectedTabIndex.value;
|
||||||
|
snapshot.value = VideoHomeSnapshot(
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
tabs: prev.tabs,
|
||||||
|
networkItemsByCategoryId: prev.networkItemsByCategoryId,
|
||||||
|
loadingCategoryIds: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
List<ExtConfigItem> visibleExtItems(ExtConfigData? e) =>
|
||||||
|
e?.items.where((it) => it.isUsableOnHome).toList() ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final catRes = await ImageApi.getCategoryList();
|
||||||
|
final extItems = visibleExtItems(ext);
|
||||||
|
final hasImagesTab = extItems.isNotEmpty;
|
||||||
|
|
||||||
|
List<CategoryItem> apiCategories = [];
|
||||||
|
if (catRes.isSuccess && catRes.data?.categories != null) {
|
||||||
|
apiCategories = catRes.data!.categories!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final withId = apiCategories.where((c) => c.id != null).toList();
|
||||||
|
|
||||||
|
final tabs = <VideoHomeTab>[];
|
||||||
|
void pushImages() {
|
||||||
|
if (hasImagesTab) {
|
||||||
|
tabs.add(VideoHomeTab.images(label: imagesLabel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void pushNetwork() {
|
||||||
|
for (final c in withId) {
|
||||||
|
final id = c.id!;
|
||||||
|
final name = c.name?.trim();
|
||||||
|
final label =
|
||||||
|
name != null && name.isNotEmpty ? name : 'Category $id';
|
||||||
|
tabs.add(VideoHomeTab.network(label: label, categoryId: id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagesFirst) {
|
||||||
|
pushImages();
|
||||||
|
pushNetwork();
|
||||||
|
} else {
|
||||||
|
pushNetwork();
|
||||||
|
pushImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabs.isEmpty) {
|
||||||
|
final msg = !catRes.isSuccess
|
||||||
|
? (catRes.msg.isNotEmpty ? catRes.msg : 'Category list failed')
|
||||||
|
: null;
|
||||||
|
snapshot.value = VideoHomeSnapshot(
|
||||||
|
loading: false,
|
||||||
|
error: msg,
|
||||||
|
tabs: const [],
|
||||||
|
networkItemsByCategoryId: const {},
|
||||||
|
loadingCategoryIds: const {},
|
||||||
|
);
|
||||||
|
selectedTabIndex.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若已有并发 hydrate 先写入了非空 tabs,本次只 clamp,不强制默认(避免覆盖用户已选)。
|
||||||
|
final concurrentTabsAlready = snapshot.value.tabs.isNotEmpty;
|
||||||
|
|
||||||
|
snapshot.value = VideoHomeSnapshot(
|
||||||
|
loading: false,
|
||||||
|
error: catRes.isSuccess ? null : catRes.msg,
|
||||||
|
tabs: tabs,
|
||||||
|
networkItemsByCategoryId:
|
||||||
|
_mergeNetworkItemsByCategoryId(prev.networkItemsByCategoryId, tabs),
|
||||||
|
loadingCategoryIds: const {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 首次从「无分类」建 tabs 时用默认(首个网络分类);再次 hydrate 时保留当前分类。
|
||||||
|
// 首次若在 await 分类接口期间用户已切到最后一个 images 等 tab,不得再强制改回默认。
|
||||||
|
final defaultIdx = _defaultTabIndex(tabs);
|
||||||
|
final int nextTabIdx;
|
||||||
|
if (concurrentTabsAlready) {
|
||||||
|
nextTabIdx =
|
||||||
|
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
|
||||||
|
} else if (prev.tabs.isEmpty) {
|
||||||
|
final userChangedDuringAwait =
|
||||||
|
selectedTabIndex.value != selectedIndexBeforeHydrate;
|
||||||
|
if (userChangedDuringAwait) {
|
||||||
|
nextTabIdx =
|
||||||
|
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
|
||||||
|
} else {
|
||||||
|
nextTabIdx = defaultIdx.clamp(0, tabs.length - 1).toInt();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextTabIdx =
|
||||||
|
selectedTabIndex.value.clamp(0, tabs.length - 1).toInt();
|
||||||
|
}
|
||||||
|
selectedTabIndex.value = nextTabIdx;
|
||||||
|
unawaited(ensureTabItems(selectedTabIndex.value));
|
||||||
|
} catch (e, st) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[VideoHomeRuntime] hydrate failed: $e\n$st');
|
||||||
|
}
|
||||||
|
snapshot.value = VideoHomeSnapshot(
|
||||||
|
loading: false,
|
||||||
|
error: e.toString(),
|
||||||
|
tabs: const [],
|
||||||
|
networkItemsByCategoryId: const {},
|
||||||
|
loadingCategoryIds: const {},
|
||||||
|
);
|
||||||
|
selectedTabIndex.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第一个**服务端**分类;若仅有 images Tab 则为 0。
|
||||||
|
static int _defaultTabIndex(List<VideoHomeTab> tabs) {
|
||||||
|
final i = tabs.indexWhere((t) => !t.isImages);
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新拉取分类列表后,保留仍存在于新 [tabs] 中的分类模板缓存,避免展平页数骤减触发首页 [PageView] 误跳转。
|
||||||
|
static Map<int, List<ExtConfigItem>> _mergeNetworkItemsByCategoryId(
|
||||||
|
Map<int, List<ExtConfigItem>> previous,
|
||||||
|
List<VideoHomeTab> tabs,
|
||||||
|
) {
|
||||||
|
final validIds = <int>{};
|
||||||
|
for (final t in tabs) {
|
||||||
|
if (!t.isImages && t.categoryId != null) {
|
||||||
|
validIds.add(t.categoryId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (validIds.isEmpty || previous.isEmpty) return const {};
|
||||||
|
final out = <int, List<ExtConfigItem>>{};
|
||||||
|
for (final e in previous.entries) {
|
||||||
|
if (validIds.contains(e.key)) {
|
||||||
|
out[e.key] = e.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确保当前 Tab 已有模板列表(images 用 ext;网络分类按需请求)。
|
||||||
|
static Future<void> ensureTabItems(int index) async {
|
||||||
|
final uid = _lastUserId;
|
||||||
|
final app = _lastApp;
|
||||||
|
if (uid == null || uid.isEmpty || app == null || app.isEmpty) return;
|
||||||
|
|
||||||
|
final snap = snapshot.value;
|
||||||
|
if (index < 0 || index >= snap.tabs.length) return;
|
||||||
|
final tab = snap.tabs[index];
|
||||||
|
if (tab.isImages) return;
|
||||||
|
|
||||||
|
final id = tab.categoryId!;
|
||||||
|
if (snap.networkItemsByCategoryId.containsKey(id)) return;
|
||||||
|
if (snap.loadingCategoryIds.contains(id)) return;
|
||||||
|
|
||||||
|
snapshot.value = snap.copyWith(
|
||||||
|
loadingCategoryIds: {...snap.loadingCategoryIds, id},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final r = await ImageApi.getImg2VideoTasks(categoryId: id);
|
||||||
|
|
||||||
|
final cur = snapshot.value;
|
||||||
|
if (index >= cur.tabs.length || cur.tabs[index].categoryId != id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final label = cur.tabs[index].label;
|
||||||
|
if (!r.isSuccess || r.data?.tasks == null) {
|
||||||
|
snapshot.value = cur.copyWith(
|
||||||
|
loadingCategoryIds: {...cur.loadingCategoryIds}..remove(id),
|
||||||
|
networkItemsByCategoryId: {
|
||||||
|
...cur.networkItemsByCategoryId,
|
||||||
|
id: const [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final list = <ExtConfigItem>[];
|
||||||
|
for (final t in r.data!.tasks!) {
|
||||||
|
list.add(ExtConfigItem.fromTaskItem(t, categoryLabel: label));
|
||||||
|
}
|
||||||
|
|
||||||
|
final cur2 = snapshot.value;
|
||||||
|
snapshot.value = cur2.copyWith(
|
||||||
|
loadingCategoryIds: {...cur2.loadingCategoryIds}..remove(id),
|
||||||
|
networkItemsByCategoryId: {
|
||||||
|
...cur2.networkItemsByCategoryId,
|
||||||
|
id: list,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[VideoHomeRuntime] ensureTabItems: $e\n$st');
|
||||||
|
}
|
||||||
|
final cur = snapshot.value;
|
||||||
|
snapshot.value = cur.copyWith(
|
||||||
|
loadingCategoryIds: {...cur.loadingCategoryIds}..remove(id),
|
||||||
|
networkItemsByCategoryId: {
|
||||||
|
...cur.networkItemsByCategoryId,
|
||||||
|
id: const [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reset() {
|
||||||
|
_lastUserId = null;
|
||||||
|
_lastApp = null;
|
||||||
|
snapshot.value = const VideoHomeSnapshot();
|
||||||
|
selectedTabIndex.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoHomeSnapshot {
|
||||||
|
const VideoHomeSnapshot({
|
||||||
|
this.loading = false,
|
||||||
|
this.error,
|
||||||
|
this.tabs = const [],
|
||||||
|
this.networkItemsByCategoryId = const {},
|
||||||
|
this.loadingCategoryIds = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool loading;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
/// 含 images(ext)与接口分类;顺序由 [AppConfig.videoHomeImagesTabFirst] 决定。
|
||||||
|
final List<VideoHomeTab> tabs;
|
||||||
|
|
||||||
|
/// 仅缓存**服务端**分类 id → 模板列表。
|
||||||
|
final Map<int, List<ExtConfigItem>> networkItemsByCategoryId;
|
||||||
|
|
||||||
|
final Set<int> loadingCategoryIds;
|
||||||
|
|
||||||
|
VideoHomeSnapshot copyWith({
|
||||||
|
bool? loading,
|
||||||
|
String? error,
|
||||||
|
List<VideoHomeTab>? tabs,
|
||||||
|
Map<int, List<ExtConfigItem>>? networkItemsByCategoryId,
|
||||||
|
Set<int>? loadingCategoryIds,
|
||||||
|
}) {
|
||||||
|
return VideoHomeSnapshot(
|
||||||
|
loading: loading ?? this.loading,
|
||||||
|
error: error ?? this.error,
|
||||||
|
tabs: tabs ?? this.tabs,
|
||||||
|
networkItemsByCategoryId:
|
||||||
|
networkItemsByCategoryId ?? this.networkItemsByCategoryId,
|
||||||
|
loadingCategoryIds: loadingCategoryIds ?? this.loadingCategoryIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lib/src/entities/credits_balance_parse.dart
Normal file
26
lib/src/entities/credits_balance_parse.dart
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/// 解析用户积分余额(`credit` / `credits`)。
|
||||||
|
///
|
||||||
|
/// 换皮映射下余额多为 `credit`(文档 wire `export` → `credit`)。
|
||||||
|
/// `credits`/`padding` 在部分接口里可能是 int 余额,也可能是嵌套的积分配置
|
||||||
|
/// `Map`(如 fast_login 里 `padding: {}`),不能把 Map 当数字解析。
|
||||||
|
int? parseUserCreditsBalance(Map<String, dynamic> json) {
|
||||||
|
int? toInt(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
final i = int.tryParse(value);
|
||||||
|
if (i != null) return i;
|
||||||
|
final d = double.tryParse(value);
|
||||||
|
if (d != null) return d.round();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final credit = toInt(json['credit']);
|
||||||
|
if (credit != null) return credit;
|
||||||
|
|
||||||
|
final credits = json['credits'];
|
||||||
|
if (credits is Map) return null;
|
||||||
|
return toInt(credits);
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
|
export 'credits_balance_parse.dart';
|
||||||
export 'entity.dart';
|
export 'entity.dart';
|
||||||
export 'feedback_entities.dart';
|
export 'feedback_entities.dart';
|
||||||
|
export 'gallery_task_models.dart';
|
||||||
export 'image_entities.dart';
|
export 'image_entities.dart';
|
||||||
export 'payment_entities.dart';
|
export 'payment_entities.dart';
|
||||||
|
export 'task_id_parse.dart';
|
||||||
export 'user_entities.dart';
|
export 'user_entities.dart';
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'entity.dart';
|
import 'entity.dart';
|
||||||
|
|
||||||
/// 反馈预签名上传 URL 响应
|
/// 反馈预签名上传 URL 响应
|
||||||
@ -5,17 +7,63 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
|
|||||||
FeedbackUploadPresignedUrlResponse({
|
FeedbackUploadPresignedUrlResponse({
|
||||||
this.uploadUrl,
|
this.uploadUrl,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
|
this.putHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? uploadUrl;
|
final String? uploadUrl;
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
|
|
||||||
|
/// 与 [UploadPresignedUrlResponse.putHeaders] 一致:PUT 到对象存储时的额外头。
|
||||||
|
final Map<String, String>? putHeaders;
|
||||||
|
|
||||||
|
static String _headerValueToString(dynamic v) {
|
||||||
|
if (v == null) return '';
|
||||||
|
if (v is String) return v;
|
||||||
|
if (v is num || v is bool) return v.toString();
|
||||||
|
if (v is List) {
|
||||||
|
return v.map(_headerValueToString).where((s) => s.isNotEmpty).join(',');
|
||||||
|
}
|
||||||
|
if (v is Map) {
|
||||||
|
return jsonEncode(v);
|
||||||
|
}
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, String>? _parsePutHeaders(Map<String, dynamic> json) {
|
||||||
|
final raw = json['putHeaders'] ??
|
||||||
|
json['headers'] ??
|
||||||
|
json['uploadHeaders'] ??
|
||||||
|
json['requiredHeaders'];
|
||||||
|
|
||||||
|
if (raw is List) {
|
||||||
|
final out = <String, String>{};
|
||||||
|
for (final item in raw) {
|
||||||
|
if (item is! Map) continue;
|
||||||
|
final m = Map<String, dynamic>.from(item);
|
||||||
|
final name = m['name'] ?? m['Name'] ?? m['key'] ?? m['Key'];
|
||||||
|
final value = m['value'] ?? m['Value'];
|
||||||
|
if (name == null) continue;
|
||||||
|
out[name.toString()] = _headerValueToString(value);
|
||||||
|
}
|
||||||
|
return out.isEmpty ? null : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw is! Map) return null;
|
||||||
|
final map = Map<String, dynamic>.from(raw);
|
||||||
|
final out = <String, String>{};
|
||||||
|
for (final e in map.entries) {
|
||||||
|
out[e.key.toString()] = _headerValueToString(e.value);
|
||||||
|
}
|
||||||
|
return out.isEmpty ? null : out;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory FeedbackUploadPresignedUrlResponse.fromJson(
|
factory FeedbackUploadPresignedUrlResponse.fromJson(
|
||||||
Map<String, dynamic> json) {
|
Map<String, dynamic> json) {
|
||||||
return FeedbackUploadPresignedUrlResponse(
|
return FeedbackUploadPresignedUrlResponse(
|
||||||
uploadUrl: json['uploadUrl'] as String?,
|
uploadUrl: json['uploadUrl'] as String?,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
|
putHeaders: _parsePutHeaders(json),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,6 +71,7 @@ class FeedbackUploadPresignedUrlResponse extends Entity {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'uploadUrl': uploadUrl,
|
'uploadUrl': uploadUrl,
|
||||||
'filePath': filePath,
|
'filePath': filePath,
|
||||||
|
if (putHeaders != null) 'putHeaders': putHeaders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
292
lib/src/entities/gallery_task_models.dart
Normal file
292
lib/src/entities/gallery_task_models.dart
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
// Logic aligned with app_client gallery task list / cover behavior (data-only).
|
||||||
|
|
||||||
|
import 'image_entities.dart';
|
||||||
|
|
||||||
|
/// listing 为数字时的英文文案(兼容旧接口)
|
||||||
|
String _galleryListingLabelEnglish(int listing) {
|
||||||
|
switch (listing) {
|
||||||
|
case 1:
|
||||||
|
return 'Queued';
|
||||||
|
case 2:
|
||||||
|
return 'Processing';
|
||||||
|
case 3:
|
||||||
|
return 'Completed';
|
||||||
|
case 4:
|
||||||
|
return 'Timed out';
|
||||||
|
case 5:
|
||||||
|
return 'Error';
|
||||||
|
case 6:
|
||||||
|
return 'Aborted';
|
||||||
|
case 0:
|
||||||
|
return 'Pending';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接口 `listing`:字符串原样展示;纯数字或 int 走英文映射
|
||||||
|
String listingDisplayFromApi(dynamic raw) {
|
||||||
|
if (raw == null) return '';
|
||||||
|
if (raw is int) return _galleryListingLabelEnglish(raw);
|
||||||
|
if (raw is num) return _galleryListingLabelEnglish(raw.toInt());
|
||||||
|
final s = raw.toString().trim();
|
||||||
|
if (s.isEmpty) return '';
|
||||||
|
final asInt = int.tryParse(s);
|
||||||
|
if (asInt != null && s == asInt.toString()) {
|
||||||
|
return _galleryListingLabelEnglish(asInt);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool galleryMediaHasRemoteUrl(GalleryMediaItem m) {
|
||||||
|
bool http(String? x) {
|
||||||
|
if (x == null) return false;
|
||||||
|
final t = x.trim();
|
||||||
|
return t.startsWith('http://') || t.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
return http(m.imageUrl) || http(m.videoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool galleryListingIsInProgress(dynamic raw, String display) {
|
||||||
|
final d = display.trim().toLowerCase();
|
||||||
|
if (d == 'pending' ||
|
||||||
|
d == 'queued' ||
|
||||||
|
d == 'processing' ||
|
||||||
|
d == 'in progress' ||
|
||||||
|
d == 'running') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (raw != null) {
|
||||||
|
if (raw is int && (raw == 0 || raw == 1 || raw == 2)) return true;
|
||||||
|
if (raw is num) {
|
||||||
|
final v = raw.toInt();
|
||||||
|
if (v == 0 || v == 1 || v == 2) return true;
|
||||||
|
}
|
||||||
|
final s = raw.toString().trim().toLowerCase();
|
||||||
|
if (s == 'pending' ||
|
||||||
|
s == 'queued' ||
|
||||||
|
s == 'processing' ||
|
||||||
|
s == 'in_progress' ||
|
||||||
|
s == 'in progress' ||
|
||||||
|
s == 'running') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final n = int.tryParse(s);
|
||||||
|
if (n != null && (n == 0 || n == 1 || n == 2)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool galleryListingIsFinishedSuccess(dynamic raw, String display) {
|
||||||
|
final d = display.trim().toLowerCase();
|
||||||
|
if (d == 'finished' ||
|
||||||
|
d == 'completed' ||
|
||||||
|
d == 'complete' ||
|
||||||
|
d == 'success' ||
|
||||||
|
d == 'done') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (raw != null) {
|
||||||
|
if (raw is int && raw == 3) return true;
|
||||||
|
if (raw is num && raw.toInt() == 3) return true;
|
||||||
|
final s = raw.toString().trim().toLowerCase();
|
||||||
|
if (s == 'finished' ||
|
||||||
|
s == 'completed' ||
|
||||||
|
s == 'complete' ||
|
||||||
|
s == 'success' ||
|
||||||
|
s == 'done') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (int.tryParse(s) == 3) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String galleryListingBlockedHint(dynamic raw, String display) {
|
||||||
|
int? code;
|
||||||
|
if (raw is int) {
|
||||||
|
code = raw;
|
||||||
|
} else if (raw is num) {
|
||||||
|
code = raw.toInt();
|
||||||
|
} else if (raw != null) {
|
||||||
|
final s = raw.toString().trim().toLowerCase();
|
||||||
|
if (s == 'timeout' || s == 'timed out' || s == 'timed_out') {
|
||||||
|
code = 4;
|
||||||
|
} else if (s == 'error' || s == 'failed' || s == 'failure') {
|
||||||
|
code = 5;
|
||||||
|
} else if (s == 'aborted' || s == 'cancelled' || s == 'canceled') {
|
||||||
|
code = 6;
|
||||||
|
} else {
|
||||||
|
code = int.tryParse(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (code) {
|
||||||
|
case 4:
|
||||||
|
return 'This task has timed out.';
|
||||||
|
case 5:
|
||||||
|
return 'This task failed. Please try again.';
|
||||||
|
case 6:
|
||||||
|
return 'This task was cancelled.';
|
||||||
|
default:
|
||||||
|
final low = display.trim().toLowerCase();
|
||||||
|
if (low.contains('timeout') || low.contains('timed out')) {
|
||||||
|
return 'This task has timed out.';
|
||||||
|
}
|
||||||
|
if (low.contains('error') || low.contains('fail')) {
|
||||||
|
return 'This task failed. Please try again.';
|
||||||
|
}
|
||||||
|
if (low.contains('abort') || low.contains('cancel')) {
|
||||||
|
return 'This task was cancelled.';
|
||||||
|
}
|
||||||
|
return 'This item is not available yet.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GalleryMediaItem {
|
||||||
|
GalleryMediaItem({
|
||||||
|
this.imageUrl,
|
||||||
|
this.videoUrl,
|
||||||
|
this.taskId,
|
||||||
|
this.createTime = 0,
|
||||||
|
this.createTimeText,
|
||||||
|
this.listingDisplay = '',
|
||||||
|
this.listingRaw,
|
||||||
|
}) : assert(
|
||||||
|
(imageUrl?.isNotEmpty ?? false) ||
|
||||||
|
(videoUrl?.isNotEmpty ?? false) ||
|
||||||
|
((taskId ?? 0) > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
final String? imageUrl;
|
||||||
|
final String? videoUrl;
|
||||||
|
final int? taskId;
|
||||||
|
final int createTime;
|
||||||
|
final String? createTimeText;
|
||||||
|
final String listingDisplay;
|
||||||
|
final dynamic listingRaw;
|
||||||
|
|
||||||
|
bool get isVideo =>
|
||||||
|
videoUrl != null && (imageUrl == null || imageUrl!.isEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 我的任务项(与 app_client V2 字段名一致:`tree`、`discover`、`downsample` 等解密后的 business 形态)
|
||||||
|
class GalleryTaskItem {
|
||||||
|
const GalleryTaskItem({
|
||||||
|
required this.taskId,
|
||||||
|
required this.state,
|
||||||
|
required this.taskType,
|
||||||
|
required this.createTime,
|
||||||
|
required this.mediaItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int taskId;
|
||||||
|
final String state;
|
||||||
|
final int taskType;
|
||||||
|
final int createTime;
|
||||||
|
final List<GalleryMediaItem> mediaItems;
|
||||||
|
|
||||||
|
factory GalleryTaskItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final treeRaw = json['tree'] as num?;
|
||||||
|
final treeId = treeRaw?.toInt() ?? 0;
|
||||||
|
final itemTaskId = treeId > 0 ? treeId : null;
|
||||||
|
final createTime = (json['discover'] as num?)?.toInt() ?? 0;
|
||||||
|
final createTimeText = json['uncover'] as String?;
|
||||||
|
final listingRaw = json['listing'];
|
||||||
|
final listingDisplay = listingDisplayFromApi(listingRaw);
|
||||||
|
final downsample = json['downsample'] as List<dynamic>? ?? [];
|
||||||
|
final items = <GalleryMediaItem>[];
|
||||||
|
if (downsample.isNotEmpty) {
|
||||||
|
final first = downsample[0];
|
||||||
|
if (first is String && first.trim().isNotEmpty) {
|
||||||
|
items.add(GalleryMediaItem(
|
||||||
|
imageUrl: first,
|
||||||
|
taskId: itemTaskId,
|
||||||
|
createTime: createTime,
|
||||||
|
createTimeText: createTimeText,
|
||||||
|
listingDisplay: listingDisplay,
|
||||||
|
listingRaw: listingRaw,
|
||||||
|
));
|
||||||
|
} 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,
|
||||||
|
taskId: itemTaskId,
|
||||||
|
createTime: createTime,
|
||||||
|
createTimeText: createTimeText,
|
||||||
|
listingDisplay: listingDisplay,
|
||||||
|
listingRaw: listingRaw,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
items.add(GalleryMediaItem(
|
||||||
|
imageUrl: reconfigure,
|
||||||
|
taskId: itemTaskId,
|
||||||
|
createTime: createTime,
|
||||||
|
createTimeText: createTimeText,
|
||||||
|
listingDisplay: listingDisplay,
|
||||||
|
listingRaw: listingRaw,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.isEmpty && itemTaskId != null && itemTaskId > 0) {
|
||||||
|
items.add(GalleryMediaItem(
|
||||||
|
taskId: itemTaskId,
|
||||||
|
createTime: createTime,
|
||||||
|
createTimeText: createTimeText,
|
||||||
|
listingDisplay: listingDisplay,
|
||||||
|
listingRaw: listingRaw,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return GalleryTaskItem(
|
||||||
|
taskId: treeId,
|
||||||
|
state: json['listing']?.toString() ?? '',
|
||||||
|
taskType: (json['cipher'] as num?)?.toInt() ?? 0,
|
||||||
|
createTime: (json['discover'] as num?)?.toInt() ?? 0,
|
||||||
|
mediaItems: items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- [MyTaskItem](框架实体)与 app_client 列表语义对齐 ---
|
||||||
|
|
||||||
|
/// 优先 [MyTaskItem.state](线网 `bitrate`),否则 [MyTaskItem.status] / `listing` 字符串。
|
||||||
|
dynamic myTaskListingRaw(MyTaskItem item) {
|
||||||
|
if (item.state != null) return item.state;
|
||||||
|
return item.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool myTaskHasRemoteResultUrl(MyTaskItem item) {
|
||||||
|
final u = item.resultUrl?.trim() ?? '';
|
||||||
|
return u.startsWith('http://') || u.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表与 `/v1/image/progress` 字段形态可能不一致:`bitrate` 常为 **pending / finished** 等字符串。
|
||||||
|
/// **有 https 结果地址即视为可下载**(与「有没有完成看有没有地址」一致);无地址时仅靠状态展示。
|
||||||
|
bool myTaskCanShowDownload(MyTaskItem item) {
|
||||||
|
return myTaskHasRemoteResultUrl(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卡片上展示:`bitrate` 字符串原样经 [listingDisplayFromApi](数字 1–6 仍映射为英文)。
|
||||||
|
String myTaskStatusLabel(MyTaskItem item) {
|
||||||
|
final raw = myTaskListingRaw(item);
|
||||||
|
final s = listingDisplayFromApi(raw);
|
||||||
|
return s.isNotEmpty ? s : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成中:**无远程结果 URL** 且状态为进行中(含 `pending` 字符串)。
|
||||||
|
bool myTaskIsInProgress(MyTaskItem item) {
|
||||||
|
if (myTaskHasRemoteResultUrl(item)) return false;
|
||||||
|
final raw = myTaskListingRaw(item);
|
||||||
|
final display = listingDisplayFromApi(raw);
|
||||||
|
return galleryListingIsInProgress(raw, display);
|
||||||
|
}
|
||||||
@ -1,4 +1,8 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'credits_balance_parse.dart';
|
||||||
import 'entity.dart';
|
import 'entity.dart';
|
||||||
|
import 'task_id_parse.dart';
|
||||||
|
|
||||||
/// 分类项
|
/// 分类项
|
||||||
class CategoryItem extends Entity {
|
class CategoryItem extends Entity {
|
||||||
@ -15,12 +19,20 @@ class CategoryItem extends Entity {
|
|||||||
@override
|
@override
|
||||||
factory CategoryItem.fromJson(Map<String, dynamic> json) {
|
factory CategoryItem.fromJson(Map<String, dynamic> json) {
|
||||||
return CategoryItem(
|
return CategoryItem(
|
||||||
id: json['id'] as int?,
|
id: _readInt(json['id']),
|
||||||
name: json['name'] as String?,
|
name: json['name'] as String?,
|
||||||
icon: json['icon'] as String?,
|
icon: json['icon'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int? _readInt(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
if (v is String) return int.tryParse(v.trim());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -39,10 +51,18 @@ class CategoryListResponse extends Entity {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
factory CategoryListResponse.fromJson(Map<String, dynamic> json) {
|
factory CategoryListResponse.fromJson(Map<String, dynamic> json) {
|
||||||
final list = json['categories'] as List<dynamic>?;
|
List<dynamic>? list = json['categories'] as List<dynamic>?;
|
||||||
|
list ??= json['list'] as List<dynamic>?;
|
||||||
|
list ??= json['records'] as List<dynamic>?;
|
||||||
|
final data = json['data'];
|
||||||
|
if (list == null && data is List<dynamic>) {
|
||||||
|
list = data;
|
||||||
|
}
|
||||||
return CategoryListResponse(
|
return CategoryListResponse(
|
||||||
categories: list
|
categories: list
|
||||||
?.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
|
?.map((e) => CategoryItem.fromJson(
|
||||||
|
Map<String, dynamic>.from(e as Map),
|
||||||
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -53,13 +73,21 @@ class CategoryListResponse extends Entity {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 任务项
|
/// 任务项(`GET /v1/image/img2video/tasks` 模板列表等;见 FunyMee 文档 `previewImage` / `title`)。
|
||||||
class TaskItem extends Entity {
|
class TaskItem extends Entity {
|
||||||
TaskItem({
|
TaskItem({
|
||||||
this.id,
|
this.id,
|
||||||
this.name,
|
this.name,
|
||||||
this.imageUrl,
|
this.imageUrl,
|
||||||
this.categoryId,
|
this.categoryId,
|
||||||
|
this.title,
|
||||||
|
this.templateName,
|
||||||
|
this.templateUrl,
|
||||||
|
this.taskType,
|
||||||
|
this.ext,
|
||||||
|
this.previewVideoUrl,
|
||||||
|
this.credits480p,
|
||||||
|
this.credits720p,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? id;
|
final String? id;
|
||||||
@ -67,22 +95,99 @@ class TaskItem extends Entity {
|
|||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
final int? categoryId;
|
final int? categoryId;
|
||||||
|
|
||||||
|
/// 与 [name] 二选一;映射后字段名见换皮 `fieldMapping`。
|
||||||
|
final String? title;
|
||||||
|
final String? templateName;
|
||||||
|
final String? templateUrl;
|
||||||
|
|
||||||
|
/// 任务类型;列表项换皮 `liaison` → 逻辑 `taskType`;创建任务时写入 `cipher`。
|
||||||
|
final String? taskType;
|
||||||
|
|
||||||
|
/// 扩展参数,创建任务时可作模板标识。
|
||||||
|
final String? ext;
|
||||||
|
|
||||||
|
/// `GET /v1/image/img2video/tasks` 文档:[previewVideo] 内 `hlsUrl` / `url` / `lowUrl`(映射后字段名)。
|
||||||
|
final String? previewVideoUrl;
|
||||||
|
|
||||||
|
/// [resolution480p] 嵌套对象中的 `credits`;生图页 480p 档展示用。
|
||||||
|
final int? credits480p;
|
||||||
|
|
||||||
|
/// [resolution720p] 嵌套对象中的 `credits`;生图页 720p 档展示用。
|
||||||
|
final int? credits720p;
|
||||||
|
|
||||||
|
static String? _pickPreviewVideoUrl(Map<String, dynamic> m) {
|
||||||
|
for (final k in ['hlsUrl', 'url', 'lowUrl']) {
|
||||||
|
final v = m[k];
|
||||||
|
if (v is String && v.trim().isNotEmpty) return v.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory TaskItem.fromJson(Map<String, dynamic> json) {
|
factory TaskItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final preview = json['previewImage'];
|
||||||
|
String? previewUrl;
|
||||||
|
if (preview is Map) {
|
||||||
|
previewUrl = preview['url'] as String?;
|
||||||
|
}
|
||||||
|
String? videoUrl;
|
||||||
|
final pv = json['previewVideo'];
|
||||||
|
if (pv is Map) {
|
||||||
|
videoUrl = _pickPreviewVideoUrl(Map<String, dynamic>.from(pv));
|
||||||
|
}
|
||||||
|
final idStr = json['id']?.toString() ?? json['taskId']?.toString();
|
||||||
|
final nameFrom =
|
||||||
|
json['name'] as String? ?? json['title'] as String? ?? json['templateName'] as String?;
|
||||||
|
final imgUrl = json['imageUrl'] as String? ??
|
||||||
|
previewUrl ??
|
||||||
|
json['templateUrl'] as String?;
|
||||||
return TaskItem(
|
return TaskItem(
|
||||||
id: json['id'] as String?,
|
id: idStr,
|
||||||
name: json['name'] as String?,
|
name: nameFrom,
|
||||||
imageUrl: json['imageUrl'] as String?,
|
imageUrl: imgUrl,
|
||||||
categoryId: json['categoryId'] as int?,
|
categoryId: CategoryItem._readInt(json['categoryId']),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
templateName: json['templateName'] as String?,
|
||||||
|
templateUrl: json['templateUrl'] as String?,
|
||||||
|
taskType: _stringFromDynamic(json['taskType']),
|
||||||
|
ext: json['ext'] as String?,
|
||||||
|
previewVideoUrl: videoUrl,
|
||||||
|
credits480p: _readResolutionCredits(json, 'resolution480p'),
|
||||||
|
credits720p: _readResolutionCredits(json, 'resolution720p'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int? _readResolutionCredits(Map<String, dynamic> json, String key) {
|
||||||
|
final v = json[key];
|
||||||
|
if (v is! Map) return null;
|
||||||
|
final m = Map<String, dynamic>.from(v);
|
||||||
|
final c = m['credits'];
|
||||||
|
if (c is int) return c;
|
||||||
|
if (c is num) return c.toInt();
|
||||||
|
if (c is String) return int.tryParse(c.trim());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _stringFromDynamic(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v.trim().isEmpty ? null : v.trim();
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'name': name,
|
'name': name,
|
||||||
'imageUrl': imageUrl,
|
'imageUrl': imageUrl,
|
||||||
'categoryId': categoryId,
|
'categoryId': categoryId,
|
||||||
|
'title': title,
|
||||||
|
'templateName': templateName,
|
||||||
|
'templateUrl': templateUrl,
|
||||||
|
'taskType': taskType,
|
||||||
|
'ext': ext,
|
||||||
|
'previewVideoUrl': previewVideoUrl,
|
||||||
|
'credits480p': credits480p,
|
||||||
|
'credits720p': credits720p,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,10 +201,18 @@ class TasksResponse extends Entity {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
factory TasksResponse.fromJson(Map<String, dynamic> json) {
|
factory TasksResponse.fromJson(Map<String, dynamic> json) {
|
||||||
final list = json['tasks'] as List<dynamic>?;
|
List<dynamic>? list = json['tasks'] as List<dynamic>?;
|
||||||
|
list ??= json['list'] as List<dynamic>?;
|
||||||
|
list ??= json['records'] as List<dynamic>?;
|
||||||
|
final data = json['data'];
|
||||||
|
if (list == null && data is List<dynamic>) {
|
||||||
|
list = data;
|
||||||
|
}
|
||||||
return TasksResponse(
|
return TasksResponse(
|
||||||
tasks: list
|
tasks: list
|
||||||
?.map((e) => TaskItem.fromJson(e as Map<String, dynamic>))
|
?.map((e) => TaskItem.fromJson(
|
||||||
|
Map<String, dynamic>.from(e as Map),
|
||||||
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -160,26 +273,66 @@ class PromptRecommendsResponse extends Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 任务进度响应
|
/// 任务进度响应
|
||||||
|
///
|
||||||
|
/// 文档:`state`(线网 `bitrate`)1=队列 2=处理中 **3=完成** 4=超时 5=错误 6=中止;
|
||||||
|
/// 结果 URL 常在 `imageInfos[0].imgUrl`(线网 `elastic[].boost`),不一定有顶层 `resultUrl`。
|
||||||
class ProgressResponse extends Entity {
|
class ProgressResponse extends Entity {
|
||||||
ProgressResponse({
|
ProgressResponse({
|
||||||
this.taskId,
|
this.taskId,
|
||||||
this.status,
|
this.status,
|
||||||
|
this.state,
|
||||||
this.progress,
|
this.progress,
|
||||||
this.resultUrl,
|
this.resultUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? taskId;
|
final String? taskId;
|
||||||
final String? status;
|
final String? status;
|
||||||
|
|
||||||
|
/// 任务状态(线网 `bitrate` → 逻辑 `state`):3=完成,4/5/6=终态失败。
|
||||||
|
final int? state;
|
||||||
|
|
||||||
final int? progress;
|
final int? progress;
|
||||||
final String? resultUrl;
|
final String? resultUrl;
|
||||||
|
|
||||||
|
/// 线网可能返回 `String` 或 `int`(如状态码、任务 id),避免 `as String?` 运行时崩溃。
|
||||||
|
static String? _readStringLoose(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) {
|
||||||
|
final s = v.trim();
|
||||||
|
return s.isEmpty ? null : s;
|
||||||
|
}
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _readIntLoose(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
if (v is String) return int.tryParse(v.trim());
|
||||||
|
return int.tryParse(v.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _firstImageInfoUrl(Map<String, dynamic> json) {
|
||||||
|
final infos = json['imageInfos'];
|
||||||
|
if (infos is! List || infos.isEmpty) return null;
|
||||||
|
final first = infos.first;
|
||||||
|
if (first is! Map) return null;
|
||||||
|
final m = Map<String, dynamic>.from(first);
|
||||||
|
return _readStringLoose(m['imgUrl']) ?? _readStringLoose(m['boost']);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory ProgressResponse.fromJson(Map<String, dynamic> json) {
|
factory ProgressResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final fromRoot = _readStringLoose(json['resultUrl']) ??
|
||||||
|
_readStringLoose(json['imgUrl']) ??
|
||||||
|
_readStringLoose(json['boost']);
|
||||||
|
final fromList = _firstImageInfoUrl(json);
|
||||||
return ProgressResponse(
|
return ProgressResponse(
|
||||||
taskId: json['taskId'] as String?,
|
taskId: parseTaskIdValue(json['taskId']) ?? _readStringLoose(json['taskId']),
|
||||||
status: json['status'] as String?,
|
status: _readStringLoose(json['status']),
|
||||||
progress: json['progress'] as int?,
|
state: _readIntLoose(json['state']),
|
||||||
resultUrl: json['resultUrl'] as String?,
|
progress: _readIntLoose(json['progress']),
|
||||||
|
resultUrl: fromRoot ?? fromList,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,6 +340,7 @@ class ProgressResponse extends Entity {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'taskId': taskId,
|
'taskId': taskId,
|
||||||
'status': status,
|
'status': status,
|
||||||
|
'state': state,
|
||||||
'progress': progress,
|
'progress': progress,
|
||||||
'resultUrl': resultUrl,
|
'resultUrl': resultUrl,
|
||||||
};
|
};
|
||||||
@ -250,16 +404,78 @@ class UploadPresignedUrlResponse extends Entity {
|
|||||||
UploadPresignedUrlResponse({
|
UploadPresignedUrlResponse({
|
||||||
this.uploadUrl,
|
this.uploadUrl,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
|
this.putHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? uploadUrl;
|
final String? uploadUrl;
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
|
|
||||||
|
/// 上传到对象存储时额外请求头(如服务端返回的签名头;解密后为 business 字段名)。
|
||||||
|
final Map<String, String>? putHeaders;
|
||||||
|
|
||||||
|
/// 将任意 JSON 头值压成 [http] 要求的 [String](避免 `TypeError`)。
|
||||||
|
static String _headerValueToString(dynamic v) {
|
||||||
|
if (v == null) return '';
|
||||||
|
if (v is String) return v;
|
||||||
|
if (v is num || v is bool) return v.toString();
|
||||||
|
if (v is List) {
|
||||||
|
return v.map(_headerValueToString).where((s) => s.isNotEmpty).join(',');
|
||||||
|
}
|
||||||
|
if (v is Map) {
|
||||||
|
return jsonEncode(v);
|
||||||
|
}
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, String>? _parsePutHeaders(Map<String, dynamic> json) {
|
||||||
|
final raw = json['putHeaders'] ??
|
||||||
|
json['headers'] ??
|
||||||
|
json['uploadHeaders'] ??
|
||||||
|
json['requiredHeaders'] ??
|
||||||
|
json['tokenize'];
|
||||||
|
|
||||||
|
if (raw is List) {
|
||||||
|
final out = <String, String>{};
|
||||||
|
for (final item in raw) {
|
||||||
|
if (item is! Map) continue;
|
||||||
|
final m = Map<String, dynamic>.from(item);
|
||||||
|
final name = m['name'] ?? m['Name'] ?? m['key'] ?? m['Key'];
|
||||||
|
final value = m['value'] ?? m['Value'];
|
||||||
|
if (name == null) continue;
|
||||||
|
out[name.toString()] = _headerValueToString(value);
|
||||||
|
}
|
||||||
|
return out.isEmpty ? null : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw is! Map) return null;
|
||||||
|
final map = Map<String, dynamic>.from(raw);
|
||||||
|
final out = <String, String>{};
|
||||||
|
for (final e in map.entries) {
|
||||||
|
out[e.key.toString()] = _headerValueToString(e.value);
|
||||||
|
}
|
||||||
|
return out.isEmpty ? null : out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _str(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v.isEmpty ? null : v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory UploadPresignedUrlResponse.fromJson(Map<String, dynamic> json) {
|
factory UploadPresignedUrlResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
// FunyMee 等换皮:`uploadUrl1`/`filePath1`(文档 wire:harden / generate)。
|
||||||
|
// 部分环境仍返回 `uploadUrl`/`filePath`。
|
||||||
|
final upload = _str(json['uploadUrl']) ??
|
||||||
|
_str(json['uploadUrl1']) ??
|
||||||
|
_str(json['uploadUrl2']);
|
||||||
|
final path = _str(json['filePath']) ??
|
||||||
|
_str(json['filePath1']) ??
|
||||||
|
_str(json['filePath2']);
|
||||||
return UploadPresignedUrlResponse(
|
return UploadPresignedUrlResponse(
|
||||||
uploadUrl: json['uploadUrl'] as String?,
|
uploadUrl: upload,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: path,
|
||||||
|
putHeaders: _parsePutHeaders(json),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -267,6 +483,7 @@ class UploadPresignedUrlResponse extends Entity {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'uploadUrl': uploadUrl,
|
'uploadUrl': uploadUrl,
|
||||||
'filePath': filePath,
|
'filePath': filePath,
|
||||||
|
if (putHeaders != null) 'putHeaders': putHeaders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,8 +500,8 @@ class CreateTaskResponse extends Entity {
|
|||||||
@override
|
@override
|
||||||
factory CreateTaskResponse.fromJson(Map<String, dynamic> json) {
|
factory CreateTaskResponse.fromJson(Map<String, dynamic> json) {
|
||||||
return CreateTaskResponse(
|
return CreateTaskResponse(
|
||||||
taskId: json['taskId'] as String?,
|
taskId: parseTaskIdFromMap(json),
|
||||||
status: json['status'] as String?,
|
status: json['status'] as String? ?? json['state']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,10 +513,13 @@ class CreateTaskResponse extends Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 我的任务项
|
/// 我的任务项
|
||||||
|
///
|
||||||
|
/// 与 FunyMee 文档一致:`state`(线网 `bitrate`)1–6 为权威任务态;`status` 为兼容字段。
|
||||||
class MyTaskItem extends Entity {
|
class MyTaskItem extends Entity {
|
||||||
MyTaskItem({
|
MyTaskItem({
|
||||||
this.taskId,
|
this.taskId,
|
||||||
this.status,
|
this.status,
|
||||||
|
this.state,
|
||||||
this.progress,
|
this.progress,
|
||||||
this.resultUrl,
|
this.resultUrl,
|
||||||
this.createTime,
|
this.createTime,
|
||||||
@ -308,19 +528,68 @@ class MyTaskItem extends Entity {
|
|||||||
|
|
||||||
final String? taskId;
|
final String? taskId;
|
||||||
final String? status;
|
final String? status;
|
||||||
|
|
||||||
|
/// 任务状态:1=队列 2=处理中 3=完成 4=超时 5=错误 6=中止(与 [gallery_task_models] 语义一致)。
|
||||||
|
final int? state;
|
||||||
|
|
||||||
final int? progress;
|
final int? progress;
|
||||||
final String? resultUrl;
|
final String? resultUrl;
|
||||||
final String? createTime;
|
final String? createTime;
|
||||||
final String? type;
|
final String? type;
|
||||||
|
|
||||||
|
static int? _readInt(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
return int.tryParse(v.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _firstNonEmptyStr(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
final s = v is String ? v : v.toString();
|
||||||
|
final t = s.trim();
|
||||||
|
return t.isEmpty ? null : t;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory MyTaskItem.fromJson(Map<String, dynamic> json) {
|
factory MyTaskItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final imgList = json['imgList'] as List<dynamic>? ??
|
||||||
|
json['downsample'] as List<dynamic>?;
|
||||||
|
String? firstImgUrl;
|
||||||
|
if (imgList != null && imgList.isNotEmpty) {
|
||||||
|
final first = imgList.first;
|
||||||
|
if (first is Map) {
|
||||||
|
final m = Map<String, dynamic>.from(first);
|
||||||
|
firstImgUrl = _firstNonEmptyStr(m['imgUrl']) ??
|
||||||
|
_firstNonEmptyStr(m['url']) ??
|
||||||
|
_firstNonEmptyStr(m['boost']) ??
|
||||||
|
_firstNonEmptyStr(m['reconfigure']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final topResult = _firstNonEmptyStr(json['resultUrl']) ??
|
||||||
|
_firstNonEmptyStr(json['imgUrl']) ??
|
||||||
|
_firstNonEmptyStr(json['boost']);
|
||||||
|
final ct = json['createTime'];
|
||||||
|
final createTimeStr = ct == null
|
||||||
|
? null
|
||||||
|
: (ct is String
|
||||||
|
? (ct.isEmpty ? null : ct)
|
||||||
|
: ct.toString());
|
||||||
|
final rawStateField = json['state'];
|
||||||
|
final stateVal = _readInt(rawStateField);
|
||||||
|
// 线网 `bitrate` 映射为 `state` 时可能是 **字符串**(如 pending / finished),不能只做 int 解析。
|
||||||
|
final statusLoose = _firstNonEmptyStr(json['status']) ??
|
||||||
|
_firstNonEmptyStr(json['listing']) ??
|
||||||
|
(stateVal != null ? stateVal.toString() : _firstNonEmptyStr(rawStateField));
|
||||||
return MyTaskItem(
|
return MyTaskItem(
|
||||||
taskId: json['taskId'] as String?,
|
taskId: parseTaskIdFromMap(json),
|
||||||
status: json['status'] as String?,
|
status: statusLoose,
|
||||||
progress: json['progress'] as int?,
|
state: stateVal,
|
||||||
resultUrl: json['resultUrl'] as String?,
|
progress: _readInt(json['progress']),
|
||||||
createTime: json['createTime'] as String?,
|
resultUrl: topResult ?? firstImgUrl,
|
||||||
|
createTime: createTimeStr ??
|
||||||
|
(json['uncover'] as String?) ??
|
||||||
|
(json['discover']?.toString()),
|
||||||
type: json['type'] as String?,
|
type: json['type'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -329,6 +598,7 @@ class MyTaskItem extends Entity {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'taskId': taskId,
|
'taskId': taskId,
|
||||||
'status': status,
|
'status': status,
|
||||||
|
'state': state,
|
||||||
'progress': progress,
|
'progress': progress,
|
||||||
'resultUrl': resultUrl,
|
'resultUrl': resultUrl,
|
||||||
'createTime': createTime,
|
'createTime': createTime,
|
||||||
@ -342,21 +612,35 @@ class MyTasksResponse extends Entity {
|
|||||||
this.tasks,
|
this.tasks,
|
||||||
this.total,
|
this.total,
|
||||||
this.cursor,
|
this.cursor,
|
||||||
|
this.hasNext,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<MyTaskItem>? tasks;
|
final List<MyTaskItem>? tasks;
|
||||||
final int? total;
|
final int? total;
|
||||||
final String? cursor;
|
final String? cursor;
|
||||||
|
|
||||||
|
/// 与 app_client `manifest` / 常见 `hasNext` 对齐。
|
||||||
|
final bool? hasNext;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory MyTasksResponse.fromJson(Map<String, dynamic> json) {
|
factory MyTasksResponse.fromJson(Map<String, dynamic> json) {
|
||||||
final list = json['tasks'] as List<dynamic>?;
|
final rawList =
|
||||||
|
json['tasks'] ?? json['intensify'] ?? json['records'] ?? json['list'];
|
||||||
|
final list = rawList is List<dynamic> ? rawList : null;
|
||||||
|
final totalRaw = json['total'];
|
||||||
|
int? total;
|
||||||
|
if (totalRaw is int) {
|
||||||
|
total = totalRaw;
|
||||||
|
} else if (totalRaw is num) {
|
||||||
|
total = totalRaw.toInt();
|
||||||
|
}
|
||||||
return MyTasksResponse(
|
return MyTasksResponse(
|
||||||
tasks: list
|
tasks: list
|
||||||
?.map((e) => MyTaskItem.fromJson(e as Map<String, dynamic>))
|
?.map((e) => MyTaskItem.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
.toList(),
|
.toList(),
|
||||||
total: json['total'] as int?,
|
total: total,
|
||||||
cursor: json['cursor'] as String?,
|
cursor: json['cursor'] as String?,
|
||||||
|
hasNext: json['hasNext'] as bool? ?? json['manifest'] as bool?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,6 +649,7 @@ class MyTasksResponse extends Entity {
|
|||||||
'tasks': tasks?.map((e) => e.toJson()).toList(),
|
'tasks': tasks?.map((e) => e.toJson()).toList(),
|
||||||
'total': total,
|
'total': total,
|
||||||
'cursor': cursor,
|
'cursor': cursor,
|
||||||
|
'hasNext': hasNext,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,7 +747,7 @@ class CreditsPageInfoResponse extends Entity {
|
|||||||
factory CreditsPageInfoResponse.fromJson(Map<String, dynamic> json) {
|
factory CreditsPageInfoResponse.fromJson(Map<String, dynamic> json) {
|
||||||
final recList = json['records'] as List<dynamic>?;
|
final recList = json['records'] as List<dynamic>?;
|
||||||
return CreditsPageInfoResponse(
|
return CreditsPageInfoResponse(
|
||||||
credits: _toInt(json['credits']),
|
credits: parseUserCreditsBalance(json),
|
||||||
freeTimes: _toInt(json['freeTimes']),
|
freeTimes: _toInt(json['freeTimes']),
|
||||||
vipExpireTime: json['vipExpireTime'] as String?,
|
vipExpireTime: json['vipExpireTime'] as String?,
|
||||||
isVip: json['isVip'] is bool ? json['isVip'] as bool? : null,
|
isVip: json['isVip'] is bool ? json['isVip'] as bool? : null,
|
||||||
|
|||||||
@ -8,7 +8,9 @@ class PaymentProductItem extends Entity {
|
|||||||
this.actualAmount,
|
this.actualAmount,
|
||||||
this.originAmount,
|
this.originAmount,
|
||||||
this.bonus,
|
this.bonus,
|
||||||
|
this.bonusCredits,
|
||||||
this.title,
|
this.title,
|
||||||
|
this.credits,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? productId;
|
final String? productId;
|
||||||
@ -16,20 +18,45 @@ class PaymentProductItem extends Entity {
|
|||||||
final String? actualAmount;
|
final String? actualAmount;
|
||||||
final String? originAmount;
|
final String? originAmount;
|
||||||
final int? bonus;
|
final int? bonus;
|
||||||
|
/// 额外赠送积分(换皮线网常为 `saturation` → `bonusCredits`)。
|
||||||
|
final int? bonusCredits;
|
||||||
final String? title;
|
final String? title;
|
||||||
|
|
||||||
|
/// 该档位到账积分数(文档 wire 常为 `padding` → `credits`)。
|
||||||
|
final int? credits;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory PaymentProductItem.fromJson(Map<String, dynamic> json) {
|
factory PaymentProductItem.fromJson(Map<String, dynamic> json) {
|
||||||
return PaymentProductItem(
|
return PaymentProductItem(
|
||||||
productId: json['productId'] as String?,
|
// Wire `scene` often maps to logical `code` (see fieldMapping code→scene); Google Play id must still resolve.
|
||||||
activityId: json['activityId'] as String?,
|
productId: _stringField(json, 'productId') ??
|
||||||
actualAmount: json['actualAmount'] as String?,
|
_stringField(json, 'code') ??
|
||||||
originAmount: json['originAmount'] as String?,
|
_stringField(json, 'scene'),
|
||||||
bonus: json['bonus'] as int?,
|
activityId: _stringField(json, 'activityId'),
|
||||||
title: json['title'] as String?,
|
actualAmount: _stringField(json, 'actualAmount'),
|
||||||
|
originAmount: _stringField(json, 'originAmount'),
|
||||||
|
bonus: _intField(json, 'bonus'),
|
||||||
|
bonusCredits: _intField(json, 'bonusCredits'),
|
||||||
|
title: _stringField(json, 'title'),
|
||||||
|
credits: _intField(json, 'credits') ?? _intField(json, 'padding'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _stringField(Map<String, dynamic> json, String key) {
|
||||||
|
final v = json[key];
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _intField(Map<String, dynamic> json, String key) {
|
||||||
|
final v = json[key];
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
return int.tryParse(v.toString());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'productId': productId,
|
'productId': productId,
|
||||||
@ -37,7 +64,9 @@ class PaymentProductItem extends Entity {
|
|||||||
'actualAmount': actualAmount,
|
'actualAmount': actualAmount,
|
||||||
'originAmount': originAmount,
|
'originAmount': originAmount,
|
||||||
'bonus': bonus,
|
'bonus': bonus,
|
||||||
|
'bonusCredits': bonusCredits,
|
||||||
'title': title,
|
'title': title,
|
||||||
|
'credits': credits,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,7 +80,7 @@ class PaymentProductsResponse extends Entity {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
factory PaymentProductsResponse.fromJson(Map<String, dynamic> json) {
|
factory PaymentProductsResponse.fromJson(Map<String, dynamic> json) {
|
||||||
final list = json['productList'] as List<dynamic>?;
|
final list = _parseProductList(json);
|
||||||
return PaymentProductsResponse(
|
return PaymentProductsResponse(
|
||||||
productList: list
|
productList: list
|
||||||
?.map((e) => PaymentProductItem.fromJson(e as Map<String, dynamic>))
|
?.map((e) => PaymentProductItem.fromJson(e as Map<String, dynamic>))
|
||||||
@ -59,13 +88,24 @@ class PaymentProductsResponse extends Entity {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 换皮映射里商品数组常见 wire 名:`animate` → 逻辑名 `activitys`;默认映射里为 `summon` → `productList`。
|
||||||
|
static List<dynamic>? _parseProductList(Map<String, dynamic> json) {
|
||||||
|
final a = json['productList'] as List<dynamic>?;
|
||||||
|
if (a != null) return a;
|
||||||
|
final b = json['activitys'] as List<dynamic>?;
|
||||||
|
if (b != null) return b;
|
||||||
|
return json['summon'] as List<dynamic>?;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'productList': productList?.map((e) => e.toJson()).toList(),
|
'productList': productList?.map((e) => e.toJson()).toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 支付方式项
|
/// 支付方式项(get-payment-methods;换皮线网单项常为 `renew`)
|
||||||
|
///
|
||||||
|
/// 赠送字段与 app_client [PaymentMethodItem] 一致:`conjure`→`bonusCredits`,`enchant`→`bonusRatio`(经 [FieldMapping])。
|
||||||
class PaymentMethodItem extends Entity {
|
class PaymentMethodItem extends Entity {
|
||||||
PaymentMethodItem({
|
PaymentMethodItem({
|
||||||
this.paymentMethod,
|
this.paymentMethod,
|
||||||
@ -73,6 +113,8 @@ class PaymentMethodItem extends Entity {
|
|||||||
this.name,
|
this.name,
|
||||||
this.icon,
|
this.icon,
|
||||||
this.recommend,
|
this.recommend,
|
||||||
|
this.bonusCredits,
|
||||||
|
this.bonusRatio,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? paymentMethod;
|
final String? paymentMethod;
|
||||||
@ -81,6 +123,49 @@ class PaymentMethodItem extends Entity {
|
|||||||
final String? icon;
|
final String? icon;
|
||||||
final bool? recommend;
|
final bool? recommend;
|
||||||
|
|
||||||
|
/// 该支付方式额外赠送积分(线网常为 `conjure`)。
|
||||||
|
final int? bonusCredits;
|
||||||
|
|
||||||
|
/// 赠送比例;≤1 视为小数比例,否则视为百分数(app_client [PaymentMethodItem.bonusLabel])。
|
||||||
|
final double? bonusRatio;
|
||||||
|
|
||||||
|
/// 列表主标题:优先 [name],否则 [paymentMethod]。
|
||||||
|
String get displayName {
|
||||||
|
final n = name?.trim();
|
||||||
|
if (n != null && n.isNotEmpty) return n;
|
||||||
|
return paymentMethod?.trim().isNotEmpty == true ? paymentMethod!.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 副标题:优先展示赠送积分,否则展示赠送比例(与 app_client 一致)。
|
||||||
|
String? get bonusLabel {
|
||||||
|
final bc = bonusCredits;
|
||||||
|
if (bc != null && bc > 0) {
|
||||||
|
return '+$bc bonus credits';
|
||||||
|
}
|
||||||
|
final br = bonusRatio;
|
||||||
|
if (br != null && br > 0) {
|
||||||
|
final pct = br <= 1 ? (br * 100).round() : br.round();
|
||||||
|
return '+$pct% bonus credits';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _intField(Map<String, dynamic> json, String key) {
|
||||||
|
final v = json[key];
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
return int.tryParse(v.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static double? _doubleField(Map<String, dynamic> json, String key) {
|
||||||
|
final v = json[key];
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is double) return v;
|
||||||
|
if (v is num) return v.toDouble();
|
||||||
|
return double.tryParse(v.toString());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
|
factory PaymentMethodItem.fromJson(Map<String, dynamic> json) {
|
||||||
return PaymentMethodItem(
|
return PaymentMethodItem(
|
||||||
@ -89,6 +174,9 @@ class PaymentMethodItem extends Entity {
|
|||||||
name: json['name'] as String?,
|
name: json['name'] as String?,
|
||||||
icon: json['icon'] as String?,
|
icon: json['icon'] as String?,
|
||||||
recommend: json['recommend'] as bool?,
|
recommend: json['recommend'] as bool?,
|
||||||
|
bonusCredits: _intField(json, 'bonusCredits') ?? _intField(json, 'conjure'),
|
||||||
|
bonusRatio:
|
||||||
|
_doubleField(json, 'bonusRatio') ?? _doubleField(json, 'enchant'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,6 +187,8 @@ class PaymentMethodItem extends Entity {
|
|||||||
'name': name,
|
'name': name,
|
||||||
'icon': icon,
|
'icon': icon,
|
||||||
'recommend': recommend,
|
'recommend': recommend,
|
||||||
|
'bonusCredits': bonusCredits,
|
||||||
|
'bonusRatio': bonusRatio,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +202,7 @@ class PaymentMethodsResponse extends Entity {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
factory PaymentMethodsResponse.fromJson(Map<String, dynamic> json) {
|
factory PaymentMethodsResponse.fromJson(Map<String, dynamic> json) {
|
||||||
final list = json['paymentMethods'] as List<dynamic>?;
|
final list = _parsePaymentMethodsList(json);
|
||||||
return PaymentMethodsResponse(
|
return PaymentMethodsResponse(
|
||||||
paymentMethods: list
|
paymentMethods: list
|
||||||
?.map((e) => PaymentMethodItem.fromJson(e as Map<String, dynamic>))
|
?.map((e) => PaymentMethodItem.fromJson(e as Map<String, dynamic>))
|
||||||
@ -120,6 +210,15 @@ class PaymentMethodsResponse extends Entity {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 换皮线网常见:`invoke` / `renew` → 逻辑名 `paymentMethods`(与 app_client `renew` 一致)。
|
||||||
|
static List<dynamic>? _parsePaymentMethodsList(Map<String, dynamic> json) {
|
||||||
|
final a = json['paymentMethods'] as List<dynamic>?;
|
||||||
|
if (a != null) return a;
|
||||||
|
final b = json['invoke'] as List<dynamic>?;
|
||||||
|
if (b != null) return b;
|
||||||
|
return json['renew'] as List<dynamic>?;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'paymentMethods': paymentMethods?.map((e) => e.toJson()).toList(),
|
'paymentMethods': paymentMethods?.map((e) => e.toJson()).toList(),
|
||||||
@ -132,26 +231,43 @@ class CreatePaymentResponse extends Entity {
|
|||||||
this.orderId,
|
this.orderId,
|
||||||
this.payUrl,
|
this.payUrl,
|
||||||
this.status,
|
this.status,
|
||||||
|
this.federation,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? orderId;
|
final String? orderId;
|
||||||
final String? payUrl;
|
final String? payUrl;
|
||||||
final String? status;
|
final String? status;
|
||||||
|
|
||||||
|
/// Google Play 内购场景下,服务端返回的 federation id(与 Google `orderId` 映射后走 [PaymentApi.googlepay])。
|
||||||
|
final String? federation;
|
||||||
|
|
||||||
|
static String? _str(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
factory CreatePaymentResponse.fromJson(Map<String, dynamic> json) {
|
factory CreatePaymentResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final idRaw = json['orderId'] ?? json['id'];
|
||||||
return CreatePaymentResponse(
|
return CreatePaymentResponse(
|
||||||
orderId: json['orderId'] as String?,
|
orderId: idRaw == null ? null : idRaw.toString(),
|
||||||
payUrl: json['payUrl'] as String?,
|
payUrl: json['payUrl'] as String?,
|
||||||
status: json['status'] as String?,
|
status: json['status'] as String?,
|
||||||
|
federation: _str(json['federation']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 内购核销时优先使用 federation,其次 orderId。
|
||||||
|
String? get federationOrOrderId =>
|
||||||
|
(federation != null && federation!.isNotEmpty) ? federation : orderId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'orderId': orderId,
|
'orderId': orderId,
|
||||||
'payUrl': payUrl,
|
'payUrl': payUrl,
|
||||||
'status': status,
|
'status': status,
|
||||||
|
'federation': federation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,9 +316,11 @@ class GooglePayCallbackResponse extends Entity {
|
|||||||
@override
|
@override
|
||||||
factory GooglePayCallbackResponse.fromJson(Map<String, dynamic> json) {
|
factory GooglePayCallbackResponse.fromJson(Map<String, dynamic> json) {
|
||||||
final idRaw = json['orderId'] ?? json['id'];
|
final idRaw = json['orderId'] ?? json['id'];
|
||||||
|
final line = json['line'] ?? json['status'];
|
||||||
|
final statusStr = line is String ? line : json['status'] as String?;
|
||||||
return GooglePayCallbackResponse(
|
return GooglePayCallbackResponse(
|
||||||
orderId: idRaw == null ? null : idRaw.toString(),
|
orderId: idRaw == null ? null : idRaw.toString(),
|
||||||
status: json['status'] as String?,
|
status: statusStr,
|
||||||
creditsAdded: json['creditsAdded'] as bool?,
|
creditsAdded: json['creditsAdded'] as bool?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
22
lib/src/entities/task_id_parse.dart
Normal file
22
lib/src/entities/task_id_parse.dart
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/// 从创建任务等响应中解析任务 id(兼容 `taskId`、`tree`、`id`、`exponential` 等换皮键)。
|
||||||
|
String? parseTaskIdFromMap(Map<String, dynamic>? json) {
|
||||||
|
if (json == null) return null;
|
||||||
|
return parseTaskIdValue(json['taskId']) ??
|
||||||
|
parseTaskIdValue(json['tree']) ??
|
||||||
|
parseTaskIdValue(json['id']) ??
|
||||||
|
parseTaskIdValue(json['exponential']);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? parseTaskIdValue(dynamic raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (raw is String) {
|
||||||
|
final s = raw.trim();
|
||||||
|
return s.isEmpty ? null : s;
|
||||||
|
}
|
||||||
|
if (raw is int) return raw <= 0 ? null : raw.toString();
|
||||||
|
if (raw is num) {
|
||||||
|
final i = raw.toInt();
|
||||||
|
return i <= 0 ? null : i.toString();
|
||||||
|
}
|
||||||
|
return parseTaskIdValue(raw.toString());
|
||||||
|
}
|
||||||
@ -1,5 +1,18 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'credits_balance_parse.dart';
|
||||||
import 'entity.dart';
|
import 'entity.dart';
|
||||||
|
|
||||||
|
/// `extConfig` 在解密 data 中可能是 JSON 字符串,也可能是已解析的 Map;统一成可 [json.decode] 的字符串。
|
||||||
|
String? _wireExtConfigJson(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v.isEmpty ? null : v;
|
||||||
|
if (v is Map<String, dynamic>) return jsonEncode(v);
|
||||||
|
if (v is Map) return jsonEncode(Map<String, dynamic>.from(v));
|
||||||
|
if (v is List) return jsonEncode(v);
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/// 登录响应
|
/// 登录响应
|
||||||
class FastLoginResponse extends Entity {
|
class FastLoginResponse extends Entity {
|
||||||
FastLoginResponse({
|
FastLoginResponse({
|
||||||
@ -72,11 +85,11 @@ class FastLoginResponse extends Entity {
|
|||||||
return FastLoginResponse(
|
return FastLoginResponse(
|
||||||
userToken: _toString(json['userToken']),
|
userToken: _toString(json['userToken']),
|
||||||
userId: _toString(json['userId']),
|
userId: _toString(json['userId']),
|
||||||
credits: _toInt(json['credits']),
|
credits: parseUserCreditsBalance(json),
|
||||||
avatar: _toString(json['avatar']),
|
avatar: _toString(json['avatar']),
|
||||||
userName: _toString(json['userName']),
|
userName: _toString(json['userName']),
|
||||||
countryCode: _toString(json['countryCode']),
|
countryCode: _toString(json['countryCode']),
|
||||||
extConfig: _toString(json['extConfig']),
|
extConfig: _wireExtConfigJson(json['extConfig']),
|
||||||
appFbConfig: _toString(json['appFbConfig']),
|
appFbConfig: _toString(json['appFbConfig']),
|
||||||
usign: _toString(json['usign']),
|
usign: _toString(json['usign']),
|
||||||
creditsRecordUrl: _toString(json['creditsRecordUrl']),
|
creditsRecordUrl: _toString(json['creditsRecordUrl']),
|
||||||
@ -199,11 +212,11 @@ class CommonInfoResponse extends Entity {
|
|||||||
return CommonInfoResponse(
|
return CommonInfoResponse(
|
||||||
userToken: _toString(json['userToken']),
|
userToken: _toString(json['userToken']),
|
||||||
userId: _toString(json['userId']),
|
userId: _toString(json['userId']),
|
||||||
credits: _toInt(json['credits']),
|
credits: parseUserCreditsBalance(json),
|
||||||
avatar: _toString(json['avatar']),
|
avatar: _toString(json['avatar']),
|
||||||
userName: _toString(json['userName']),
|
userName: _toString(json['userName']),
|
||||||
countryCode: _toString(json['countryCode']),
|
countryCode: _toString(json['countryCode']),
|
||||||
extConfig: _toString(json['extConfig']),
|
extConfig: _wireExtConfigJson(json['extConfig']),
|
||||||
appFbConfig: _toString(json['appFbConfig']),
|
appFbConfig: _toString(json['appFbConfig']),
|
||||||
usign: _toString(json['usign']),
|
usign: _toString(json['usign']),
|
||||||
creditsRecordUrl: _toString(json['creditsRecordUrl']),
|
creditsRecordUrl: _toString(json['creditsRecordUrl']),
|
||||||
@ -273,7 +286,7 @@ class AccountResponse extends Entity {
|
|||||||
@override
|
@override
|
||||||
factory AccountResponse.fromJson(Map<String, dynamic> json) {
|
factory AccountResponse.fromJson(Map<String, dynamic> json) {
|
||||||
return AccountResponse(
|
return AccountResponse(
|
||||||
credits: json['credits'] as int?,
|
credits: parseUserCreditsBalance(json),
|
||||||
avatar: json['avatar'] as String?,
|
avatar: json['avatar'] as String?,
|
||||||
userName: json['userName'] as String?,
|
userName: json['userName'] as String?,
|
||||||
isVip: json['isVip'] as bool?,
|
isVip: json['isVip'] as bool?,
|
||||||
|
|||||||
97
lib/src/media/video_thumbnail_cache.dart
Normal file
97
lib/src/media/video_thumbnail_cache.dart
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:video_thumbnail/video_thumbnail.dart';
|
||||||
|
|
||||||
|
/// 远程视频 URL 的缩略图 / 海报帧磁盘缓存(对照参考产品)。
|
||||||
|
class VideoThumbnailCache {
|
||||||
|
VideoThumbnailCache._();
|
||||||
|
static final VideoThumbnailCache _instance = VideoThumbnailCache._();
|
||||||
|
static VideoThumbnailCache get instance => _instance;
|
||||||
|
|
||||||
|
static const int _maxWidth = 400;
|
||||||
|
static const int _quality = 75;
|
||||||
|
|
||||||
|
/// 全屏背景等大图:单帧 JPEG,[maxWidth] 控制长边。
|
||||||
|
Future<Uint8List?> getPosterFrame(
|
||||||
|
String videoUrl, {
|
||||||
|
int maxWidth = 1024,
|
||||||
|
}) async {
|
||||||
|
final key = '${_cacheKey(videoUrl)}_poster_$maxWidth';
|
||||||
|
final cacheDir = await _getCacheDir();
|
||||||
|
final file = File('${cacheDir.path}/$key.jpg');
|
||||||
|
|
||||||
|
if (await file.exists()) {
|
||||||
|
return file.readAsBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final path = await VideoThumbnail.thumbnailFile(
|
||||||
|
video: videoUrl,
|
||||||
|
thumbnailPath: cacheDir.path,
|
||||||
|
imageFormat: ImageFormat.JPEG,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
quality: 78,
|
||||||
|
);
|
||||||
|
if (path != null) {
|
||||||
|
final cached = File(path);
|
||||||
|
final bytes = await cached.readAsBytes();
|
||||||
|
if (cached.path != file.path) {
|
||||||
|
await file.writeAsBytes(bytes);
|
||||||
|
cached.deleteSync();
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List?> getThumbnail(String videoUrl) async {
|
||||||
|
final key = _cacheKey(videoUrl);
|
||||||
|
final cacheDir = await _getCacheDir();
|
||||||
|
final file = File('${cacheDir.path}/$key.jpg');
|
||||||
|
|
||||||
|
if (await file.exists()) {
|
||||||
|
return file.readAsBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final path = await VideoThumbnail.thumbnailFile(
|
||||||
|
video: videoUrl,
|
||||||
|
thumbnailPath: cacheDir.path,
|
||||||
|
imageFormat: ImageFormat.JPEG,
|
||||||
|
maxWidth: _maxWidth,
|
||||||
|
quality: _quality,
|
||||||
|
);
|
||||||
|
if (path != null) {
|
||||||
|
final cached = File(path);
|
||||||
|
final bytes = await cached.readAsBytes();
|
||||||
|
if (cached.path != file.path) {
|
||||||
|
await file.writeAsBytes(bytes);
|
||||||
|
cached.deleteSync();
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cacheKey(String url) {
|
||||||
|
final bytes = utf8.encode(url);
|
||||||
|
final digest = md5.convert(bytes);
|
||||||
|
return digest.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory? _cacheDir;
|
||||||
|
Future<Directory> _getCacheDir() async {
|
||||||
|
_cacheDir ??= await getTemporaryDirectory();
|
||||||
|
final dir = Directory('${_cacheDir!.path}/video_thumbnails');
|
||||||
|
if (!await dir.exists()) {
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -195,11 +195,92 @@ class AdjustService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _uploadReferrerResolved = false;
|
||||||
|
static String _uploadReferrerDigest = '';
|
||||||
|
static String _uploadReferrerSource = 'gg';
|
||||||
|
|
||||||
|
/// 供 [UserApi.referrer] 的 `referer` 等使用:优先 Base64(Adjust 归因 JSON),
|
||||||
|
/// 失败则 Android 上读取 Play Install Referrer 明文(对照参考产品;结果会缓存)。
|
||||||
|
static Future<ReferrerForUpload> obtainReferrerForUpload({
|
||||||
|
int adjustQueryTimeoutMs = 4500,
|
||||||
|
Duration raceTimeout = const Duration(seconds: 6),
|
||||||
|
Duration playReferrerTimeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
if (_uploadReferrerResolved) {
|
||||||
|
return ReferrerForUpload(
|
||||||
|
digest: _uploadReferrerDigest,
|
||||||
|
source: _uploadReferrerSource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var digest = '';
|
||||||
|
var source = 'gg';
|
||||||
|
|
||||||
|
try {
|
||||||
|
final attribution = await Future.any<AdjustAttribution?>([
|
||||||
|
(() async {
|
||||||
|
try {
|
||||||
|
return await adj.Adjust.getAttributionWithTimeout(
|
||||||
|
adjustQueryTimeoutMs,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
_attributionCallbackCompleter.future,
|
||||||
|
]).timeout(raceTimeout, onTimeout: () => null);
|
||||||
|
|
||||||
|
if (attribution != null) {
|
||||||
|
final raw = _attributionToDigest(attribution);
|
||||||
|
if (raw.isNotEmpty) {
|
||||||
|
source = 'android_adjust';
|
||||||
|
digest = base64Encode(utf8.encode(raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
digest = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digest.isEmpty && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
source = 'gg';
|
||||||
|
try {
|
||||||
|
final details = await PlayInstallReferrer.installReferrer
|
||||||
|
.timeout(playReferrerTimeout);
|
||||||
|
digest = details.installReferrer ?? '';
|
||||||
|
} catch (_) {
|
||||||
|
digest = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_uploadReferrerDigest = digest;
|
||||||
|
_uploadReferrerSource = source;
|
||||||
|
_uploadReferrerResolved = true;
|
||||||
|
return ReferrerForUpload(digest: digest, source: source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与 [obtainReferrerForUpload] 使用同一套字段序列化(不含 Base64)。
|
||||||
|
static String attributionToDigestJson(AdjustAttribution attr) =>
|
||||||
|
_attributionToDigest(attr);
|
||||||
|
|
||||||
static String get referrerSource => _referrerSource;
|
static String get referrerSource => _referrerSource;
|
||||||
|
|
||||||
static String? get cachedPlayReferrer => _cachedReferrer;
|
static String? get cachedPlayReferrer => _cachedReferrer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [AdjustService.obtainReferrerForUpload] 的返回值。
|
||||||
|
class ReferrerForUpload {
|
||||||
|
ReferrerForUpload({
|
||||||
|
required this.digest,
|
||||||
|
required this.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Base64(Adjust 归因 JSON) 或 Play Install Referrer 明文。
|
||||||
|
final String digest;
|
||||||
|
|
||||||
|
/// 例如 `android_adjust` / `gg`。
|
||||||
|
final String source;
|
||||||
|
}
|
||||||
|
|
||||||
class AttributionData {
|
class AttributionData {
|
||||||
AttributionData({
|
AttributionData({
|
||||||
this.trackerToken,
|
this.trackerToken,
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/proxy_client.dart';
|
import '../api/proxy_client.dart';
|
||||||
import '../config/attribution_config.dart';
|
import '../config/attribution_config.dart';
|
||||||
|
import '../config/ext_config_models.dart';
|
||||||
|
import '../config/ext_config_runtime.dart';
|
||||||
|
import '../config/video_home_runtime.dart';
|
||||||
import '../entities/user_entities.dart';
|
import '../entities/user_entities.dart';
|
||||||
import 'adjust_service.dart';
|
import 'adjust_service.dart';
|
||||||
import 'analytics_attribution_callbacks.dart';
|
import 'analytics_attribution_callbacks.dart';
|
||||||
@ -198,7 +200,16 @@ abstract class FrameworkAuthService {
|
|||||||
required String uid,
|
required String uid,
|
||||||
required String deviceId,
|
required String deviceId,
|
||||||
}) async {
|
}) async {
|
||||||
if (uid.isEmpty) return;
|
if (uid.isEmpty) {
|
||||||
|
VideoHomeRuntime.reset();
|
||||||
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[AuthService] common_info: 跳过(userId 为空),已标记 common_info 失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final config = ApiClient.instance.config;
|
final config = ApiClient.instance.config;
|
||||||
final backendApp = defaultTargetPlatform == TargetPlatform.iOS
|
final backendApp = defaultTargetPlatform == TargetPlatform.iOS
|
||||||
@ -261,28 +272,36 @@ abstract class FrameworkAuthService {
|
|||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
);
|
);
|
||||||
if (commonRes.isSuccess && commonRes.data != null) {
|
if (commonRes.isSuccess && commonRes.data != null) {
|
||||||
|
ExtConfigRuntime.applyCommonInfoSuccess(commonRes.data!);
|
||||||
_callbacks?.onCommonInfoLoaded(commonRes.data!);
|
_callbacks?.onCommonInfoLoaded(commonRes.data!);
|
||||||
|
unawaited(
|
||||||
|
VideoHomeRuntime.hydrateAfterCommonInfo(
|
||||||
|
userId: uid,
|
||||||
|
app: backendApp,
|
||||||
|
),
|
||||||
|
);
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] common_info: 获取成功');
|
debugPrint('[AuthService] common_info: 获取成功');
|
||||||
}
|
}
|
||||||
} else if (kDebugMode) {
|
} else {
|
||||||
debugPrint(
|
VideoHomeRuntime.reset();
|
||||||
'[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}');
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
VideoHomeRuntime.reset();
|
||||||
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[AuthService] common_info: 异常 $e');
|
debugPrint('[AuthService] common_info: 异常 $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析 extConfig JSON 字符串
|
/// 解析 extConfig JSON 字符串为 Map(兼容旧代码;结构化解析请用 [ExtConfigData.parse])。
|
||||||
static Map<String, dynamic>? parseExtConfig(String? extConfigStr) {
|
static Map<String, dynamic>? parseExtConfig(String? extConfigStr) {
|
||||||
if (extConfigStr == null || extConfigStr.isEmpty) return null;
|
return ExtConfigData.parseRawMap(extConfigStr);
|
||||||
try {
|
|
||||||
return json.decode(extConfigStr) as Map<String, dynamic>?;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import '../api/api_client.dart';
|
|||||||
import '../api/proxy_client.dart';
|
import '../api/proxy_client.dart';
|
||||||
import '../entities/feedback_entities.dart';
|
import '../entities/feedback_entities.dart';
|
||||||
|
|
||||||
/// 举报/反馈相关 API(使用原始字段名)
|
/// 举报/反馈相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)
|
||||||
///
|
///
|
||||||
/// **Body**(`submit`):`fileUrls`、`contentType`、`content`(与文档顺序一致)。
|
/// **Body**(`submit`):`fileUrls`、`contentType`、`content`(与文档顺序一致)。
|
||||||
abstract final class FeedbackApi {
|
abstract final class FeedbackApi {
|
||||||
|
|||||||
@ -2,32 +2,101 @@ import '../api/api_client.dart';
|
|||||||
import '../api/proxy_client.dart';
|
import '../api/proxy_client.dart';
|
||||||
import '../entities/image_entities.dart';
|
import '../entities/image_entities.dart';
|
||||||
|
|
||||||
/// 图片/视频生成相关 API(使用原始字段名)
|
/// 图片/视频生成相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)。
|
||||||
///
|
///
|
||||||
/// **请求头**:需登录接口自动附带 `pkg` 与 `User_token`(参见 [ProxyClient.request])。
|
/// **请求头**:需登录接口自动附带 `pkg` 与 `User_token`(参见 [ProxyClient.request])。
|
||||||
abstract final class ImageApi {
|
abstract final class ImageApi {
|
||||||
static ProxyClient get _client => ApiClient.instance.proxy;
|
static ProxyClient get _client => ApiClient.instance.proxy;
|
||||||
|
|
||||||
/// 获取图转视频分类列表
|
/// 获取图转视频分类列表
|
||||||
|
///
|
||||||
|
/// 兼容 `data` 为 `{ "categories": [...] }` 或 **直接为数组**(后者若走 [ProxyClient.requestEntity]
|
||||||
|
/// 会因非 Map 而丢失 [EntityResponse.data])。
|
||||||
static Future<EntityResponse<CategoryListResponse>> getCategoryList() async {
|
static Future<EntityResponse<CategoryListResponse>> getCategoryList() async {
|
||||||
return _client.requestEntity(
|
final response = await _client.request(
|
||||||
path: '/v1/image/img2video/categories',
|
path: '/v1/image/img2video/categories',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
entityFactory: CategoryListResponse.fromJson,
|
);
|
||||||
|
if (!response.isSuccess) {
|
||||||
|
return EntityResponse(
|
||||||
|
code: response.code,
|
||||||
|
msg: response.msg,
|
||||||
|
data: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final data = _parseCategoryListPayload(response.data);
|
||||||
|
return EntityResponse(
|
||||||
|
code: response.code,
|
||||||
|
msg: response.msg,
|
||||||
|
data: data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取图转视频任务列表
|
/// 解析 `data`:数组、或含 `categories` / `list` / `records` / `data` 的 Map。
|
||||||
|
static CategoryListResponse? _parseCategoryListPayload(dynamic raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
final mapping = ApiClient.instance.config.fieldMapping;
|
||||||
|
if (raw is List) {
|
||||||
|
final items = <CategoryItem>[];
|
||||||
|
for (final e in raw) {
|
||||||
|
if (e is Map) {
|
||||||
|
final m = mapping.mapResponse(Map<String, dynamic>.from(e));
|
||||||
|
items.add(CategoryItem.fromJson(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CategoryListResponse(categories: items);
|
||||||
|
}
|
||||||
|
if (raw is Map) {
|
||||||
|
final m = Map<String, dynamic>.from(raw);
|
||||||
|
return CategoryListResponse.fromJson(m);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取图转视频任务列表(按分类拉取模板列表;见 FunyMee `GET /v1/image/img2video/tasks`)。
|
||||||
|
///
|
||||||
|
/// 兼容 `data` 为数组或 `{ "tasks": [...] }`([ProxyClient.requestEntity] 在 `data` 非 Map 时会丢实体)。
|
||||||
static Future<EntityResponse<TasksResponse>> getImg2VideoTasks({
|
static Future<EntityResponse<TasksResponse>> getImg2VideoTasks({
|
||||||
int? categoryId,
|
int? categoryId,
|
||||||
}) async {
|
}) async {
|
||||||
return _client.requestEntity(
|
final response = await _client.request(
|
||||||
path: '/v1/image/img2video/tasks',
|
path: '/v1/image/img2video/tasks',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
entityFactory: TasksResponse.fromJson,
|
|
||||||
queryParams:
|
queryParams:
|
||||||
categoryId != null ? {'categoryId': categoryId.toString()} : null,
|
categoryId != null ? {'categoryId': categoryId.toString()} : null,
|
||||||
);
|
);
|
||||||
|
if (!response.isSuccess) {
|
||||||
|
return EntityResponse(
|
||||||
|
code: response.code,
|
||||||
|
msg: response.msg,
|
||||||
|
data: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final parsed = _parseTasksPayload(response.data);
|
||||||
|
return EntityResponse(
|
||||||
|
code: response.code,
|
||||||
|
msg: response.msg,
|
||||||
|
data: parsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TasksResponse? _parseTasksPayload(dynamic raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
final mapping = ApiClient.instance.config.fieldMapping;
|
||||||
|
if (raw is List) {
|
||||||
|
final items = <TaskItem>[];
|
||||||
|
for (final e in raw) {
|
||||||
|
if (e is Map) {
|
||||||
|
final m = mapping.mapResponse(Map<String, dynamic>.from(e));
|
||||||
|
items.add(TaskItem.fromJson(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TasksResponse(tasks: items);
|
||||||
|
}
|
||||||
|
if (raw is Map) {
|
||||||
|
return TasksResponse.fromJson(Map<String, dynamic>.from(raw));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取推荐提示词
|
/// 获取推荐提示词
|
||||||
@ -50,7 +119,7 @@ abstract final class ImageApi {
|
|||||||
|
|
||||||
/// 创建文生图任务
|
/// 创建文生图任务
|
||||||
///
|
///
|
||||||
/// **Body**(原始字段):`imgCount`(默认 1)、`aspectRatio`(可选)、`prompt`(必填)。
|
/// **Body**(逻辑字段):`imgCount`(默认 1)、`aspectRatio`(可选)、`prompt`(必填)。
|
||||||
static Future<EntityResponse<CreateTaskResponse>> createTxt2Img({
|
static Future<EntityResponse<CreateTaskResponse>> createTxt2Img({
|
||||||
required String app,
|
required String app,
|
||||||
required String prompt,
|
required String prompt,
|
||||||
@ -169,31 +238,57 @@ abstract final class ImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 创建生图/视频任务
|
/// 创建生图/视频任务
|
||||||
|
///
|
||||||
|
/// 线网字段名由宿主 `skin_config.json` 的 `fieldMapping` 决定(如 FunyMee:`size`→`seminar`,`needopt`→`team`)。
|
||||||
|
/// - Query:`userId`
|
||||||
|
/// - Body:`srcImg1Url`(兼容 `resolution` / `srcImgUrls`)、`taskType`、`size`(480p/720p)、`needopt`(默认 `false`)、`templateName`、`imgUrl`、`ext` 等
|
||||||
|
///
|
||||||
|
/// 双图:第一张 `srcImg1Url`(或兼容 `resolution`),第二张 `srcImg2`。
|
||||||
|
///
|
||||||
|
/// [heatmap] 为历史别名,与 [size] 择一,最终写入逻辑字段 **`size`**(勿再使用 `heatmap` 作为 body 键)。
|
||||||
|
/// [srcImgUrls] / [resolution] 仅作兼容:与 [srcImg1Url] 择一。
|
||||||
static Future<EntityResponse<CreateTaskResponse>> createTask({
|
static Future<EntityResponse<CreateTaskResponse>> createTask({
|
||||||
required String userId,
|
required String userId,
|
||||||
|
String? srcImg1Url,
|
||||||
String? resolution,
|
String? resolution,
|
||||||
String? srcImgUrls,
|
String? srcImgUrls,
|
||||||
|
String? srcImg2,
|
||||||
String? prompt,
|
String? prompt,
|
||||||
String? cipher,
|
/// 输出尺寸(如 `480p` / `720p`);与 [heatmap] 二选一,见 [heatmap]。
|
||||||
String? heatmap,
|
String? size,
|
||||||
|
@Deprecated('Use size') String? heatmap,
|
||||||
|
String? taskType,
|
||||||
|
String? templateName,
|
||||||
String? imgUrl,
|
String? imgUrl,
|
||||||
bool allowance = false,
|
/// 是否优化;文档固定 `false`,映射如 `needopt`→`team`。
|
||||||
|
bool needopt = false,
|
||||||
String? ext,
|
String? ext,
|
||||||
}) async {
|
}) async {
|
||||||
|
final path = srcImg1Url ??
|
||||||
|
resolution ??
|
||||||
|
(srcImgUrls != null && srcImgUrls.isNotEmpty ? srcImgUrls : null);
|
||||||
|
final sizeVal = (size != null && size.isNotEmpty)
|
||||||
|
? size
|
||||||
|
: (heatmap != null && heatmap.isNotEmpty ? heatmap : null);
|
||||||
|
final taskTypeVal =
|
||||||
|
taskType != null && taskType.isNotEmpty ? taskType : null;
|
||||||
|
|
||||||
return _client.requestEntity(
|
return _client.requestEntity(
|
||||||
path: '/v1/image/create-task',
|
path: '/v1/image/create-task',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
entityFactory: CreateTaskResponse.fromJson,
|
entityFactory: CreateTaskResponse.fromJson,
|
||||||
queryParams: {'userId': userId},
|
queryParams: {'userId': userId},
|
||||||
body: {
|
body: {
|
||||||
if (resolution != null) 'resolution': resolution,
|
if (path != null) 'srcImg1Url': path,
|
||||||
if (srcImgUrls != null) 'srcImgUrls': srcImgUrls,
|
if (srcImg2 != null && srcImg2.isNotEmpty) 'srcImg2': srcImg2,
|
||||||
if (prompt != null) 'prompt': prompt,
|
if (prompt != null && prompt.isNotEmpty) 'prompt': prompt,
|
||||||
if (cipher != null) 'cipher': cipher,
|
if (taskTypeVal != null) 'taskType': taskTypeVal,
|
||||||
if (heatmap != null) 'heatmap': heatmap,
|
if (sizeVal != null) 'size': sizeVal,
|
||||||
if (imgUrl != null) 'imgUrl': imgUrl,
|
if (templateName != null && templateName.isNotEmpty)
|
||||||
if (ext != null) 'ext': ext,
|
'templateName': templateName,
|
||||||
'allowance': allowance,
|
if (imgUrl != null && imgUrl.isNotEmpty) 'imgUrl': imgUrl,
|
||||||
|
if (ext != null && ext.isNotEmpty) 'ext': ext,
|
||||||
|
'needopt': needopt,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -202,6 +297,7 @@ abstract final class ImageApi {
|
|||||||
static Future<EntityResponse<MyTasksResponse>> getMyTasks({
|
static Future<EntityResponse<MyTasksResponse>> getMyTasks({
|
||||||
required String app,
|
required String app,
|
||||||
String? page,
|
String? page,
|
||||||
|
/// 每页条数;逻辑字段名为 **`size`**(换皮如 `seminar`),勿使用未映射的 `pageSize` 键。
|
||||||
String? pageSize,
|
String? pageSize,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
}) async {
|
}) async {
|
||||||
@ -212,7 +308,7 @@ abstract final class ImageApi {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
'app': app,
|
'app': app,
|
||||||
if (page != null) 'page': page,
|
if (page != null) 'page': page,
|
||||||
if (pageSize != null) 'pageSize': pageSize,
|
if (pageSize != null) 'size': pageSize,
|
||||||
if (cursor != null) 'cursor': cursor,
|
if (cursor != null) 'cursor': cursor,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
67
lib/src/services/image_compress.dart
Normal file
67
lib/src/services/image_compress.dart
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// 上传前压缩参数(对照参考实现的常见取值:`maxSide: 1024`, `jpegQuality: 75`)。
|
||||||
|
class CompressImageForUploadOptions {
|
||||||
|
const CompressImageForUploadOptions({
|
||||||
|
this.maxSide = 2048,
|
||||||
|
this.jpegQuality = 85,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int maxSide;
|
||||||
|
final int jpegQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传前压缩:限制长边、JPEG 质量;解码失败或非 JPEG 管线时返回原文件。
|
||||||
|
Future<File> compressImageForUpload(
|
||||||
|
File source, {
|
||||||
|
int maxSide = 2048,
|
||||||
|
int jpegQuality = 85,
|
||||||
|
}) async {
|
||||||
|
return compressImageForUploadWithOptions(
|
||||||
|
source,
|
||||||
|
CompressImageForUploadOptions(maxSide: maxSide, jpegQuality: jpegQuality),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> compressImageForUploadWithOptions(
|
||||||
|
File source,
|
||||||
|
CompressImageForUploadOptions options,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final raw = await source.readAsBytes();
|
||||||
|
final image = img.decodeImage(raw);
|
||||||
|
if (image == null) return source;
|
||||||
|
|
||||||
|
var work = image;
|
||||||
|
final maxSide = options.maxSide;
|
||||||
|
if (work.width > maxSide || work.height > maxSide) {
|
||||||
|
if (work.width >= work.height) {
|
||||||
|
work = img.copyResize(
|
||||||
|
work,
|
||||||
|
width: maxSide,
|
||||||
|
interpolation: img.Interpolation.linear,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
work = img.copyResize(
|
||||||
|
work,
|
||||||
|
height: maxSide,
|
||||||
|
interpolation: img.Interpolation.linear,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final jpg = img.encodeJpg(work, quality: options.jpegQuality);
|
||||||
|
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final out = File(
|
||||||
|
'${dir.path}/upload_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
|
);
|
||||||
|
await out.writeAsBytes(jpg, flush: true);
|
||||||
|
return out;
|
||||||
|
} catch (_) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
281
lib/src/services/image_presigned_upload_create_flow.dart
Normal file
281
lib/src/services/image_presigned_upload_create_flow.dart
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../entities/image_entities.dart';
|
||||||
|
import '../log/app_logger.dart';
|
||||||
|
import 'image_api.dart';
|
||||||
|
import 'image_compress.dart';
|
||||||
|
import 'task_upload_cover_store.dart';
|
||||||
|
|
||||||
|
final _presignedPutLog = AppLogger('PresignedUpload');
|
||||||
|
|
||||||
|
/// 预签名上传 → PUT → [ImageApi.createTask] 的结果。
|
||||||
|
class ImagePresignedUploadCreateTaskResult {
|
||||||
|
ImagePresignedUploadCreateTaskResult({
|
||||||
|
required this.createResponse,
|
||||||
|
required this.fileUsedForUpload,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CreateTaskResponse createResponse;
|
||||||
|
final File fileUsedForUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UploadedPart {
|
||||||
|
_UploadedPart({required this.toUpload, required this.serverPath});
|
||||||
|
|
||||||
|
final File toUpload;
|
||||||
|
final String serverPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与参考实现一致:压缩(可选)→ 取预签名 → HTTP PUT → 创建任务;可选写入 [TaskUploadCoverStore]。
|
||||||
|
abstract final class ImagePresignedUploadCreateTaskFlow {
|
||||||
|
ImagePresignedUploadCreateTaskFlow._();
|
||||||
|
|
||||||
|
/// 合并预签名返回头与额外头,并保证值为 [String](避免 [http] header 的 `TypeError`)。
|
||||||
|
static Map<String, String> _mergePutHeaders(
|
||||||
|
Map<String, String>? server,
|
||||||
|
Map<String, String>? extra,
|
||||||
|
String contentType,
|
||||||
|
) {
|
||||||
|
final out = <String, String>{};
|
||||||
|
void putAll(Map<String, String>? m) {
|
||||||
|
if (m == null) return;
|
||||||
|
for (final e in m.entries) {
|
||||||
|
final k = e.key.trim();
|
||||||
|
if (k.isEmpty) continue;
|
||||||
|
out[k] = e.value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
putAll(server);
|
||||||
|
putAll(extra);
|
||||||
|
if (!out.containsKey('Content-Type')) {
|
||||||
|
out['Content-Type'] = contentType;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_UploadedPart> _uploadOneFile({
|
||||||
|
required File sourceFile,
|
||||||
|
required bool compressFirst,
|
||||||
|
required CompressImageForUploadOptions compressOptions,
|
||||||
|
String? customUploadBaseName,
|
||||||
|
Map<String, String>? extraPutHeaders,
|
||||||
|
}) async {
|
||||||
|
final toUpload = compressFirst
|
||||||
|
? await compressImageForUploadWithOptions(sourceFile, compressOptions)
|
||||||
|
: sourceFile;
|
||||||
|
|
||||||
|
final size = await toUpload.length();
|
||||||
|
final pathLower = toUpload.path.toLowerCase();
|
||||||
|
final extName = pathLower.contains('.')
|
||||||
|
? toUpload.path.split('.').last.toLowerCase()
|
||||||
|
: 'jpg';
|
||||||
|
final contentType = extName == 'png'
|
||||||
|
? 'image/png'
|
||||||
|
: extName == 'gif'
|
||||||
|
? 'image/gif'
|
||||||
|
: 'image/jpeg';
|
||||||
|
final fileName = customUploadBaseName != null &&
|
||||||
|
customUploadBaseName.trim().isNotEmpty
|
||||||
|
? (customUploadBaseName.contains('.')
|
||||||
|
? customUploadBaseName
|
||||||
|
: '$customUploadBaseName.$extName')
|
||||||
|
: 'img_${DateTime.now().millisecondsSinceEpoch}.$extName';
|
||||||
|
|
||||||
|
final presignedRes = await ImageApi.getUploadPresignedUrl(
|
||||||
|
fileName1: fileName,
|
||||||
|
contentType: contentType,
|
||||||
|
expectedSize: size,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!presignedRes.isSuccess || presignedRes.data == null) {
|
||||||
|
throw StateError(
|
||||||
|
presignedRes.msg.isNotEmpty
|
||||||
|
? presignedRes.msg
|
||||||
|
: 'Failed to get upload URL',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final presigned = presignedRes.data!;
|
||||||
|
final uploadUrl = presigned.uploadUrl;
|
||||||
|
final filePath = presigned.filePath;
|
||||||
|
if (uploadUrl == null ||
|
||||||
|
uploadUrl.isEmpty ||
|
||||||
|
filePath == null ||
|
||||||
|
filePath.isEmpty) {
|
||||||
|
throw StateError('Invalid presigned URL response');
|
||||||
|
}
|
||||||
|
|
||||||
|
final headers = _mergePutHeaders(
|
||||||
|
presigned.putHeaders,
|
||||||
|
extraPutHeaders,
|
||||||
|
contentType,
|
||||||
|
);
|
||||||
|
|
||||||
|
final bytes = await toUpload.readAsBytes();
|
||||||
|
final uri = Uri.parse(uploadUrl);
|
||||||
|
_presignedPutLog.d(
|
||||||
|
'PUT begin url=$uploadUrl bytes=${bytes.length} '
|
||||||
|
'contentType=${headers['Content-Type']}',
|
||||||
|
);
|
||||||
|
_presignedPutLog.d(
|
||||||
|
'PUT header keys: ${headers.keys.join(', ')}',
|
||||||
|
);
|
||||||
|
|
||||||
|
final uploadResponse = await http.put(
|
||||||
|
uri,
|
||||||
|
headers: headers,
|
||||||
|
body: bytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
_presignedPutLog.d(
|
||||||
|
'PUT done status=${uploadResponse.statusCode} '
|
||||||
|
'reason=${uploadResponse.reasonPhrase} '
|
||||||
|
'respBodyLen=${uploadResponse.body.length}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
|
||||||
|
final snippet = uploadResponse.body.length > 800
|
||||||
|
? '${uploadResponse.body.substring(0, 800)}…'
|
||||||
|
: uploadResponse.body;
|
||||||
|
_presignedPutLog.e('PUT failed body snippet: $snippet');
|
||||||
|
throw StateError('Upload failed: ${uploadResponse.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
return _UploadedPart(toUpload: toUpload, serverPath: filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [srcImageServerPath] 默认使用预签名返回的 [UploadPresignedUrlResponse.filePath];若接口要求填 URL 字段可自行传入。
|
||||||
|
static Future<ImagePresignedUploadCreateTaskResult> run({
|
||||||
|
required File sourceFile,
|
||||||
|
required String userId,
|
||||||
|
bool compressFirst = true,
|
||||||
|
CompressImageForUploadOptions compressOptions =
|
||||||
|
const CompressImageForUploadOptions(maxSide: 1024, jpegQuality: 75),
|
||||||
|
String? customUploadBaseName,
|
||||||
|
String? resolution,
|
||||||
|
String? srcImgUrls,
|
||||||
|
String? prompt,
|
||||||
|
String? size,
|
||||||
|
String? taskType,
|
||||||
|
String? templateName,
|
||||||
|
String? imgUrl,
|
||||||
|
bool needopt = false,
|
||||||
|
String? ext,
|
||||||
|
/// 保留兼容;上传路径一律走 `srcImg1Url`(换皮见 `skin_config`),此标志不再改变行为。
|
||||||
|
bool createTaskUseImgUrlOnly = false,
|
||||||
|
/// 默认 `true`:成功且能解析 [CreateTaskResponse.taskId] 时保存本地封面。
|
||||||
|
bool saveLocalUploadCover = true,
|
||||||
|
Map<String, String>? extraPutHeaders,
|
||||||
|
}) async {
|
||||||
|
final part = await _uploadOneFile(
|
||||||
|
sourceFile: sourceFile,
|
||||||
|
compressFirst: compressFirst,
|
||||||
|
compressOptions: compressOptions,
|
||||||
|
customUploadBaseName: customUploadBaseName,
|
||||||
|
extraPutHeaders: extraPutHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
final serverPath = srcImgUrls ?? part.serverPath;
|
||||||
|
final pathForTask = resolution ?? serverPath;
|
||||||
|
|
||||||
|
final createRes = await ImageApi.createTask(
|
||||||
|
userId: userId,
|
||||||
|
srcImg1Url: pathForTask,
|
||||||
|
prompt: prompt,
|
||||||
|
size: size,
|
||||||
|
taskType: taskType,
|
||||||
|
templateName: templateName,
|
||||||
|
imgUrl: imgUrl,
|
||||||
|
needopt: needopt,
|
||||||
|
ext: ext,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!createRes.isSuccess || createRes.data == null) {
|
||||||
|
throw StateError(
|
||||||
|
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cr = createRes.data!;
|
||||||
|
if (saveLocalUploadCover) {
|
||||||
|
await TaskUploadCoverStore.saveAfterCreateTaskResponse(
|
||||||
|
response: cr,
|
||||||
|
source: part.toUpload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImagePresignedUploadCreateTaskResult(
|
||||||
|
createResponse: cr,
|
||||||
|
fileUsedForUpload: part.toUpload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传两张图:第一张 `srcImg1Url`(参数 `resolution` 或上传路径),第二张 `srcImg2`。
|
||||||
|
/// 封面与本地预览仍以 [sourceFile1] 为准。
|
||||||
|
static Future<ImagePresignedUploadCreateTaskResult> runTwoSourceFiles({
|
||||||
|
required File sourceFile1,
|
||||||
|
required File sourceFile2,
|
||||||
|
required String userId,
|
||||||
|
bool compressFirst = true,
|
||||||
|
CompressImageForUploadOptions compressOptions =
|
||||||
|
const CompressImageForUploadOptions(maxSide: 1024, jpegQuality: 75),
|
||||||
|
String? resolution,
|
||||||
|
String? prompt,
|
||||||
|
String? size,
|
||||||
|
String? taskType,
|
||||||
|
String? templateName,
|
||||||
|
String? imgUrl,
|
||||||
|
bool needopt = false,
|
||||||
|
String? ext,
|
||||||
|
bool saveLocalUploadCover = true,
|
||||||
|
Map<String, String>? extraPutHeaders,
|
||||||
|
}) async {
|
||||||
|
final part1 = await _uploadOneFile(
|
||||||
|
sourceFile: sourceFile1,
|
||||||
|
compressFirst: compressFirst,
|
||||||
|
compressOptions: compressOptions,
|
||||||
|
extraPutHeaders: extraPutHeaders,
|
||||||
|
);
|
||||||
|
final part2 = await _uploadOneFile(
|
||||||
|
sourceFile: sourceFile2,
|
||||||
|
compressFirst: compressFirst,
|
||||||
|
compressOptions: compressOptions,
|
||||||
|
extraPutHeaders: extraPutHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
final createRes = await ImageApi.createTask(
|
||||||
|
userId: userId,
|
||||||
|
srcImg1Url: resolution ?? part1.serverPath,
|
||||||
|
srcImg2: part2.serverPath,
|
||||||
|
prompt: prompt,
|
||||||
|
size: size,
|
||||||
|
taskType: taskType,
|
||||||
|
templateName: templateName,
|
||||||
|
imgUrl: imgUrl,
|
||||||
|
needopt: needopt,
|
||||||
|
ext: ext,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!createRes.isSuccess || createRes.data == null) {
|
||||||
|
throw StateError(
|
||||||
|
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cr = createRes.data!;
|
||||||
|
if (saveLocalUploadCover) {
|
||||||
|
await TaskUploadCoverStore.saveAfterCreateTaskResponse(
|
||||||
|
response: cr,
|
||||||
|
source: part1.toUpload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImagePresignedUploadCreateTaskResult(
|
||||||
|
createResponse: cr,
|
||||||
|
fileUsedForUpload: part1.toUpload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
206
lib/src/services/image_progress_poll.dart
Normal file
206
lib/src/services/image_progress_poll.dart
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' show ClientException;
|
||||||
|
|
||||||
|
import '../api/proxy_client.dart';
|
||||||
|
import '../entities/image_entities.dart';
|
||||||
|
import 'image_api.dart';
|
||||||
|
|
||||||
|
/// 单次轮询回调数据(成功从线网拿到一帧 [EntityResponse] 后触发)。
|
||||||
|
class ProgressPollTick {
|
||||||
|
const ProgressPollTick({
|
||||||
|
required this.response,
|
||||||
|
this.transientNetworkFailuresBeforeSuccess = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EntityResponse<ProgressResponse> response;
|
||||||
|
|
||||||
|
/// 本次成功请求前累计的瞬时网络失败次数(已清零后成功)。
|
||||||
|
final int transientNetworkFailuresBeforeSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进度语义(与 FunyMee 生成页约定一致),供宿主在回调里分支。
|
||||||
|
abstract final class ProgressPollSemantics {
|
||||||
|
static bool hasUsableResultUrl(String? url) {
|
||||||
|
if (url == null || url.isEmpty) return false;
|
||||||
|
final u = url.trim();
|
||||||
|
return u.startsWith('http://') || u.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isTerminalStatus(String? status) {
|
||||||
|
final t = (status ?? '').toLowerCase();
|
||||||
|
return t == 'success' ||
|
||||||
|
t == 'completed' ||
|
||||||
|
t == 'complete' ||
|
||||||
|
t == 'failed' ||
|
||||||
|
t == 'failure' ||
|
||||||
|
t == 'error' ||
|
||||||
|
t == 'cancelled' ||
|
||||||
|
t == 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isSuccessTerminal(String? status) {
|
||||||
|
final t = (status ?? '').toLowerCase();
|
||||||
|
if (t == 'success' || t == 'completed' || t == 'complete') return true;
|
||||||
|
// 部分线网把完成态写在 `status` 字符串里(或与 `state` 同步为 "3")。
|
||||||
|
if (t == '3') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isFailureTerminal(String? status) {
|
||||||
|
final t = (status ?? '').toLowerCase();
|
||||||
|
return t == 'failed' ||
|
||||||
|
t == 'failure' ||
|
||||||
|
t == 'error' ||
|
||||||
|
t == 'cancelled' ||
|
||||||
|
t == 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否应结束轮询(成功出图、终态、或已有可下载 URL)。
|
||||||
|
static bool shouldStopPolling(ProgressResponse p) {
|
||||||
|
if (hasUsableResultUrl(p.resultUrl)) return true;
|
||||||
|
if (isTerminalStatus(p.status)) return true;
|
||||||
|
final s = p.state;
|
||||||
|
// 3=完成,4=超时,5=错误,6=中止(FunyMee 文档)
|
||||||
|
if (s != null && s >= 3 && s <= 6) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否应跳转结果页(完成且尽量已有 URL;`state==3` 为权威完成态)。
|
||||||
|
static bool isProgressSuccess(ProgressResponse p) {
|
||||||
|
if (hasUsableResultUrl(p.resultUrl)) return true;
|
||||||
|
if (p.state == 3) return true;
|
||||||
|
return isSuccessTerminal(p.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否终态失败(超时/错误/中止或字符串失败态)。
|
||||||
|
static bool isProgressFailure(ProgressResponse p) {
|
||||||
|
final s = p.state;
|
||||||
|
if (s == 4 || s == 5 || s == 6) return true;
|
||||||
|
return isTerminalStatus(p.status) && isFailureTerminal(p.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可取消的轮询句柄。
|
||||||
|
class ImageProgressPollHandle {
|
||||||
|
ImageProgressPollHandle._(void Function() cancel) : _cancel = cancel;
|
||||||
|
final void Function() _cancel;
|
||||||
|
|
||||||
|
void cancel() => _cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ImageApi.getProgress] 串行轮询:上一帧完成后再等待 [interval] 发起下一帧,不重叠。
|
||||||
|
abstract final class ImageProgressPoll {
|
||||||
|
static const Duration defaultInterval = Duration(seconds: 5);
|
||||||
|
|
||||||
|
/// 在独立异步循环中运行;首次请求在 [delayBeforeFirst] 之后(默认为 0)。
|
||||||
|
///
|
||||||
|
/// - [onTick]:每次成功走完一次 `getProgress`(含业务失败码)时调用。
|
||||||
|
/// - [onTransientNetworkFailure]:瞬时网络错误时重试前调用(`count` 从 1 递增)。
|
||||||
|
/// - [onFatalError]:超过 [maxTransientNetworkFailures] 或框架判定无法继续时调用。
|
||||||
|
static ImageProgressPollHandle start({
|
||||||
|
required String app,
|
||||||
|
required String taskId,
|
||||||
|
String? userId,
|
||||||
|
Duration interval = defaultInterval,
|
||||||
|
Duration delayBeforeFirst = Duration.zero,
|
||||||
|
int maxTransientNetworkFailures = 12,
|
||||||
|
required void Function(ProgressPollTick tick) onTick,
|
||||||
|
void Function(int failureCount, int maxFailures)? onTransientNetworkFailure,
|
||||||
|
void Function(String message)? onFatalError,
|
||||||
|
}) {
|
||||||
|
var cancelled = false;
|
||||||
|
void cancel() => cancelled = true;
|
||||||
|
|
||||||
|
Future<void> loop() async {
|
||||||
|
if (delayBeforeFirst > Duration.zero) {
|
||||||
|
await Future<void>.delayed(delayBeforeFirst);
|
||||||
|
}
|
||||||
|
while (!cancelled) {
|
||||||
|
EntityResponse<ProgressResponse>? res;
|
||||||
|
var transientBeforeSuccess = 0;
|
||||||
|
|
||||||
|
while (!cancelled) {
|
||||||
|
try {
|
||||||
|
res = await ImageApi.getProgress(
|
||||||
|
app: app,
|
||||||
|
taskId: taskId,
|
||||||
|
userId: userId,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} on SocketException catch (_) {
|
||||||
|
transientBeforeSuccess++;
|
||||||
|
onTransientNetworkFailure?.call(
|
||||||
|
transientBeforeSuccess,
|
||||||
|
maxTransientNetworkFailures,
|
||||||
|
);
|
||||||
|
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
|
||||||
|
onFatalError?.call(
|
||||||
|
'Network unstable (TLS). Check connection or try again later.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} on HandshakeException catch (_) {
|
||||||
|
transientBeforeSuccess++;
|
||||||
|
onTransientNetworkFailure?.call(
|
||||||
|
transientBeforeSuccess,
|
||||||
|
maxTransientNetworkFailures,
|
||||||
|
);
|
||||||
|
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
|
||||||
|
onFatalError?.call(
|
||||||
|
'Network unstable (TLS). Check connection or try again later.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} on TlsException catch (_) {
|
||||||
|
transientBeforeSuccess++;
|
||||||
|
onTransientNetworkFailure?.call(
|
||||||
|
transientBeforeSuccess,
|
||||||
|
maxTransientNetworkFailures,
|
||||||
|
);
|
||||||
|
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
|
||||||
|
onFatalError?.call(
|
||||||
|
'Network unstable (TLS). Check connection or try again later.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} on ClientException catch (_) {
|
||||||
|
transientBeforeSuccess++;
|
||||||
|
onTransientNetworkFailure?.call(
|
||||||
|
transientBeforeSuccess,
|
||||||
|
maxTransientNetworkFailures,
|
||||||
|
);
|
||||||
|
if (transientBeforeSuccess >= maxTransientNetworkFailures) {
|
||||||
|
onFatalError?.call(
|
||||||
|
'Network unstable. Check connection or try again later.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled || res == null) return;
|
||||||
|
|
||||||
|
onTick(ProgressPollTick(
|
||||||
|
response: res,
|
||||||
|
transientNetworkFailuresBeforeSuccess: transientBeforeSuccess,
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!res.isSuccess || res.data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final p = res.data!;
|
||||||
|
if (ProgressPollSemantics.shouldStopPolling(p)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future<void>.delayed(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(loop());
|
||||||
|
return ImageProgressPollHandle._(cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
lib/src/services/image_task_history.dart
Normal file
40
lib/src/services/image_task_history.dart
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import '../entities/gallery_task_models.dart';
|
||||||
|
import '../entities/image_entities.dart';
|
||||||
|
import 'task_upload_cover_store.dart';
|
||||||
|
|
||||||
|
/// 与 app_client 对齐的「我的任务」数据解析与本地上传封面路径合并(无 UI)。
|
||||||
|
abstract final class ImageTaskHistory {
|
||||||
|
ImageTaskHistory._();
|
||||||
|
|
||||||
|
/// 解析解密后的列表体(如 `EntityResponse.data`):支持 `tasks` / `intensify`。
|
||||||
|
static List<GalleryTaskItem> parseGalleryTasksFromData(
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
) {
|
||||||
|
if (data == null) return [];
|
||||||
|
final list = (data['tasks'] ?? data['intensify']) as List<dynamic>? ?? [];
|
||||||
|
return list
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(GalleryTaskItem.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool? parseHasNextFromData(Map<String, dynamic>? data) {
|
||||||
|
if (data == null) return null;
|
||||||
|
return data['hasNext'] as bool? ?? data['manifest'] as bool?;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [GalleryTaskItem.taskId] 与 [TaskUploadCoverStore] 使用的数字 id 一致。
|
||||||
|
static Future<Map<int, String>> localCoverPathsForGalleryTasks(
|
||||||
|
Iterable<GalleryTaskItem> tasks,
|
||||||
|
) {
|
||||||
|
final ids = tasks.map((t) => t.taskId).where((id) => id > 0);
|
||||||
|
return TaskUploadCoverStore.existingPathsForTaskIdsInt(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, String>> localCoverPathsForMyTaskItems(
|
||||||
|
Iterable<MyTaskItem> items,
|
||||||
|
) {
|
||||||
|
final ids = items.map((e) => e.taskId).whereType<String>();
|
||||||
|
return TaskUploadCoverStore.existingPathsForTaskIds(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import '../api/api_client.dart';
|
|||||||
import '../api/proxy_client.dart';
|
import '../api/proxy_client.dart';
|
||||||
import '../entities/payment_entities.dart';
|
import '../entities/payment_entities.dart';
|
||||||
|
|
||||||
/// 支付相关 API(使用原始字段名)
|
/// 支付相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)
|
||||||
///
|
///
|
||||||
/// **请求头**:需登录接口自动附带 `pkg` 与 `User_token`。
|
/// **请求头**:需登录接口自动附带 `pkg` 与 `User_token`。
|
||||||
abstract final class PaymentApi {
|
abstract final class PaymentApi {
|
||||||
@ -74,18 +74,15 @@ abstract final class PaymentApi {
|
|||||||
/// 创建支付订单
|
/// 创建支付订单
|
||||||
///
|
///
|
||||||
/// **Query**:`app`、`userId`。
|
/// **Query**:`app`、`userId`。
|
||||||
/// **Body**:必填字段 + 文档中的可选字段(以下为原始名,非空才写入):
|
/// **Body**:必填字段 + 可选字段(逻辑名,非空才写入;线网名见 `skin_config.fieldMapping`):
|
||||||
/// `lastName`、`country`、`expireMonth`、`accountName`、`userInfoType`、
|
/// `lastName`、`country`、…、`card`、`status`;其中 `fps` 常映射为线网 `lineage` 等。
|
||||||
/// `automaticRenewal`、`channel`、`cvcCode`、`channelType`、`firstName`、
|
|
||||||
/// `subPaymentMethod`、`phone`、`tgOrderId`、`tgId`、`name`、`expireYear`、
|
|
||||||
/// `card`、`status`;另保留换皮用的 `lineage`、`armor`(视 [FieldMapping] 而定)。
|
|
||||||
static Future<EntityResponse<CreatePaymentResponse>> createPayment({
|
static Future<EntityResponse<CreatePaymentResponse>> createPayment({
|
||||||
required String app,
|
required String app,
|
||||||
required String userId,
|
required String userId,
|
||||||
required String activityId,
|
required String activityId,
|
||||||
required String paymentMethod,
|
required String paymentMethod,
|
||||||
String? paymentType,
|
String? paymentType,
|
||||||
String? lineage,
|
String? fps,
|
||||||
String? armor,
|
String? armor,
|
||||||
String? lastName,
|
String? lastName,
|
||||||
String? country,
|
String? country,
|
||||||
@ -118,7 +115,7 @@ abstract final class PaymentApi {
|
|||||||
'paymentMethod': paymentMethod,
|
'paymentMethod': paymentMethod,
|
||||||
if (paymentType != null && paymentType.isNotEmpty)
|
if (paymentType != null && paymentType.isNotEmpty)
|
||||||
'paymentType': paymentType,
|
'paymentType': paymentType,
|
||||||
if (lineage != null && lineage.isNotEmpty) 'lineage': lineage,
|
if (fps != null && fps.isNotEmpty) 'fps': fps,
|
||||||
if (armor != null && armor.isNotEmpty) 'armor': armor,
|
if (armor != null && armor.isNotEmpty) 'armor': armor,
|
||||||
if (lastName != null && lastName.isNotEmpty) 'lastName': lastName,
|
if (lastName != null && lastName.isNotEmpty) 'lastName': lastName,
|
||||||
if (country != null && country.isNotEmpty) 'country': country,
|
if (country != null && country.isNotEmpty) 'country': country,
|
||||||
@ -183,7 +180,7 @@ abstract final class PaymentApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取订单详情(query 使用原始字段 `id` 表示订单/支付 ID)
|
/// 获取订单详情(query 使用逻辑字段 `id` 表示订单/支付 ID)
|
||||||
static Future<EntityResponse<OrderDetailResponse>> getOrderDetail({
|
static Future<EntityResponse<OrderDetailResponse>> getOrderDetail({
|
||||||
required String userId,
|
required String userId,
|
||||||
required String orderId,
|
required String orderId,
|
||||||
|
|||||||
199
lib/src/services/payment_flow/native_iap_coordinator.dart
Normal file
199
lib/src/services/payment_flow/native_iap_coordinator.dart
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../api/api_client.dart';
|
||||||
|
import '../../entities/payment_entities.dart';
|
||||||
|
import '../payment_api.dart';
|
||||||
|
import '../payment_service.dart';
|
||||||
|
import 'payment_flow_models.dart';
|
||||||
|
import 'payment_settlement_sink.dart';
|
||||||
|
|
||||||
|
/// 原生商店内购编排(当前 **完整自动核销仅 Android Google Play**)。
|
||||||
|
///
|
||||||
|
/// 与 **app_client** [RechargeScreen] 对齐:
|
||||||
|
/// - 先 [PaymentApi.createPayment];
|
||||||
|
/// - 若响应中 **无收银台 URL**([CreatePaymentResponse.payUrl] 为空,线网常为 `convert` 空),则走 Play 拉起 + [PaymentApi.googlepay];
|
||||||
|
/// - 若有 [payUrl],则由宿主打开 H5/浏览器并轮询订单,**不**走本类。
|
||||||
|
abstract final class NativeIapCoordinator {
|
||||||
|
/// 与 app_client [RechargeScreen._shouldUseGooglePay] 一致:`payUrl` 为空则走谷歌内购。
|
||||||
|
static bool shouldLaunchGooglePlayBillingInsteadOfWeb(String? payUrl) {
|
||||||
|
final p = payUrl?.trim() ?? '';
|
||||||
|
return p.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android Google Play 全流程(内部先 [PaymentApi.createPayment])。
|
||||||
|
///
|
||||||
|
/// [activityId]:后台活动/商品 id(与 `PaymentApi.createPayment.activityId` 一致)。
|
||||||
|
/// [storeProductId]:商店架上的 `productId`(与 [PaymentProductItem] 等一致)。
|
||||||
|
static Future<void> purchaseGooglePlay({
|
||||||
|
required PaymentSettlementSink sink,
|
||||||
|
required String userId,
|
||||||
|
required String activityId,
|
||||||
|
required String storeProductId,
|
||||||
|
String paymentMethod = 'GooglePay',
|
||||||
|
String? paymentType,
|
||||||
|
String? createPaymentApp,
|
||||||
|
}) async {
|
||||||
|
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||||
|
sink.onPaymentSettled(PaymentSettlement.failure(
|
||||||
|
message:
|
||||||
|
'NativeIapCoordinator.purchaseGooglePlay only supports Android',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId.isEmpty) {
|
||||||
|
sink.onPaymentSettled(
|
||||||
|
PaymentSettlement.failure(message: 'userId is empty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cfg = ApiClient.instance.config;
|
||||||
|
final app = createPaymentApp ?? cfg.backendAppTypeAndroid;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final createRes = await PaymentApi.createPayment(
|
||||||
|
app: app,
|
||||||
|
userId: userId,
|
||||||
|
activityId: activityId,
|
||||||
|
paymentMethod: paymentMethod,
|
||||||
|
paymentType: paymentType ?? paymentMethod,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!createRes.isSuccess || createRes.data == null) {
|
||||||
|
sink.onPaymentSettled(PaymentSettlement.failure(
|
||||||
|
message: createRes.msg.isNotEmpty
|
||||||
|
? createRes.msg
|
||||||
|
: 'createPayment failed',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _completeGooglePlayIap(
|
||||||
|
sink: sink,
|
||||||
|
userId: userId,
|
||||||
|
storeProductId: storeProductId,
|
||||||
|
data: createRes.data!,
|
||||||
|
app: app,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
sink.onPaymentSettled(
|
||||||
|
PaymentSettlement.failure(message: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **建单已成功**(例如三方列表选中后已 [PaymentApi.createPayment])且 [shouldLaunchGooglePlayBillingInsteadOfWeb] 为 true 时调用:
|
||||||
|
/// 仅拉起 Play + [PaymentApi.googlepay] + consume,**不再**请求 createPayment。
|
||||||
|
///
|
||||||
|
/// 对应 app_client [RechargeScreen._launchGooglePlayPurchase](带 [serverOrderId])。
|
||||||
|
static Future<void> purchaseGooglePlayAfterCreatePayment({
|
||||||
|
required PaymentSettlementSink sink,
|
||||||
|
required String userId,
|
||||||
|
required String storeProductId,
|
||||||
|
required CreatePaymentResponse createResponse,
|
||||||
|
String? createPaymentApp,
|
||||||
|
}) async {
|
||||||
|
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||||
|
sink.onPaymentSettled(PaymentSettlement.failure(
|
||||||
|
message:
|
||||||
|
'NativeIapCoordinator.purchaseGooglePlay only supports Android',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId.isEmpty) {
|
||||||
|
sink.onPaymentSettled(
|
||||||
|
PaymentSettlement.failure(message: 'userId is empty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cfg = ApiClient.instance.config;
|
||||||
|
final app = createPaymentApp ?? cfg.backendAppTypeAndroid;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _completeGooglePlayIap(
|
||||||
|
sink: sink,
|
||||||
|
userId: userId,
|
||||||
|
storeProductId: storeProductId,
|
||||||
|
data: createResponse,
|
||||||
|
app: app,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
sink.onPaymentSettled(
|
||||||
|
PaymentSettlement.failure(message: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _completeGooglePlayIap({
|
||||||
|
required PaymentSettlementSink sink,
|
||||||
|
required String userId,
|
||||||
|
required String storeProductId,
|
||||||
|
required CreatePaymentResponse data,
|
||||||
|
required String app,
|
||||||
|
}) async {
|
||||||
|
final serverFederation = data.federationOrOrderId;
|
||||||
|
|
||||||
|
final purchase = await PaymentService.launchPurchaseAndReturnData(
|
||||||
|
storeProductId,
|
||||||
|
);
|
||||||
|
if (purchase == null) {
|
||||||
|
sink.onPaymentSettled(PaymentSettlement.cancelled(
|
||||||
|
message: 'Purchase cancelled or failed',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final federation = (serverFederation != null &&
|
||||||
|
serverFederation.isNotEmpty)
|
||||||
|
? serverFederation
|
||||||
|
: purchase.orderId;
|
||||||
|
if (serverFederation != null && serverFederation.isNotEmpty) {
|
||||||
|
await PaymentService.saveFederationForGoogleOrderId(
|
||||||
|
purchase.orderId,
|
||||||
|
serverFederation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final googlepayRes = await PaymentApi.googlepay(
|
||||||
|
signature: purchase.payload.signature,
|
||||||
|
purchaseData: purchase.payload.purchaseData,
|
||||||
|
orderId: federation,
|
||||||
|
userId: userId,
|
||||||
|
app: app,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!googlepayRes.isSuccess || googlepayRes.data == null) {
|
||||||
|
sink.onPaymentSettled(PaymentSettlement.failure(
|
||||||
|
orderId: federation,
|
||||||
|
message: googlepayRes.msg.isNotEmpty
|
||||||
|
? googlepayRes.msg
|
||||||
|
: 'googlepay verification failed',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = googlepayRes.data!;
|
||||||
|
if (!_isGooglePaySuccess(body)) {
|
||||||
|
sink.onPaymentSettled(PaymentSettlement.failure(
|
||||||
|
orderId: federation,
|
||||||
|
message: body.status ?? 'verification not successful',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PaymentService.completeAndConsumePurchase(purchase.purchaseDetails);
|
||||||
|
if (serverFederation != null && serverFederation.isNotEmpty) {
|
||||||
|
await PaymentService.removeFederationForGoogleOrderId(purchase.orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sink.onPaymentSettled(PaymentSettlement.success(
|
||||||
|
orderId: federation,
|
||||||
|
thirdParty: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isGooglePaySuccess(GooglePayCallbackResponse body) {
|
||||||
|
if (body.creditsAdded == true) return true;
|
||||||
|
final s = (body.status ?? '').toUpperCase();
|
||||||
|
return s == 'SUCCESS';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
/// 宿主注入:打开 H5/三方收银台(应用内 WebView / 系统浏览器 / 外链)。
|
||||||
|
typedef PaymentCheckoutUrlLauncher = Future<void> Function(Uri url);
|
||||||
10
lib/src/services/payment_flow/payment_flow.dart
Normal file
10
lib/src/services/payment_flow/payment_flow.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/// 支付编排(宿主策略 + 框架编排):档位列表、三方建单/轮询、Google Play 内购收口。
|
||||||
|
library payment_flow;
|
||||||
|
|
||||||
|
export 'payment_checkout_launcher.dart';
|
||||||
|
export 'payment_flow_catalog.dart';
|
||||||
|
export 'payment_flow_models.dart';
|
||||||
|
export 'payment_settlement_sink.dart';
|
||||||
|
export 'native_iap_coordinator.dart';
|
||||||
|
export 'third_party_checkout_coordinator.dart';
|
||||||
|
export 'third_party_payment_watch.dart';
|
||||||
28
lib/src/services/payment_flow/payment_flow_catalog.dart
Normal file
28
lib/src/services/payment_flow/payment_flow_catalog.dart
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../api/proxy_client.dart';
|
||||||
|
import '../../entities/payment_entities.dart';
|
||||||
|
import '../payment_api.dart';
|
||||||
|
|
||||||
|
/// 充值档位 / 商品列表(封装平台差异)。
|
||||||
|
abstract final class PaymentFlowCatalog {
|
||||||
|
/// 按当前平台加载预置支付活动(Google / Apple)。
|
||||||
|
static Future<EntityResponse<PaymentProductsResponse>> loadStoreActivities({
|
||||||
|
String? client,
|
||||||
|
String? country,
|
||||||
|
String? pkg,
|
||||||
|
}) async {
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||||
|
return PaymentApi.getApplePayActivities(
|
||||||
|
client: client,
|
||||||
|
country: country,
|
||||||
|
pkg: pkg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return PaymentApi.getGooglePayActivities(
|
||||||
|
client: client,
|
||||||
|
country: country,
|
||||||
|
pkg: pkg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
lib/src/services/payment_flow/payment_flow_models.dart
Normal file
155
lib/src/services/payment_flow/payment_flow_models.dart
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/// 支付编排结果类型(内购 / 三方统一收口,宿主在 [PaymentSettlementSink.onPaymentSettled] 内刷新账户)。
|
||||||
|
enum PaymentFlowOutcomeType {
|
||||||
|
/// 服务端或商店流程已确认成功
|
||||||
|
success,
|
||||||
|
|
||||||
|
/// 明确失败(含验单失败)
|
||||||
|
failure,
|
||||||
|
|
||||||
|
/// 用户取消或未继续
|
||||||
|
cancelled,
|
||||||
|
|
||||||
|
/// 轮询超时或达到上限
|
||||||
|
timeout,
|
||||||
|
|
||||||
|
/// 当前仅用于扩展;框架未自动核销时宿主可接手
|
||||||
|
nativePendingHostVerification,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单次支付流结束后交给宿主的一体化结果。
|
||||||
|
class PaymentSettlement {
|
||||||
|
PaymentSettlement._({
|
||||||
|
required this.type,
|
||||||
|
this.orderId,
|
||||||
|
this.message,
|
||||||
|
this.thirdParty = false,
|
||||||
|
this.extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaymentSettlement.success({
|
||||||
|
String? orderId,
|
||||||
|
String? message,
|
||||||
|
bool thirdParty = false,
|
||||||
|
Map<String, String>? extra,
|
||||||
|
}) {
|
||||||
|
return PaymentSettlement._(
|
||||||
|
type: PaymentFlowOutcomeType.success,
|
||||||
|
orderId: orderId,
|
||||||
|
message: message,
|
||||||
|
thirdParty: thirdParty,
|
||||||
|
extra: extra,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PaymentSettlement.failure({
|
||||||
|
String? orderId,
|
||||||
|
String? message,
|
||||||
|
bool thirdParty = false,
|
||||||
|
}) {
|
||||||
|
return PaymentSettlement._(
|
||||||
|
type: PaymentFlowOutcomeType.failure,
|
||||||
|
orderId: orderId,
|
||||||
|
message: message,
|
||||||
|
thirdParty: thirdParty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PaymentSettlement.cancelled({String? orderId, String? message}) {
|
||||||
|
return PaymentSettlement._(
|
||||||
|
type: PaymentFlowOutcomeType.cancelled,
|
||||||
|
orderId: orderId,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PaymentSettlement.timeout({String? orderId, String? message}) {
|
||||||
|
return PaymentSettlement._(
|
||||||
|
type: PaymentFlowOutcomeType.timeout,
|
||||||
|
orderId: orderId,
|
||||||
|
message: message,
|
||||||
|
thirdParty: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PaymentSettlement.nativePendingHostVerification({
|
||||||
|
required String message,
|
||||||
|
String? orderId,
|
||||||
|
Map<String, String>? extra,
|
||||||
|
}) {
|
||||||
|
return PaymentSettlement._(
|
||||||
|
type: PaymentFlowOutcomeType.nativePendingHostVerification,
|
||||||
|
orderId: orderId,
|
||||||
|
message: message,
|
||||||
|
extra: extra,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final PaymentFlowOutcomeType type;
|
||||||
|
final String? orderId;
|
||||||
|
final String? message;
|
||||||
|
final bool thirdParty;
|
||||||
|
final Map<String, String>? extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三方「建单 + 拉起收银台」结果。
|
||||||
|
class ThirdPartyCheckoutOutcome {
|
||||||
|
ThirdPartyCheckoutOutcome._({
|
||||||
|
required this.isSuccess,
|
||||||
|
this.message,
|
||||||
|
this.orderId,
|
||||||
|
this.payUrl,
|
||||||
|
this.createResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ThirdPartyCheckoutOutcome.ok({
|
||||||
|
required String orderId,
|
||||||
|
String? payUrl,
|
||||||
|
dynamic createResponse,
|
||||||
|
}) {
|
||||||
|
return ThirdPartyCheckoutOutcome._(
|
||||||
|
isSuccess: true,
|
||||||
|
orderId: orderId,
|
||||||
|
payUrl: payUrl,
|
||||||
|
createResponse: createResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ThirdPartyCheckoutOutcome.fail(String message) {
|
||||||
|
return ThirdPartyCheckoutOutcome._(isSuccess: false, message: message);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool isSuccess;
|
||||||
|
final String? message;
|
||||||
|
final String? orderId;
|
||||||
|
final String? payUrl;
|
||||||
|
final dynamic createResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三方订单轮询策略(按 [OrderDetailResponse.status] 字符串比较,不区分大小写)。
|
||||||
|
class PaymentPollPolicy {
|
||||||
|
const PaymentPollPolicy({
|
||||||
|
this.interval = const Duration(seconds: 2),
|
||||||
|
this.maxDuration = const Duration(minutes: 5),
|
||||||
|
this.successStatuses = const {
|
||||||
|
'paid',
|
||||||
|
'success',
|
||||||
|
'completed',
|
||||||
|
'paid_success',
|
||||||
|
'success_paid',
|
||||||
|
},
|
||||||
|
this.failureStatuses = const {
|
||||||
|
'failed',
|
||||||
|
'cancel',
|
||||||
|
'cancelled',
|
||||||
|
'canceled',
|
||||||
|
'closed',
|
||||||
|
'expired',
|
||||||
|
'fail',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
final Duration interval;
|
||||||
|
final Duration maxDuration;
|
||||||
|
final Set<String> successStatuses;
|
||||||
|
final Set<String> failureStatuses;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import 'payment_flow_models.dart';
|
||||||
|
|
||||||
|
/// 宿主实现:在任意分支结束时刷新积分 / common_info / UI。
|
||||||
|
abstract class PaymentSettlementSink {
|
||||||
|
void onPaymentSettled(PaymentSettlement settlement);
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../api/api_client.dart';
|
||||||
|
import '../payment_api.dart';
|
||||||
|
import 'payment_checkout_launcher.dart';
|
||||||
|
import 'payment_flow_models.dart';
|
||||||
|
|
||||||
|
/// 三方:创建订单 → 可选打开 [payUrl]。
|
||||||
|
abstract final class ThirdPartyCheckoutCoordinator {
|
||||||
|
/// 调 [PaymentApi.createPayment];成功时返回 `orderId`(entity 内)与 `payUrl`。
|
||||||
|
static Future<ThirdPartyCheckoutOutcome> createOrder({
|
||||||
|
required String userId,
|
||||||
|
required String activityId,
|
||||||
|
required String paymentMethod,
|
||||||
|
String? paymentType,
|
||||||
|
String? app,
|
||||||
|
String? fps,
|
||||||
|
String? armor,
|
||||||
|
String? country,
|
||||||
|
String? subPaymentMethod,
|
||||||
|
}) async {
|
||||||
|
final cfg = ApiClient.instance.config;
|
||||||
|
final backendApp = app ??
|
||||||
|
(defaultTargetPlatform == TargetPlatform.iOS
|
||||||
|
? cfg.backendAppTypeIOS
|
||||||
|
: cfg.backendAppTypeAndroid);
|
||||||
|
|
||||||
|
final res = await PaymentApi.createPayment(
|
||||||
|
app: backendApp,
|
||||||
|
userId: userId,
|
||||||
|
activityId: activityId,
|
||||||
|
paymentMethod: paymentMethod,
|
||||||
|
paymentType: paymentType,
|
||||||
|
fps: fps,
|
||||||
|
armor: armor,
|
||||||
|
country: country,
|
||||||
|
subPaymentMethod: subPaymentMethod,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.isSuccess || res.data == null) {
|
||||||
|
return ThirdPartyCheckoutOutcome.fail(
|
||||||
|
res.msg.isNotEmpty ? res.msg : 'createPayment failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = res.data!;
|
||||||
|
final oid = data.orderId ?? data.federation;
|
||||||
|
if (oid == null || oid.isEmpty) {
|
||||||
|
return ThirdPartyCheckoutOutcome.fail('Missing order id in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
final url = data.payUrl;
|
||||||
|
return ThirdPartyCheckoutOutcome.ok(
|
||||||
|
orderId: oid,
|
||||||
|
payUrl: (url != null && url.isNotEmpty) ? url : null,
|
||||||
|
createResponse: data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 若 [payUrl] 非空则调用 [launcher](宿主实现 WebView / 外链)。
|
||||||
|
static Future<void> openPayUrlIfPresent(
|
||||||
|
String? payUrl,
|
||||||
|
PaymentCheckoutUrlLauncher? launcher,
|
||||||
|
) async {
|
||||||
|
if (payUrl == null || payUrl.isEmpty) return;
|
||||||
|
if (launcher == null) {
|
||||||
|
throw StateError(
|
||||||
|
'ThirdPartyCheckoutCoordinator: payUrl is set but launcher is null',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final uri = Uri.tryParse(payUrl);
|
||||||
|
if (uri == null) {
|
||||||
|
throw FormatException('Invalid payUrl: $payUrl');
|
||||||
|
}
|
||||||
|
await launcher(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
lib/src/services/payment_flow/third_party_payment_watch.dart
Normal file
101
lib/src/services/payment_flow/third_party_payment_watch.dart
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../payment_api.dart';
|
||||||
|
import 'payment_flow_models.dart';
|
||||||
|
import 'payment_settlement_sink.dart';
|
||||||
|
|
||||||
|
/// 三方支付完成后,按订单轮询 [PaymentApi.getOrderDetail] 直至成功 / 失败 / 超时。
|
||||||
|
///
|
||||||
|
/// 宿主在「从收银台返回 App」后调用 [start];离开页面或已成功时 [stop]。
|
||||||
|
class ThirdPartyPaymentWatch {
|
||||||
|
ThirdPartyPaymentWatch({
|
||||||
|
required this.userId,
|
||||||
|
required this.sink,
|
||||||
|
this.policy = const PaymentPollPolicy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final PaymentSettlementSink sink;
|
||||||
|
final PaymentPollPolicy policy;
|
||||||
|
|
||||||
|
Timer? _timer;
|
||||||
|
DateTime? _startedAt;
|
||||||
|
bool _settled = false;
|
||||||
|
|
||||||
|
/// 开始轮询 [orderId];重复调用会先 [stop] 再起。
|
||||||
|
void start({required String orderId}) {
|
||||||
|
stop();
|
||||||
|
_settled = false;
|
||||||
|
_startedAt = DateTime.now();
|
||||||
|
_timer = Timer.periodic(policy.interval, (_) {
|
||||||
|
unawaited(_tick(orderId));
|
||||||
|
});
|
||||||
|
unawaited(_tick(orderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止轮询(不触发 sink)。
|
||||||
|
void stop() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _complete(PaymentSettlement settlement) {
|
||||||
|
if (_settled) return;
|
||||||
|
_settled = true;
|
||||||
|
stop();
|
||||||
|
sink.onPaymentSettled(settlement);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tick(String orderId) async {
|
||||||
|
if (_settled) return;
|
||||||
|
final started = _startedAt;
|
||||||
|
if (started != null &&
|
||||||
|
DateTime.now().difference(started) > policy.maxDuration) {
|
||||||
|
_complete(PaymentSettlement.timeout(
|
||||||
|
orderId: orderId,
|
||||||
|
message: 'Payment status polling timed out',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await PaymentApi.getOrderDetail(
|
||||||
|
userId: userId,
|
||||||
|
orderId: orderId,
|
||||||
|
);
|
||||||
|
if (_settled) return;
|
||||||
|
if (!res.isSuccess || res.data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final detail = res.data!;
|
||||||
|
final raw = detail.status;
|
||||||
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
|
||||||
|
final norm = raw.trim().toLowerCase();
|
||||||
|
for (final s in policy.successStatuses) {
|
||||||
|
if (norm == s.toLowerCase()) {
|
||||||
|
_complete(PaymentSettlement.success(
|
||||||
|
orderId: detail.orderId ?? orderId,
|
||||||
|
message: raw,
|
||||||
|
thirdParty: true,
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final s in policy.failureStatuses) {
|
||||||
|
if (norm == s.toLowerCase()) {
|
||||||
|
_complete(PaymentSettlement.failure(
|
||||||
|
orderId: detail.orderId ?? orderId,
|
||||||
|
message: raw,
|
||||||
|
thirdParty: true,
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* 单次失败忽略,依赖超时 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => stop();
|
||||||
|
}
|
||||||
144
lib/src/services/task_upload_cover_store.dart
Normal file
144
lib/src/services/task_upload_cover_store.dart
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../entities/image_entities.dart';
|
||||||
|
import '../entities/task_id_parse.dart';
|
||||||
|
|
||||||
|
/// 创建生图任务成功后,将用户上传的本地文件按 [taskId] 落盘一份,便于「我的任务」在接口尚未返回封面时使用。
|
||||||
|
///
|
||||||
|
/// 与 app_client [GalleryUploadCoverStore] 行为对齐:目录位于应用 support 下的 `gallery_upload_covers`,
|
||||||
|
/// 默认文件名 `{taskId}.jpg`(纯数字 id),并带 25h 过期清理。
|
||||||
|
abstract final class TaskUploadCoverStore {
|
||||||
|
TaskUploadCoverStore._();
|
||||||
|
|
||||||
|
static const String _subdir = 'gallery_upload_covers';
|
||||||
|
static const String _fileExt = '.jpg';
|
||||||
|
|
||||||
|
static const Duration maxRetention = Duration(hours: 25);
|
||||||
|
|
||||||
|
static Future<Directory> _directory() async {
|
||||||
|
final base = await getApplicationSupportDirectory();
|
||||||
|
final dir = Directory('${base.path}/$_subdir');
|
||||||
|
if (!await dir.exists()) {
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Directory> _directoryAfterPurge() async {
|
||||||
|
final dir = await _directory();
|
||||||
|
await _purgeExpired(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _purgeExpired(Directory dir) async {
|
||||||
|
if (!await dir.exists()) return;
|
||||||
|
final now = DateTime.now();
|
||||||
|
try {
|
||||||
|
await for (final entity in dir.list(followLinks: false)) {
|
||||||
|
if (entity is! File) continue;
|
||||||
|
final name = entity.uri.pathSegments.last;
|
||||||
|
if (!name.endsWith(_fileExt)) continue;
|
||||||
|
final stat = await entity.stat();
|
||||||
|
if (now.difference(stat.modified) >= maxRetention) {
|
||||||
|
try {
|
||||||
|
await entity.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与 app_client 数字 id 文件名一致:`123.jpg`。
|
||||||
|
static String fileBaseNameForTaskId(String taskId) {
|
||||||
|
final t = taskId.trim();
|
||||||
|
if (t.isEmpty) return '';
|
||||||
|
if (RegExp(r'^\d+$').hasMatch(t)) return t;
|
||||||
|
return t.replaceAll(RegExp(r'[^0-9a-zA-Z_-]+'), '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
static File _fileForTaskId(Directory dir, String taskId) =>
|
||||||
|
File('${dir.path}/${fileBaseNameForTaskId(taskId)}$_fileExt');
|
||||||
|
|
||||||
|
/// [source] 一般为压缩后的待上传文件。
|
||||||
|
static Future<void> saveForTask(String taskId, File source) async {
|
||||||
|
final id = taskId.trim();
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
if (!await source.exists()) return;
|
||||||
|
final dir = await _directoryAfterPurge();
|
||||||
|
final dest = _fileForTaskId(dir, id);
|
||||||
|
await source.copy(dest.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> saveForTaskInt(int taskId, File source) =>
|
||||||
|
saveForTask(taskId.toString(), source);
|
||||||
|
|
||||||
|
static Future<String?> pathIfExists(String taskId) async {
|
||||||
|
final id = taskId.trim();
|
||||||
|
if (id.isEmpty) return null;
|
||||||
|
final dir = await _directoryAfterPurge();
|
||||||
|
final f = _fileForTaskId(dir, id);
|
||||||
|
return await f.exists() ? f.path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> pathIfExistsInt(int taskId) async {
|
||||||
|
if (taskId <= 0) return null;
|
||||||
|
return pathIfExists(taskId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅返回已存在文件的 path,用于列表刷新后填充状态。
|
||||||
|
static Future<Map<String, String>> existingPathsForTaskIds(
|
||||||
|
Iterable<String> ids,
|
||||||
|
) async {
|
||||||
|
final dir = await _directoryAfterPurge();
|
||||||
|
final out = <String, String>{};
|
||||||
|
for (final raw in ids) {
|
||||||
|
final id = raw.trim();
|
||||||
|
if (id.isEmpty) continue;
|
||||||
|
final f = _fileForTaskId(dir, id);
|
||||||
|
if (await f.exists()) {
|
||||||
|
out[id] = f.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与 app_client 一致:key 为解析后的 int 任务 id(仅当 id 为有效正整数时加入)。
|
||||||
|
static Future<Map<int, String>> existingPathsForTaskIdsInt(
|
||||||
|
Iterable<int> ids,
|
||||||
|
) async {
|
||||||
|
final dir = await _directoryAfterPurge();
|
||||||
|
final out = <int, String>{};
|
||||||
|
for (final id in ids) {
|
||||||
|
if (id <= 0) continue;
|
||||||
|
final f = _fileForTaskId(dir, id.toString());
|
||||||
|
if (await f.exists()) {
|
||||||
|
out[id] = f.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建任务接口解密后的 data(或整段 JSON),解析出任务 id 后异步保存封面(不阻塞调用方时可 `unawaited`)。
|
||||||
|
static Future<void> saveAfterCreateTaskBody({
|
||||||
|
required Map<String, dynamic>? body,
|
||||||
|
required File source,
|
||||||
|
}) async {
|
||||||
|
final id = parseTaskIdFromMap(body);
|
||||||
|
if (id != null && id.isNotEmpty) {
|
||||||
|
await saveForTask(id, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ImageApi.createTask] 等返回的实体;[CreateTaskResponse.taskId] 由 [parseTaskIdFromMap] 解析(含 `exponential` 等键)。
|
||||||
|
static Future<void> saveAfterCreateTaskResponse({
|
||||||
|
required CreateTaskResponse? response,
|
||||||
|
required File source,
|
||||||
|
}) async {
|
||||||
|
final id = response?.taskId?.trim();
|
||||||
|
if (id != null && id.isNotEmpty) {
|
||||||
|
await saveForTask(id, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lib/src/services/user_account_refresh.dart
Normal file
26
lib/src/services/user_account_refresh.dart
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import '../entities/user_entities.dart';
|
||||||
|
import 'user_api.dart';
|
||||||
|
|
||||||
|
/// 拉取账户信息供宿主更新积分/会员等展示(不包含 `ValueNotifier` 等 UI 状态)。
|
||||||
|
abstract final class UserAccountRefresh {
|
||||||
|
UserAccountRefresh._();
|
||||||
|
|
||||||
|
/// 成功后调用 [onAccount] 并返回 [AccountResponse];失败返回 `null`,并可选 [onFailure]。
|
||||||
|
static Future<AccountResponse?> fetchAndNotify({
|
||||||
|
required String app,
|
||||||
|
String? userId,
|
||||||
|
void Function(AccountResponse account)? onAccount,
|
||||||
|
void Function(String message)? onFailure,
|
||||||
|
}) async {
|
||||||
|
final res = await UserApi.getAccount(app: app, userId: userId);
|
||||||
|
if (!res.isSuccess || res.data == null) {
|
||||||
|
final msg =
|
||||||
|
res.msg.isNotEmpty ? res.msg : 'getAccount failed (code ${res.code})';
|
||||||
|
onFailure?.call(msg);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final account = res.data!;
|
||||||
|
onAccount?.call(account);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,12 +4,12 @@ import '../api/proxy_client.dart';
|
|||||||
import '../entities/image_entities.dart';
|
import '../entities/image_entities.dart';
|
||||||
import '../entities/user_entities.dart';
|
import '../entities/user_entities.dart';
|
||||||
|
|
||||||
/// 用户相关 API(使用原始字段名)
|
/// 用户相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)
|
||||||
///
|
///
|
||||||
/// **请求头**:除 [UserApi.fastLogin] 外,需登录接口均由 [ProxyClient] 自动附带
|
/// **请求头**:除 [UserApi.fastLogin] 外,需登录接口均由 [ProxyClient] 自动附带
|
||||||
/// `pkg`(包名)与 `User_token`(已设置 token 时)。fast_login 仅带 `pkg`,不带 token。
|
/// `pkg`(包名)与 `User_token`(已设置 token 时)。fast_login 仅带 `pkg`,不带 token。
|
||||||
///
|
///
|
||||||
/// **请求体**:与《客户端指南》一致,使用解密后的**原始字段名**(如 `referer`、`deviceId`)。
|
/// **请求体**:与《客户端指南》一致,使用**业务逻辑字段名**(如 `referer`、`deviceId`)。
|
||||||
abstract final class UserApi {
|
abstract final class UserApi {
|
||||||
static ProxyClient get _client => ApiClient.instance.proxy;
|
static ProxyClient get _client => ApiClient.instance.proxy;
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ abstract final class UserApi {
|
|||||||
|
|
||||||
/// 获取用户通用信息
|
/// 获取用户通用信息
|
||||||
///
|
///
|
||||||
/// 与当前接口约定一致(原始 query 字段名,会经 [AppConfig.fieldMapping] 映射):
|
/// 与当前接口约定一致(逻辑 query 字段名,会经 [AppConfig.fieldMapping] 映射):
|
||||||
/// - [app] 必填:应用渠道标识(常见 iOS `HIOS` / Android `HAndroid`,与 fast_login 一致)
|
/// - [app] 必填:应用渠道标识(常见 iOS `HIOS` / Android `HAndroid`,与 fast_login 一致)
|
||||||
/// - [pkg] 必填:应用包名
|
/// - [pkg] 必填:应用包名
|
||||||
/// - 其余可选:[client]、[userId]、[ch]、[inviteBy]、[deviceId]、[clientId]
|
/// - 其余可选:[client]、[userId]、[ch]、[inviteBy]、[deviceId]、[clientId]
|
||||||
|
|||||||
160
lib/src/util/device_memory_profile.dart
Normal file
160
lib/src/util/device_memory_profile.dart
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// 默认与 [ClientProxyFrameworkPlugin] Android 侧注册的通道一致。
|
||||||
|
const String kDefaultDeviceMemoryChannelName =
|
||||||
|
'client_proxy_framework/device_memory';
|
||||||
|
|
||||||
|
/// 物理内存低于约 3GiB(或读不到)时仅静态封面。
|
||||||
|
const int kMemoryTierStaticOnlyBytesThreshold = 3 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// 不少于约 6GiB 时允许更高并发解码;\[3GiB, 6GiB) 为中间档。
|
||||||
|
const int kMemoryTierFullConcurrentBytesThreshold = 6 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
enum GridVideoMemoryPolicy {
|
||||||
|
staticOnly,
|
||||||
|
maxConcurrent2,
|
||||||
|
maxConcurrent4,
|
||||||
|
}
|
||||||
|
|
||||||
|
GridVideoMemoryPolicy? _cachedPolicy;
|
||||||
|
|
||||||
|
GridVideoMemoryPolicy get _effectivePolicy =>
|
||||||
|
_cachedPolicy ?? GridVideoMemoryPolicy.staticOnly;
|
||||||
|
|
||||||
|
/// 须在 `WidgetsFlutterBinding.ensureInitialized()` 之后、`runApp` 之前调用一次(建议)。
|
||||||
|
Future<void> ensureDeviceMemoryProfileInitialized({
|
||||||
|
String methodChannelName = kDefaultDeviceMemoryChannelName,
|
||||||
|
}) async {
|
||||||
|
if (_cachedPolicy != null) return;
|
||||||
|
if (!Platform.isAndroid) {
|
||||||
|
_cachedPolicy = GridVideoMemoryPolicy.maxConcurrent4;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channel = MethodChannel(methodChannelName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final raw =
|
||||||
|
await channel.invokeMethod<dynamic>('getTotalPhysicalMemoryBytes');
|
||||||
|
final bytes = _coerceToPhysicalRamBytes(raw);
|
||||||
|
if (bytes != null && _isPlausibleDeviceRamBytes(bytes)) {
|
||||||
|
_cachedPolicy = _policyFromTotalBytes(bytes);
|
||||||
|
debugPrint(
|
||||||
|
'DeviceMemory: channel totalMem≈${(bytes / (1024 * 1024)).toStringAsFixed(0)}MiB '
|
||||||
|
'→ $_cachedPolicy',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bytes != null) {
|
||||||
|
debugPrint(
|
||||||
|
'DeviceMemory: rejected implausible totalMem=$bytes (channel raw=$raw)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on MissingPluginException catch (e, st) {
|
||||||
|
debugPrint('DeviceMemory: channel missing: $e\n$st');
|
||||||
|
} on PlatformException catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'DeviceMemory: platform ${e.code} ${e.message}\n${e.details}\n$st',
|
||||||
|
);
|
||||||
|
} on Object catch (e, st) {
|
||||||
|
debugPrint('DeviceMemory: channel error: $e\n$st');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final kb = _tryParseProcMemTotalKb();
|
||||||
|
if (kb != null) {
|
||||||
|
final bytes = kb * 1024;
|
||||||
|
if (_isPlausibleDeviceRamBytes(bytes)) {
|
||||||
|
_cachedPolicy = _policyFromTotalBytes(bytes);
|
||||||
|
debugPrint(
|
||||||
|
'DeviceMemory: /proc/meminfo MemTotal=${kb}kB → $_cachedPolicy',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'DeviceMemory: MemTotal kB=$kb implausible as bytes=$bytes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on Object catch (e, st) {
|
||||||
|
debugPrint('DeviceMemory: /proc/meminfo fallback error: $e\n$st');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('DeviceMemory: unreadable → staticOnly (fallback)');
|
||||||
|
_cachedPolicy = GridVideoMemoryPolicy.staticOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
GridVideoMemoryPolicy _policyFromTotalBytes(int bytes) {
|
||||||
|
if (bytes < kMemoryTierStaticOnlyBytesThreshold) {
|
||||||
|
return GridVideoMemoryPolicy.staticOnly;
|
||||||
|
}
|
||||||
|
if (bytes < kMemoryTierFullConcurrentBytesThreshold) {
|
||||||
|
return GridVideoMemoryPolicy.maxConcurrent2;
|
||||||
|
}
|
||||||
|
return GridVideoMemoryPolicy.maxConcurrent4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否在网格中禁用视频预览(仅静态图)。
|
||||||
|
bool get deviceGridStaticPreviewOnly =>
|
||||||
|
_effectivePolicy == GridVideoMemoryPolicy.staticOnly;
|
||||||
|
|
||||||
|
/// 网格同时解码/播放并发上限(0 表示不启视频仅用封面)。
|
||||||
|
int get deviceGridMaxConcurrentVideos {
|
||||||
|
switch (_effectivePolicy) {
|
||||||
|
case GridVideoMemoryPolicy.staticOnly:
|
||||||
|
return 0;
|
||||||
|
case GridVideoMemoryPolicy.maxConcurrent2:
|
||||||
|
return 2;
|
||||||
|
case GridVideoMemoryPolicy.maxConcurrent4:
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _coerceToPhysicalRamBytes(Object? raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (raw is int) return raw > 0 ? raw : null;
|
||||||
|
if (raw is num) {
|
||||||
|
final v = raw.round();
|
||||||
|
if (v <= 0 || !v.isFinite) return null;
|
||||||
|
return v.toInt();
|
||||||
|
}
|
||||||
|
final s = raw.toString().trim();
|
||||||
|
if (s.isEmpty) return null;
|
||||||
|
return int.tryParse(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isPlausibleDeviceRamBytes(int bytes) {
|
||||||
|
const minBytes = 128 * 1024 * 1024;
|
||||||
|
const maxBytes = 32 * 1024 * 1024 * 1024;
|
||||||
|
return bytes >= minBytes && bytes <= maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _tryParseProcMemTotalKb() {
|
||||||
|
if (!Platform.isAndroid) return null;
|
||||||
|
try {
|
||||||
|
final file = File('/proc/meminfo');
|
||||||
|
final content = file.readAsStringSync();
|
||||||
|
if (content.isEmpty) return null;
|
||||||
|
final match = RegExp(
|
||||||
|
r'^MemTotal:\s+(\d+)\s+kB',
|
||||||
|
multiLine: true,
|
||||||
|
).firstMatch(content);
|
||||||
|
if (match == null) return null;
|
||||||
|
final kb = int.tryParse(match.group(1)!);
|
||||||
|
if (kb == null || kb <= 0) return null;
|
||||||
|
if (kb > 1 << 28) return null;
|
||||||
|
return kb;
|
||||||
|
} on FileSystemException catch (e, st) {
|
||||||
|
debugPrint('DeviceMemory: meminfo FileSystemException: $e\n$st');
|
||||||
|
return null;
|
||||||
|
} on IOException catch (e, st) {
|
||||||
|
debugPrint('DeviceMemory: meminfo IOException: $e\n$st');
|
||||||
|
} on FormatException catch (e, st) {
|
||||||
|
debugPrint('DeviceMemory: meminfo FormatException: $e\n$st');
|
||||||
|
} on Object catch (e, st) {
|
||||||
|
debugPrint('DeviceMemory: meminfo: $e\n$st');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
164
pubspec.lock
164
pubspec.lock
@ -9,6 +9,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.5.1"
|
version: "5.5.1"
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -49,6 +57,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -115,6 +131,22 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -131,6 +163,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
in_app_purchase:
|
in_app_purchase:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -163,6 +203,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.8+1"
|
version: "0.4.8+1"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -187,6 +243,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.0"
|
version: "2.7.0"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -203,6 +267,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.17.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.6"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.3.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -211,6 +299,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "914a07484c4380e572998d30486e77e0d9cd2faec72fee268086d07bf7f302c9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -235,6 +347,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -267,6 +387,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.9.1"
|
version: "3.9.1"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -368,6 +504,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
video_thumbnail:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: video_thumbnail
|
||||||
|
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.6"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -384,6 +528,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.0 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@ -28,3 +28,6 @@ dependencies:
|
|||||||
in_app_purchase_android: ^0.4.0+8
|
in_app_purchase_android: ^0.4.0+8
|
||||||
play_install_referrer: ^0.5.0
|
play_install_referrer: ^0.5.0
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
|
path_provider: ^2.1.2
|
||||||
|
image: ^4.3.0
|
||||||
|
video_thumbnail: ^0.5.6
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user