diff --git a/.dart_tool/package_config.json b/.dart_tool/package_config.json index 35ee0f4..b09ed07 100644 --- a/.dart_tool/package_config.json +++ b/.dart_tool/package_config.json @@ -7,6 +7,12 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/args-2.7.0", @@ -37,6 +43,12 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/collection-1.19.1", @@ -91,6 +103,18 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/http-1.6.0", @@ -103,6 +127,12 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/in_app_purchase-3.2.3", @@ -127,6 +157,18 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/js-0.7.2", @@ -145,6 +187,12 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/material_color_utilities-0.13.0", @@ -157,12 +205,48 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path-1.9.1", "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1", @@ -181,6 +265,12 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/platform-3.1.6", @@ -205,6 +295,18 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/shared_preferences-2.5.4", @@ -283,6 +385,12 @@ "packageUri": "lib/", "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", "rootUri": "file:///Users/sven/.pub-cache/hosted/pub.dev/web-1.1.1", @@ -295,6 +403,18 @@ "packageUri": "lib/", "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", "rootUri": "../", diff --git a/.dart_tool/package_graph.json b/.dart_tool/package_graph.json index 288a09f..502d49f 100644 --- a/.dart_tool/package_graph.json +++ b/.dart_tool/package_graph.json @@ -13,14 +13,36 @@ "facebook_app_events", "flutter", "http", + "image", "in_app_purchase", "in_app_purchase_android", "logger", + "path_provider", "play_install_referrer", - "shared_preferences" + "shared_preferences", + "video_thumbnail" ], "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", "version": "2.5.4", @@ -124,6 +146,56 @@ "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", "version": "2.4.1", @@ -286,30 +358,70 @@ "version": "1.4.1", "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", "version": "1.9.1", "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", "version": "7.0.1", @@ -325,24 +437,6 @@ "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", "version": "4.11.0", @@ -379,20 +473,53 @@ ] }, { - "name": "ffi", + "name": "pub_semver", "version": "2.2.0", - "dependencies": [] - }, - { - "name": "platform", - "version": "3.1.6", - "dependencies": [] - }, - { - "name": "xdg_directories", - "version": "1.1.0", "dependencies": [ + "collection" + ] + }, + { + "name": "native_toolchain_c", + "version": "0.17.6", + "dependencies": [ + "code_assets", + "glob", + "hooks", + "logging", "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" ] }, @@ -400,6 +527,69 @@ "name": "term_glyph", "version": "1.2.2", "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 diff --git a/android/src/main/kotlin/com/funymee/client_proxy_framework/ClientProxyFrameworkPlugin.kt b/android/src/main/kotlin/com/funymee/client_proxy_framework/ClientProxyFrameworkPlugin.kt index e3afb3d..420bf2e 100644 --- a/android/src/main/kotlin/com/funymee/client_proxy_framework/ClientProxyFrameworkPlugin.kt +++ b/android/src/main/kotlin/com/funymee/client_proxy_framework/ClientProxyFrameworkPlugin.kt @@ -1,6 +1,8 @@ package com.funymee.client_proxy_framework +import android.app.ActivityManager import android.app.Application +import android.content.Context import com.facebook.appevents.AppEventsLogger import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall @@ -9,6 +11,7 @@ import io.flutter.plugin.common.MethodChannel /** Facebook App Events:在引擎侧注册固定 Channel,供 Dart 触发 activateApp。 */ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { private var channel: MethodChannel? = null + private var deviceMemoryChannel: MethodChannel? = null private var applicationContext: android.content.Context? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -16,11 +19,35 @@ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandle val ch = MethodChannel(binding.binaryMessenger, CHANNEL_NAME) channel = ch 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) { channel?.setMethodCallHandler(null) channel = null + deviceMemoryChannel?.setMethodCallHandler(null) + deviceMemoryChannel = null applicationContext = null } @@ -45,5 +72,6 @@ class ClientProxyFrameworkPlugin : FlutterPlugin, MethodChannel.MethodCallHandle companion object { const val CHANNEL_NAME = "client_proxy_framework/facebook_sdk" + const val DEVICE_MEMORY_CHANNEL_NAME = "client_proxy_framework/device_memory" } } diff --git a/lib/client_proxy_framework.dart b/lib/client_proxy_framework.dart index 5519acc..4909ad7 100644 --- a/lib/client_proxy_framework.dart +++ b/lib/client_proxy_framework.dart @@ -16,12 +16,17 @@ export 'src/api/proxy_client.dart'; export 'src/bootstrap/client_bootstrap.dart'; export 'src/config/app_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/field_mapping.dart'; export 'src/config/default_field_mapping.dart'; export 'src/entities/entities.dart'; export 'src/log/app_logger.dart'; export 'src/log/sdk_reminder_log.dart'; +export 'src/media/video_thumbnail_cache.dart'; export 'src/services/adjust_service.dart'; export 'src/services/analytics_attribution_callbacks.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/feedback_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_flow/payment_flow.dart'; export 'src/services/payment_service.dart'; export 'src/services/user_api.dart'; +export 'src/util/device_memory_profile.dart'; diff --git a/lib/src/api/proxy_client.dart b/lib/src/api/proxy_client.dart index 23ff287..53a0fdf 100644 --- a/lib/src/api/proxy_client.dart +++ b/lib/src/api/proxy_client.dart @@ -97,14 +97,14 @@ class ProxyClient { return result; } - /// 发送代理请求(返回原始字段的 Map) + /// 发送代理请求(返回逻辑字段的 Map) /// - /// [headers]、[queryParams]、[body] 使用**原始字段名**, - /// 框架会按 [AppConfig.fieldMapping] 转为 V2 字段名后发送。 - /// 响应 data 会自动从 V2 转回原始字段名。 + /// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**, + /// 框架会按 [AppConfig.fieldMapping] 转为线网字段名后发送。 + /// 响应 data 会自动从线网转回逻辑字段名。 /// /// **请求头(自动注入)** - /// - [AppConfig.packageName] → 原始字段名 `pkg`(经映射后的请求头键,如 `stakeholder`) + /// - [AppConfig.packageName] → 逻辑字段名 `pkg`(再映射为线网请求头键) /// - 若已 [userToken] 且 [includeUserTokenInHeader] 为 true,则注入 `User_token` /// /// 与文档一致:**设备快速登录等无需登录态接口**应传 `includeUserTokenInHeader: false`, @@ -176,7 +176,7 @@ class ProxyClient { /// 发送代理请求并返回实体 /// - /// [headers]、[queryParams]、[body] 使用**原始字段名**。 + /// [headers]、[queryParams]、[body] 使用**业务逻辑字段名**。 /// [entityFactory] 用于将映射后的 data 转换为实体对象。 /// /// 参见 [request] 的 [includeUserTokenInHeader] 说明。 @@ -198,8 +198,18 @@ class ProxyClient { includeUserTokenInHeader: includeUserTokenInHeader, ); - if (response.isSuccess && response.data is Map) { - final entity = entityFactory(response.data as Map); + if (response.isSuccess) { + final raw = response.data; + if (raw is Map) { + final entity = entityFactory(raw); + return EntityResponse( + code: response.code, + msg: response.msg, + data: entity, + ); + } + // 成功但 `data` 为 null、或非对象(如 `true`)时仍构造实体,避免误判为失败。 + final entity = entityFactory({}); return EntityResponse( code: response.code, msg: response.msg, diff --git a/lib/src/config/app_config.dart b/lib/src/config/app_config.dart index 0f587c0..fdb42ce 100644 --- a/lib/src/config/app_config.dart +++ b/lib/src/config/app_config.dart @@ -1,59 +1,32 @@ import 'package:flutter/foundation.dart'; -import 'default_field_mapping.dart'; +import 'ext_config_key_schema.dart'; import 'field_mapping.dart'; -/// 代理请求体字段名配置 +/// 代理请求最外层 JSON 的**键名**(值为密文或明文;具体线网名由 `skin_config.json` 的 `proxyKeys` 配置)。 class ProxyKeysConfig { const ProxyKeysConfig({ - /// 代理请求体最外层「应用明文」字段的 **键名**(值由框架写入 [AppConfig.appName],与后端约定一致) - this.appIdField = 'hero_class', - - /// 原始 path 字段名 - this.pathField = 'pet_species', - - /// "POST" 或 "GET" 字段名 - this.methodField = 'power_level', - - /// 映射后的 Header 字段名(构造 JSON) - this.headerField = 'quest_rank', - - /// 映射后的 URL 参数字段名(构造 JSON) - this.paramsField = 'battle_score', - - /// V2 包装后的业务数据字段名 - this.bodyField = 'loyalty_index', - - /// 噪音字段名列表 + /// 应用标识明文字段名(值一般为 [AppConfig.appName]) + this.appIdField = 'appId', + this.pathField = 'path', + this.methodField = 'method', + this.headerField = 'headers', + this.paramsField = 'params', + this.bodyField = 'body', this.noiseKeys = const [ - 'billing_addr', - 'utm_term', - 'cluster_id', - 'lsn_value', - 'accuracy_val', - 'dir_path' + 'noise1', + 'noise2', + 'noise3', + 'noise4', ], }); - /// 代理请求体应用明文字段键名(值 = appName) final String appIdField; - - /// 原始 path 字段名 final String pathField; - - /// "POST" 或 "GET" 字段名 final String methodField; - - /// 映射后的 Header 字段名(构造 JSON) final String headerField; - - /// 映射后的 URL 参数字段名(构造 JSON) final String paramsField; - - /// V2 包装后的业务数据字段名 final String bodyField; - - /// 噪音字段名列表 final List noiseKeys; } @@ -104,26 +77,37 @@ abstract class AppConfig { /// 代理请求体字段名 ProxyKeysConfig get proxyKeys => const ProxyKeysConfig(); - /// V2 层级路径 + /// V2 包装:业务数据嵌套路径(键名由 `skin_config.json` 的 `v2.sanctumPath` 配置) List get v2SanctumPath => - const ['vault', 'tome', 'codex', 'grimoire', 'sanctum']; + const ['wrapper', 'layer', 'payload']; /// V2 包装:噪音字段名 List get v2NoiseKeys => - const ['roar', 'clash', 'thunder', 'rumble', 'howl', 'growl']; + const ['n1', 'n2', 'n3', 'n4', 'n5', 'n6']; - /// V2 层级字段名 - String get v2LevelField => 'arsenal'; + /// V2 最外层层级字段名(与 [v2LevelFixedValue] 组成固定壳) + String get v2LevelField => 'level'; /// V2 层级固定值 - int get v2LevelFixedValue => 4; + int get v2LevelFixedValue => 1; /// V2 层级固定值 Map get v2FixedValues { return {v2LevelField: v2LevelFixedValue}; } - /// 字段映射表(原始字段 ↔ V2 字段) - /// 换皮应用若后端 V2 字段名不同,覆盖此方法返回自己的映射 - FieldMapping get fieldMapping => petsHeroAIFieldMapping; + /// 字段映射(逻辑名 ↔ 线网名);未换皮时为 [kIdentityFieldMapping],**正式包应在 `skin_config.json` 配置**。 + FieldMapping get fieldMapping => kIdentityFieldMapping; + + /// `common_info.extConfig` 的 JSON 键名(由 `skin_config.extConfig` 配置)。 + ExtConfigKeySchema get extConfigKeySchema => ExtConfigKeySchema.defaults(); + + /// 合并进服务器 extConfig 之前的本地默认值;null 表示不设默认。 + Map? get extConfigDefaults => null; + + /// 视频首页:合并 [ExtConfigData.items] 的 Tab 文案(与接口分类并列;见 `skin_config.videoHome`)。 + String get videoHomeImagesTabLabel => 'Images'; + + /// 为 true 时「images」Tab 排在服务端分类之前;默认 `false`(images 在列表末尾)。 + bool get videoHomeImagesTabFirst => false; } diff --git a/lib/src/config/default_field_mapping.dart b/lib/src/config/default_field_mapping.dart index 3c99a69..4d09ba1 100644 --- a/lib/src/config/default_field_mapping.dart +++ b/lib/src/config/default_field_mapping.dart @@ -1,105 +1,5 @@ import 'field_mapping.dart'; -/// petsHeroAI 默认字段映射(原始字段 → 后端字段) -/// -/// 单一映射表,后端文档给的格式通常与此一致。 -/// 不同应用若后端字段名不同,只需提供新的 [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', -}); +/// 历史兼容:曾内置 petsHero 映射表;**线网字段名请只在 `skin_config.json` 的 `fieldMapping` 中维护**。 +@Deprecated('Use kIdentityFieldMapping; configure skin_config.json fieldMapping.') +const FieldMapping petsHeroAIFieldMapping = kIdentityFieldMapping; diff --git a/lib/src/config/ext_config_key_schema.dart b/lib/src/config/ext_config_key_schema.dart new file mode 100644 index 0000000..31806b6 --- /dev/null +++ b/lib/src/config/ext_config_key_schema.dart @@ -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 showVideoMenuKeys; + + /// 为 `true` 时表示**允许**截屏(直接读取这些键的布尔值)。 + final List allowScreenshotKeys; + + /// 为 `true` 时表示**禁止**截屏(与 [allowScreenshotKeys] 互斥推导:本键为 true ⇒ 不允许截屏)。 + final List blockScreenshotKeys; + + /// 是否允许第三方支付。 + final List allowThirdPartyPaymentKeys; + + final List privacyUrlKeys; + final List agreementUrlKeys; + + /// items 数组所在键名。 + final List itemsKeys; + + final List itemImageKeys; + final List itemImageFixKeys; + final List itemImgNeedKeys; + final List itemCostKeys; + + /// 生图分辨率 480p 对应积分(可选;缺省由业务侧按 [itemCostKeys] 推导)。 + final List itemCost480pKeys; + + /// 生图分辨率 720p 对应积分(可选;缺省用 [itemCostKeys] / `cost`)。 + final List itemCost720pKeys; + + final List itemTitleKeys; + + /// 创建任务用的模板标识(`POST /v1/image/create-task` 的 `templateName` / 换皮 `itinerary`);与展示用 [itemTitleKeys] 分离。 + final List itemTemplateNameKeys; + + /// 创建任务时的任务类型(`POST /v1/image/create-task` 的 `cipher` / 列表项换皮 `liaison` ↔ 逻辑 `taskType`)。 + final List itemTaskTypeKeys; + + /// 新业务扩展串(如 animal_expression),与 [itemDetailKeys] 并列用于 [ExtConfigItem.taskExt]。 + final List itemParamsKeys; + final List itemDetailKeys; + + /// 预览/背景视频地址;非空时 [ExtConfigItem.isVideoItem] 为 true(与 [itemImageKeys] 并列)。 + final List itemVideoUrlKeys; + + /// 首页/列表展示侧:封面图、标题、预览视频等线网键覆盖(逻辑名与 [itemKeys] 一致)。 + final Map>? itemKeysHome; + + /// 创建任务/生图侧:模板名、任务类型、params/detail、张数、计费等线网键覆盖。 + final Map>? itemKeysTask; + + /// 将 `extConfig.items` 单项**组装**成与 [TaskItem.fromJson] 一致的结构(与 `/v1/image/img2video/tasks` 单项在 mapResponse 后同形)。 + /// 非空时**优先**于 [itemKeys] 扁平解析。 + final Map>? taskItemMapping; + + /// 为 `true`(默认)时:先对单项做 [FieldMapping.mapResponse],再按 [taskItemMapping] 从**逻辑/已换皮**键上取值;为 `false` 时从**原始**单项上按源路径取值(源路径需为线网键名)。 + final bool itemsApplyFieldMappingBeforeTaskMapping; + + /// `extConfig.items` 解析出 [ExtConfigItem] 后,若 [ExtConfigItem.title] 仅空白,则替换为该字符串(见 `extConfig.defaultItemTitle`)。 + final String defaultItemTitleWhenEmpty; + + /// 首页展示用线网键:[itemKeysHome] 中该逻辑名非空则用之,否则 [fallbackFromItemKeys](通常为 [itemKeys] 解析结果)。 + List homeWireKeysFor( + String logical, + List fallbackFromItemKeys, + ) { + final o = itemKeysHome?[logical]; + if (o != null && o.isNotEmpty) return o; + return fallbackFromItemKeys; + } + + /// 生图/创建任务用线网键:[itemKeysTask] 优先,否则 [fallbackFromItemKeys]。 + List taskWireKeysFor( + String logical, + List 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? ext) { + if (ext == null) return ExtConfigKeySchema.defaults(); + final d = ExtConfigKeySchema.defaults(); + final keys = ext['keys']; + final itemKeys = ext['itemKeys'] as Map?; + + List rootList(Map? m, String k, List fallback) { + if (m == null) return fallback; + final raw = m[k]; + final parsed = _stringList(raw); + return parsed.isEmpty ? fallback : parsed; + } + + Map? keyMap; + if (keys is Map) { + keyMap = keys; + } + + List itemField( + String k, + List 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>? _optionalLogicalWireMap(dynamic raw) { + if (raw is! Map) return null; + final out = >{}; + 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 _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? defaults; + + static SkinExtConfigSection? fromRootJson(Map? ext) { + if (ext == null) return null; + final schema = ExtConfigKeySchema.fromSkinExtConfigJson(ext); + Map? def; + final raw = ext['defaults']; + if (raw is Map) { + def = Map.from(raw); + } else if (raw is String && raw.trim().isNotEmpty) { + try { + final dec = json.decode(raw); + if (dec is Map) def = dec; + } catch (_) {} + } + return SkinExtConfigSection(keySchema: schema, defaults: def); + } + + /// 浅合并:[server] 中非 null 的顶层键覆盖 [base]。 + static Map? mergeDefaults({ + Map? base, + Map? server, + }) { + if (base == null || base.isEmpty) { + if (server == null || server.isEmpty) return null; + return Map.from(server); + } + if (server == null || server.isEmpty) { + return Map.from(base); + } + final out = Map.from(base); + server.forEach((k, v) { + out[k] = v; + }); + return out; + } +} diff --git a/lib/src/config/ext_config_models.dart b/lib/src/config/ext_config_models.dart new file mode 100644 index 0000000..a31536b --- /dev/null +++ b/lib/src/config/ext_config_models.dart @@ -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 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? parseRawMap(String? extConfigJson) { + if (extConfigJson == null || extConfigJson.isEmpty) return null; + try { + final decoded = json.decode(extConfigJson); + if (decoded is Map) return decoded; + return null; + } catch (_) { + return null; + } + } + + static ExtConfigData fromJson( + Map 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 items = []; + if (rawItems != null) { + for (final e in rawItems) { + if (e is! Map) continue; + final rawItem = Map.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 m) { + return m.containsKey('previewVideo') || + m.containsKey('previewImage') || + m.containsKey('resolution480p') || + m.containsKey('resolution720p') || + m.containsKey('imageUrl') || + m.containsKey('templateUrl'); + } + + static bool? _readBoolFromKeys(Map map, List keys) { + for (final k in keys) { + if (!map.containsKey(k)) continue; + return _readBool(map, k); + } + return null; + } + + static String? _readStringFromKeys( + Map map, + List 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? _firstListFromKeys( + Map map, + List 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 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 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 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 map, + List 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 map, + List 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 map, List 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 buildTaskItemShapeFromExtItemMap( + Map src, + Map> mapping, +) { + final out = {}; + 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 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.from(cur); + cur = m[p]; + if (cur == null) return null; + } + return cur; +} + +void _mapSetAtDotPath(Map root, String dotPath, dynamic value) { + final parts = dotPath.split('.').where((s) => s.isNotEmpty).toList(); + if (parts.isEmpty) return; + Map cur = root; + for (var i = 0; i < parts.length - 1; i++) { + final p = parts[i]; + final next = cur[p]; + if (next is! Map) { + cur[p] = {}; + } + cur = cur[p]! as Map; + } + cur[parts.last] = value; +} + +const int kExtConfigItemsCategoryId = -1; + +List mergeHomeTabsWithExtConfigItems({ + required List apiTabs, + required ExtConfigData? ext, + required T staticTab, +}) { + if (ext?.showVideoMenu != true) return apiTabs; + return [...apiTabs, staticTab]; +} diff --git a/lib/src/config/ext_config_runtime.dart b/lib/src/config/ext_config_runtime.dart new file mode 100644 index 0000000..d137213 --- /dev/null +++ b/lib/src/config/ext_config_runtime.dart @@ -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 data = ValueNotifier(null); + + /// `null`:本轮会话尚未结束 common_info 请求;`true`:成功(含 extConfig 解析);`false`:接口失败。 + /// + /// **建议**:仅当 `FrameworkAuthService.isLoginComplete` 为 true 且本值为 `true` 时, + /// 再展示依赖后台下发的核心业务界面(与用户约定「common_info 成功后才展示应用内容」一致)。 + static final ValueNotifier commonInfoSucceeded = + ValueNotifier(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; + } +} diff --git a/lib/src/config/field_mapping.dart b/lib/src/config/field_mapping.dart index 46ec3ed..26dd0f2 100644 --- a/lib/src/config/field_mapping.dart +++ b/lib/src/config/field_mapping.dart @@ -1,13 +1,12 @@ /// 字段映射配置 /// -/// 单一映射表:**原始字段名 → 后端字段名**(canonical → V2)。 -/// 请求时按此表转换,响应时自动取反(V2 → 原始)。 -/// 后端给的映射表通常也不区分方向,直接填入即可。 +/// 单一映射表:**业务逻辑字段名 → 线网(V2)字段名**。 +/// 请求 [mapRequest]:逻辑名 → 线网名;响应 [mapResponse]:线网名 → 逻辑名。 +/// 全量表由各宿主 `skin_config.json` 的 `fieldMapping` 提供;未配置时使用 [kIdentityFieldMapping]。 class FieldMapping { const FieldMapping(this.mapping); - /// 原始字段名 → 后端字段名 - /// 如 {'deviceId': 'origin', 'userId': 'asset', 'userToken': 'reevaluate'} + /// 业务逻辑字段名 → 线网字段名 final Map mapping; Map get _inverse { @@ -16,10 +15,10 @@ class FieldMapping { ); } - /// 获取映射后的字段名,如果不存在则返回原始字段名 + /// 逻辑字段名 → 线网字段名;无映射则不变 String mapField(String original) => mapping[original] ?? original; - /// 获取反向映射的字段名(V2 → 原始),如果不存在则返回原始字段名 + /// 线网字段名 → 逻辑字段名;无映射则不变 String reverseMapField(String v2) => _inverse[v2] ?? v2; /// 将 Map 的 key 从原始名转为后端名(请求) @@ -81,3 +80,6 @@ class FieldMapping { /// 请求头:用户 token 字段名 String get headerUserTokenField => mapField('User_token'); } + +/// 恒等映射:未配置 `skin_config.json` 的 `fieldMapping` 时使用(逻辑名与线网名相同)。 +const FieldMapping kIdentityFieldMapping = FieldMapping({}); diff --git a/lib/src/config/skin_config.dart b/lib/src/config/skin_config.dart index 14f4c45..3e4d542 100644 --- a/lib/src/config/skin_config.dart +++ b/lib/src/config/skin_config.dart @@ -6,7 +6,7 @@ import '../log/sdk_reminder_log.dart'; import '../services/analytics_service.dart'; import 'app_config.dart'; import 'attribution_config.dart'; -import 'default_field_mapping.dart'; +import 'ext_config_key_schema.dart'; import 'field_mapping.dart'; /// JSON 换皮配置(单文件描述 API、归因、字段映射等)。 @@ -36,9 +36,20 @@ class SkinConfig implements AppConfig { required this.fieldMapping, required Map adjustEvents, 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? analyticsJson; + final SkinExtConfigSection? _skinExtConfig; final Map _adjustEvents; /// 从 JSON 文本解析(如 `rootBundle.loadString` 的结果)。 @@ -97,22 +108,16 @@ class SkinConfig implements AppConfig { final proxyKeys = _proxyKeysFromJson(json['proxyKeys']); final v2 = json['v2']; - String v2Level = 'arsenal'; - int v2Fixed = 4; - List sanctum = const [ - 'vault', - 'tome', - 'codex', - 'grimoire', - 'sanctum', - ]; + String v2Level = 'level'; + int v2Fixed = 1; + List sanctum = const ['wrapper', 'layer', 'payload']; List v2Noise = const [ - 'roar', - 'clash', - 'thunder', - 'rumble', - 'howl', - 'growl', + 'n1', + 'n2', + 'n3', + 'n4', + 'n5', + 'n6', ]; if (v2 is Map) { v2Level = v2['levelField'] as String? ?? v2Level; @@ -130,10 +135,10 @@ class SkinConfig implements AppConfig { FieldMapping mapping; final fmRaw = json['fieldMapping']; if (fmRaw == null) { - mapping = petsHeroAIFieldMapping; + mapping = kIdentityFieldMapping; } else if (fmRaw is Map) { if (fmRaw.isEmpty) { - mapping = petsHeroAIFieldMapping; + mapping = kIdentityFieldMapping; } else { final m = {}; for (final e in fmRaw.entries) { @@ -156,6 +161,23 @@ class SkinConfig implements AppConfig { } } + SkinExtConfigSection? skinExt; + final extCfg = json['extConfig']; + if (extCfg is Map) { + skinExt = SkinExtConfigSection.fromRootJson(extCfg); + } + + var videoHomeImagesTabLabel = 'Images'; + var videoHomeImagesTabFirst = false; + final vh = json['videoHome']; + if (vh is Map) { + final rawLabel = vh['imagesTabLabel']; + if (rawLabel is String && rawLabel.trim().isNotEmpty) { + videoHomeImagesTabLabel = rawLabel.trim(); + } + videoHomeImagesTabFirst = vh['imagesTabFirst'] as bool? ?? false; + } + return SkinConfig._( appName: name, appId: id, @@ -176,6 +198,9 @@ class SkinConfig implements AppConfig { fieldMapping: mapping, adjustEvents: events, analyticsJson: json['analytics'] as Map?, + skinExtConfig: skinExt, + videoHomeImagesTabLabel: videoHomeImagesTabLabel, + videoHomeImagesTabFirst: videoHomeImagesTabFirst, ); } @@ -191,25 +216,18 @@ class SkinConfig implements AppConfig { if (v is! Map) { throw FormatException('proxyKeys must be a JSON object'); } - List noise = const [ - 'billing_addr', - 'utm_term', - 'cluster_id', - 'lsn_value', - 'accuracy_val', - 'dir_path', - ]; + List noise = const ['noise1', 'noise2', 'noise3', 'noise4']; final nk = v['noiseKeys']; if (nk is List && nk.isNotEmpty) { noise = nk.map((e) => e.toString()).toList(); } return ProxyKeysConfig( - appIdField: v['appIdField'] as String? ?? 'hero_class', - pathField: v['pathField'] as String? ?? 'pet_species', - methodField: v['methodField'] as String? ?? 'power_level', - headerField: v['headerField'] as String? ?? 'quest_rank', - paramsField: v['paramsField'] as String? ?? 'battle_score', - bodyField: v['bodyField'] as String? ?? 'loyalty_index', + appIdField: v['appIdField'] as String? ?? 'appId', + pathField: v['pathField'] as String? ?? 'path', + methodField: v['methodField'] as String? ?? 'method', + headerField: v['headerField'] as String? ?? 'headers', + paramsField: v['paramsField'] as String? ?? 'params', + bodyField: v['bodyField'] as String? ?? 'body', noiseKeys: noise, ); } @@ -275,6 +293,13 @@ class SkinConfig implements AppConfig { @override final FieldMapping fieldMapping; + @override + ExtConfigKeySchema get extConfigKeySchema => + _skinExtConfig?.keySchema ?? ExtConfigKeySchema.defaults(); + + @override + Map? get extConfigDefaults => _skinExtConfig?.defaults; + @override Map get v2FixedValues => {v2LevelField: v2LevelFixedValue}; diff --git a/lib/src/config/skin_config.example.json b/lib/src/config/skin_config.example.json index 5e1af37..4f50bca 100644 --- a/lib/src/config/skin_config.example.json +++ b/lib/src/config/skin_config.example.json @@ -8,6 +8,10 @@ "iosAppType": "HIOS", "androidAppType": "HAndroid" }, + "videoHome": { + "imagesTabLabel": "Images", + "imagesTabFirst": false + }, "api": { "preBaseUrl": "https://pre-api.example.com", "prodBaseUrl": "https://api.example.com", @@ -17,24 +21,24 @@ "aesKey": "1234567890123456" }, "proxyKeys": { - "appIdField": "hero_class", - "pathField": "pet_species", - "methodField": "power_level", - "headerField": "quest_rank", - "paramsField": "battle_score", - "bodyField": "loyalty_index", - "noiseKeys": ["billing_addr", "utm_term", "cluster_id", "lsn_value", "accuracy_val", "dir_path"] + "appIdField": "appId", + "pathField": "path", + "methodField": "method", + "headerField": "headers", + "paramsField": "params", + "bodyField": "body", + "noiseKeys": ["noise1", "noise2", "noise3", "noise4"] }, "v2": { - "levelField": "arsenal", - "levelFixedValue": 4, - "sanctumPath": ["vault", "tome", "codex", "grimoire", "sanctum"], - "noiseKeys": ["roar", "clash", "thunder", "rumble", "howl", "growl"] + "levelField": "level", + "levelFixedValue": 1, + "sanctumPath": ["wrapper", "layer", "payload"], + "noiseKeys": ["n1", "n2", "n3", "n4", "n5", "n6"] }, "fieldMapping": { - "code": "helm", - "msg": "rampart", - "data": "sidekick" + "code": "httpCode", + "msg": "message", + "data": "payload" }, "analytics": { "debugLogs": false, @@ -53,5 +57,49 @@ "adjustEvents": { "register": "abc123", "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": [] + } } } diff --git a/lib/src/config/video_home_runtime.dart b/lib/src/config/video_home_runtime.dart new file mode 100644 index 0000000..39b017c --- /dev/null +++ b/lib/src/config/video_home_runtime.dart @@ -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 snapshot = + ValueNotifier(const VideoHomeSnapshot()); + + /// 当前选中的**分类** Tab 索引(与 [VideoHomeSnapshot.tabs] 对齐)。 + static final ValueNotifier selectedTabIndex = ValueNotifier(0); + + static String? _lastUserId; + static String? _lastApp; + + /// 在 [ExtConfigRuntime.applyCommonInfoSuccess] 之后由 [FrameworkAuthService] 调用。 + static Future 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 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 apiCategories = []; + if (catRes.isSuccess && catRes.data?.categories != null) { + apiCategories = catRes.data!.categories!; + } + + final withId = apiCategories.where((c) => c.id != null).toList(); + + final tabs = []; + 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 tabs) { + final i = tabs.indexWhere((t) => !t.isImages); + return i >= 0 ? i : 0; + } + + /// 重新拉取分类列表后,保留仍存在于新 [tabs] 中的分类模板缓存,避免展平页数骤减触发首页 [PageView] 误跳转。 + static Map> _mergeNetworkItemsByCategoryId( + Map> previous, + List tabs, + ) { + final validIds = {}; + for (final t in tabs) { + if (!t.isImages && t.categoryId != null) { + validIds.add(t.categoryId!); + } + } + if (validIds.isEmpty || previous.isEmpty) return const {}; + final out = >{}; + for (final e in previous.entries) { + if (validIds.contains(e.key)) { + out[e.key] = e.value; + } + } + return out; + } + + /// 确保当前 Tab 已有模板列表(images 用 ext;网络分类按需请求)。 + static Future 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 = []; + 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 tabs; + + /// 仅缓存**服务端**分类 id → 模板列表。 + final Map> networkItemsByCategoryId; + + final Set loadingCategoryIds; + + VideoHomeSnapshot copyWith({ + bool? loading, + String? error, + List? tabs, + Map>? networkItemsByCategoryId, + Set? loadingCategoryIds, + }) { + return VideoHomeSnapshot( + loading: loading ?? this.loading, + error: error ?? this.error, + tabs: tabs ?? this.tabs, + networkItemsByCategoryId: + networkItemsByCategoryId ?? this.networkItemsByCategoryId, + loadingCategoryIds: loadingCategoryIds ?? this.loadingCategoryIds, + ); + } +} diff --git a/lib/src/entities/credits_balance_parse.dart b/lib/src/entities/credits_balance_parse.dart new file mode 100644 index 0000000..2bbfce9 --- /dev/null +++ b/lib/src/entities/credits_balance_parse.dart @@ -0,0 +1,26 @@ +/// 解析用户积分余额(`credit` / `credits`)。 +/// +/// 换皮映射下余额多为 `credit`(文档 wire `export` → `credit`)。 +/// `credits`/`padding` 在部分接口里可能是 int 余额,也可能是嵌套的积分配置 +/// `Map`(如 fast_login 里 `padding: {}`),不能把 Map 当数字解析。 +int? parseUserCreditsBalance(Map 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); +} diff --git a/lib/src/entities/entities.dart b/lib/src/entities/entities.dart index 2a3ef54..2824371 100644 --- a/lib/src/entities/entities.dart +++ b/lib/src/entities/entities.dart @@ -1,5 +1,8 @@ +export 'credits_balance_parse.dart'; export 'entity.dart'; export 'feedback_entities.dart'; +export 'gallery_task_models.dart'; export 'image_entities.dart'; export 'payment_entities.dart'; +export 'task_id_parse.dart'; export 'user_entities.dart'; diff --git a/lib/src/entities/feedback_entities.dart b/lib/src/entities/feedback_entities.dart index a493755..b3a1e2c 100644 --- a/lib/src/entities/feedback_entities.dart +++ b/lib/src/entities/feedback_entities.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'entity.dart'; /// 反馈预签名上传 URL 响应 @@ -5,17 +7,63 @@ class FeedbackUploadPresignedUrlResponse extends Entity { FeedbackUploadPresignedUrlResponse({ this.uploadUrl, this.filePath, + this.putHeaders, }); final String? uploadUrl; final String? filePath; + /// 与 [UploadPresignedUrlResponse.putHeaders] 一致:PUT 到对象存储时的额外头。 + final Map? 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? _parsePutHeaders(Map json) { + final raw = json['putHeaders'] ?? + json['headers'] ?? + json['uploadHeaders'] ?? + json['requiredHeaders']; + + if (raw is List) { + final out = {}; + for (final item in raw) { + if (item is! Map) continue; + final m = Map.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.from(raw); + final out = {}; + for (final e in map.entries) { + out[e.key.toString()] = _headerValueToString(e.value); + } + return out.isEmpty ? null : out; + } + @override factory FeedbackUploadPresignedUrlResponse.fromJson( Map json) { return FeedbackUploadPresignedUrlResponse( uploadUrl: json['uploadUrl'] as String?, filePath: json['filePath'] as String?, + putHeaders: _parsePutHeaders(json), ); } @@ -23,6 +71,7 @@ class FeedbackUploadPresignedUrlResponse extends Entity { Map toJson() => { 'uploadUrl': uploadUrl, 'filePath': filePath, + if (putHeaders != null) 'putHeaders': putHeaders, }; } diff --git a/lib/src/entities/gallery_task_models.dart b/lib/src/entities/gallery_task_models.dart new file mode 100644 index 0000000..8c96a93 --- /dev/null +++ b/lib/src/entities/gallery_task_models.dart @@ -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 mediaItems; + + factory GalleryTaskItem.fromJson(Map 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? ?? []; + final items = []; + 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) { + 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); +} diff --git a/lib/src/entities/image_entities.dart b/lib/src/entities/image_entities.dart index 8ff5f26..2ee242b 100644 --- a/lib/src/entities/image_entities.dart +++ b/lib/src/entities/image_entities.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; + +import 'credits_balance_parse.dart'; import 'entity.dart'; +import 'task_id_parse.dart'; /// 分类项 class CategoryItem extends Entity { @@ -15,12 +19,20 @@ class CategoryItem extends Entity { @override factory CategoryItem.fromJson(Map json) { return CategoryItem( - id: json['id'] as int?, + id: _readInt(json['id']), name: json['name'] 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 Map toJson() => { 'id': id, @@ -39,10 +51,18 @@ class CategoryListResponse extends Entity { @override factory CategoryListResponse.fromJson(Map json) { - final list = json['categories'] as List?; + List? list = json['categories'] as List?; + list ??= json['list'] as List?; + list ??= json['records'] as List?; + final data = json['data']; + if (list == null && data is List) { + list = data; + } return CategoryListResponse( categories: list - ?.map((e) => CategoryItem.fromJson(e as Map)) + ?.map((e) => CategoryItem.fromJson( + Map.from(e as Map), + )) .toList(), ); } @@ -53,13 +73,21 @@ class CategoryListResponse extends Entity { }; } -/// 任务项 +/// 任务项(`GET /v1/image/img2video/tasks` 模板列表等;见 FunyMee 文档 `previewImage` / `title`)。 class TaskItem extends Entity { TaskItem({ this.id, this.name, this.imageUrl, this.categoryId, + this.title, + this.templateName, + this.templateUrl, + this.taskType, + this.ext, + this.previewVideoUrl, + this.credits480p, + this.credits720p, }); final String? id; @@ -67,22 +95,99 @@ class TaskItem extends Entity { final String? imageUrl; 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 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 factory TaskItem.fromJson(Map 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.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( - id: json['id'] as String?, - name: json['name'] as String?, - imageUrl: json['imageUrl'] as String?, - categoryId: json['categoryId'] as int?, + id: idStr, + name: nameFrom, + imageUrl: imgUrl, + 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 json, String key) { + final v = json[key]; + if (v is! Map) return null; + final m = Map.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 Map toJson() => { 'id': id, 'name': name, 'imageUrl': imageUrl, '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 factory TasksResponse.fromJson(Map json) { - final list = json['tasks'] as List?; + List? list = json['tasks'] as List?; + list ??= json['list'] as List?; + list ??= json['records'] as List?; + final data = json['data']; + if (list == null && data is List) { + list = data; + } return TasksResponse( tasks: list - ?.map((e) => TaskItem.fromJson(e as Map)) + ?.map((e) => TaskItem.fromJson( + Map.from(e as Map), + )) .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 { ProgressResponse({ this.taskId, this.status, + this.state, this.progress, this.resultUrl, }); final String? taskId; final String? status; + + /// 任务状态(线网 `bitrate` → 逻辑 `state`):3=完成,4/5/6=终态失败。 + final int? state; + final int? progress; 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 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.from(first); + return _readStringLoose(m['imgUrl']) ?? _readStringLoose(m['boost']); + } + @override factory ProgressResponse.fromJson(Map json) { + final fromRoot = _readStringLoose(json['resultUrl']) ?? + _readStringLoose(json['imgUrl']) ?? + _readStringLoose(json['boost']); + final fromList = _firstImageInfoUrl(json); return ProgressResponse( - taskId: json['taskId'] as String?, - status: json['status'] as String?, - progress: json['progress'] as int?, - resultUrl: json['resultUrl'] as String?, + taskId: parseTaskIdValue(json['taskId']) ?? _readStringLoose(json['taskId']), + status: _readStringLoose(json['status']), + state: _readIntLoose(json['state']), + progress: _readIntLoose(json['progress']), + resultUrl: fromRoot ?? fromList, ); } @@ -187,6 +340,7 @@ class ProgressResponse extends Entity { Map toJson() => { 'taskId': taskId, 'status': status, + 'state': state, 'progress': progress, 'resultUrl': resultUrl, }; @@ -250,16 +404,78 @@ class UploadPresignedUrlResponse extends Entity { UploadPresignedUrlResponse({ this.uploadUrl, this.filePath, + this.putHeaders, }); final String? uploadUrl; final String? filePath; + /// 上传到对象存储时额外请求头(如服务端返回的签名头;解密后为 business 字段名)。 + final Map? 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? _parsePutHeaders(Map json) { + final raw = json['putHeaders'] ?? + json['headers'] ?? + json['uploadHeaders'] ?? + json['requiredHeaders'] ?? + json['tokenize']; + + if (raw is List) { + final out = {}; + for (final item in raw) { + if (item is! Map) continue; + final m = Map.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.from(raw); + final out = {}; + 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 factory UploadPresignedUrlResponse.fromJson(Map 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( - uploadUrl: json['uploadUrl'] as String?, - filePath: json['filePath'] as String?, + uploadUrl: upload, + filePath: path, + putHeaders: _parsePutHeaders(json), ); } @@ -267,6 +483,7 @@ class UploadPresignedUrlResponse extends Entity { Map toJson() => { 'uploadUrl': uploadUrl, 'filePath': filePath, + if (putHeaders != null) 'putHeaders': putHeaders, }; } @@ -283,8 +500,8 @@ class CreateTaskResponse extends Entity { @override factory CreateTaskResponse.fromJson(Map json) { return CreateTaskResponse( - taskId: json['taskId'] as String?, - status: json['status'] as String?, + taskId: parseTaskIdFromMap(json), + 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 { MyTaskItem({ this.taskId, this.status, + this.state, this.progress, this.resultUrl, this.createTime, @@ -308,19 +528,68 @@ class MyTaskItem extends Entity { final String? taskId; final String? status; + + /// 任务状态:1=队列 2=处理中 3=完成 4=超时 5=错误 6=中止(与 [gallery_task_models] 语义一致)。 + final int? state; + final int? progress; final String? resultUrl; final String? createTime; 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 factory MyTaskItem.fromJson(Map json) { + final imgList = json['imgList'] as List? ?? + json['downsample'] as List?; + String? firstImgUrl; + if (imgList != null && imgList.isNotEmpty) { + final first = imgList.first; + if (first is Map) { + final m = Map.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( - taskId: json['taskId'] as String?, - status: json['status'] as String?, - progress: json['progress'] as int?, - resultUrl: json['resultUrl'] as String?, - createTime: json['createTime'] as String?, + taskId: parseTaskIdFromMap(json), + status: statusLoose, + state: stateVal, + progress: _readInt(json['progress']), + resultUrl: topResult ?? firstImgUrl, + createTime: createTimeStr ?? + (json['uncover'] as String?) ?? + (json['discover']?.toString()), type: json['type'] as String?, ); } @@ -329,6 +598,7 @@ class MyTaskItem extends Entity { Map toJson() => { 'taskId': taskId, 'status': status, + 'state': state, 'progress': progress, 'resultUrl': resultUrl, 'createTime': createTime, @@ -342,21 +612,35 @@ class MyTasksResponse extends Entity { this.tasks, this.total, this.cursor, + this.hasNext, }); final List? tasks; final int? total; final String? cursor; + /// 与 app_client `manifest` / 常见 `hasNext` 对齐。 + final bool? hasNext; + @override factory MyTasksResponse.fromJson(Map json) { - final list = json['tasks'] as List?; + final rawList = + json['tasks'] ?? json['intensify'] ?? json['records'] ?? json['list']; + final list = rawList is List ? rawList : null; + final totalRaw = json['total']; + int? total; + if (totalRaw is int) { + total = totalRaw; + } else if (totalRaw is num) { + total = totalRaw.toInt(); + } return MyTasksResponse( tasks: list - ?.map((e) => MyTaskItem.fromJson(e as Map)) + ?.map((e) => MyTaskItem.fromJson(Map.from(e as Map))) .toList(), - total: json['total'] as int?, + total: total, 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(), 'total': total, 'cursor': cursor, + 'hasNext': hasNext, }; } @@ -462,7 +747,7 @@ class CreditsPageInfoResponse extends Entity { factory CreditsPageInfoResponse.fromJson(Map json) { final recList = json['records'] as List?; return CreditsPageInfoResponse( - credits: _toInt(json['credits']), + credits: parseUserCreditsBalance(json), freeTimes: _toInt(json['freeTimes']), vipExpireTime: json['vipExpireTime'] as String?, isVip: json['isVip'] is bool ? json['isVip'] as bool? : null, diff --git a/lib/src/entities/payment_entities.dart b/lib/src/entities/payment_entities.dart index e05b991..e3d863a 100644 --- a/lib/src/entities/payment_entities.dart +++ b/lib/src/entities/payment_entities.dart @@ -8,7 +8,9 @@ class PaymentProductItem extends Entity { this.actualAmount, this.originAmount, this.bonus, + this.bonusCredits, this.title, + this.credits, }); final String? productId; @@ -16,20 +18,45 @@ class PaymentProductItem extends Entity { final String? actualAmount; final String? originAmount; final int? bonus; + /// 额外赠送积分(换皮线网常为 `saturation` → `bonusCredits`)。 + final int? bonusCredits; final String? title; + /// 该档位到账积分数(文档 wire 常为 `padding` → `credits`)。 + final int? credits; + @override factory PaymentProductItem.fromJson(Map json) { return PaymentProductItem( - productId: json['productId'] as String?, - activityId: json['activityId'] as String?, - actualAmount: json['actualAmount'] as String?, - originAmount: json['originAmount'] as String?, - bonus: json['bonus'] as int?, - title: json['title'] as String?, + // Wire `scene` often maps to logical `code` (see fieldMapping code→scene); Google Play id must still resolve. + productId: _stringField(json, 'productId') ?? + _stringField(json, 'code') ?? + _stringField(json, 'scene'), + activityId: _stringField(json, 'activityId'), + 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 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 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 Map toJson() => { 'productId': productId, @@ -37,7 +64,9 @@ class PaymentProductItem extends Entity { 'actualAmount': actualAmount, 'originAmount': originAmount, 'bonus': bonus, + 'bonusCredits': bonusCredits, 'title': title, + 'credits': credits, }; } @@ -51,7 +80,7 @@ class PaymentProductsResponse extends Entity { @override factory PaymentProductsResponse.fromJson(Map json) { - final list = json['productList'] as List?; + final list = _parseProductList(json); return PaymentProductsResponse( productList: list ?.map((e) => PaymentProductItem.fromJson(e as Map)) @@ -59,13 +88,24 @@ class PaymentProductsResponse extends Entity { ); } + /// 换皮映射里商品数组常见 wire 名:`animate` → 逻辑名 `activitys`;默认映射里为 `summon` → `productList`。 + static List? _parseProductList(Map json) { + final a = json['productList'] as List?; + if (a != null) return a; + final b = json['activitys'] as List?; + if (b != null) return b; + return json['summon'] as List?; + } + @override Map toJson() => { 'productList': productList?.map((e) => e.toJson()).toList(), }; } -/// 支付方式项 +/// 支付方式项(get-payment-methods;换皮线网单项常为 `renew`) +/// +/// 赠送字段与 app_client [PaymentMethodItem] 一致:`conjure`→`bonusCredits`,`enchant`→`bonusRatio`(经 [FieldMapping])。 class PaymentMethodItem extends Entity { PaymentMethodItem({ this.paymentMethod, @@ -73,6 +113,8 @@ class PaymentMethodItem extends Entity { this.name, this.icon, this.recommend, + this.bonusCredits, + this.bonusRatio, }); final String? paymentMethod; @@ -81,6 +123,49 @@ class PaymentMethodItem extends Entity { final String? icon; 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 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 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 factory PaymentMethodItem.fromJson(Map json) { return PaymentMethodItem( @@ -89,6 +174,9 @@ class PaymentMethodItem extends Entity { name: json['name'] as String?, icon: json['icon'] as String?, 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, 'icon': icon, 'recommend': recommend, + 'bonusCredits': bonusCredits, + 'bonusRatio': bonusRatio, }; } @@ -112,7 +202,7 @@ class PaymentMethodsResponse extends Entity { @override factory PaymentMethodsResponse.fromJson(Map json) { - final list = json['paymentMethods'] as List?; + final list = _parsePaymentMethodsList(json); return PaymentMethodsResponse( paymentMethods: list ?.map((e) => PaymentMethodItem.fromJson(e as Map)) @@ -120,6 +210,15 @@ class PaymentMethodsResponse extends Entity { ); } + /// 换皮线网常见:`invoke` / `renew` → 逻辑名 `paymentMethods`(与 app_client `renew` 一致)。 + static List? _parsePaymentMethodsList(Map json) { + final a = json['paymentMethods'] as List?; + if (a != null) return a; + final b = json['invoke'] as List?; + if (b != null) return b; + return json['renew'] as List?; + } + @override Map toJson() => { 'paymentMethods': paymentMethods?.map((e) => e.toJson()).toList(), @@ -132,26 +231,43 @@ class CreatePaymentResponse extends Entity { this.orderId, this.payUrl, this.status, + this.federation, }); final String? orderId; final String? payUrl; 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 factory CreatePaymentResponse.fromJson(Map json) { + final idRaw = json['orderId'] ?? json['id']; return CreatePaymentResponse( - orderId: json['orderId'] as String?, + orderId: idRaw == null ? null : idRaw.toString(), payUrl: json['payUrl'] as String?, status: json['status'] as String?, + federation: _str(json['federation']), ); } + /// 内购核销时优先使用 federation,其次 orderId。 + String? get federationOrOrderId => + (federation != null && federation!.isNotEmpty) ? federation : orderId; + @override Map toJson() => { 'orderId': orderId, 'payUrl': payUrl, 'status': status, + 'federation': federation, }; } @@ -200,9 +316,11 @@ class GooglePayCallbackResponse extends Entity { @override factory GooglePayCallbackResponse.fromJson(Map json) { final idRaw = json['orderId'] ?? json['id']; + final line = json['line'] ?? json['status']; + final statusStr = line is String ? line : json['status'] as String?; return GooglePayCallbackResponse( orderId: idRaw == null ? null : idRaw.toString(), - status: json['status'] as String?, + status: statusStr, creditsAdded: json['creditsAdded'] as bool?, ); } diff --git a/lib/src/entities/task_id_parse.dart b/lib/src/entities/task_id_parse.dart new file mode 100644 index 0000000..5e84840 --- /dev/null +++ b/lib/src/entities/task_id_parse.dart @@ -0,0 +1,22 @@ +/// 从创建任务等响应中解析任务 id(兼容 `taskId`、`tree`、`id`、`exponential` 等换皮键)。 +String? parseTaskIdFromMap(Map? 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()); +} diff --git a/lib/src/entities/user_entities.dart b/lib/src/entities/user_entities.dart index 782c3f2..912ede1 100644 --- a/lib/src/entities/user_entities.dart +++ b/lib/src/entities/user_entities.dart @@ -1,5 +1,18 @@ +import 'dart:convert'; + +import 'credits_balance_parse.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) return jsonEncode(v); + if (v is Map) return jsonEncode(Map.from(v)); + if (v is List) return jsonEncode(v); + return v.toString(); +} + /// 登录响应 class FastLoginResponse extends Entity { FastLoginResponse({ @@ -72,11 +85,11 @@ class FastLoginResponse extends Entity { return FastLoginResponse( userToken: _toString(json['userToken']), userId: _toString(json['userId']), - credits: _toInt(json['credits']), + credits: parseUserCreditsBalance(json), avatar: _toString(json['avatar']), userName: _toString(json['userName']), countryCode: _toString(json['countryCode']), - extConfig: _toString(json['extConfig']), + extConfig: _wireExtConfigJson(json['extConfig']), appFbConfig: _toString(json['appFbConfig']), usign: _toString(json['usign']), creditsRecordUrl: _toString(json['creditsRecordUrl']), @@ -199,11 +212,11 @@ class CommonInfoResponse extends Entity { return CommonInfoResponse( userToken: _toString(json['userToken']), userId: _toString(json['userId']), - credits: _toInt(json['credits']), + credits: parseUserCreditsBalance(json), avatar: _toString(json['avatar']), userName: _toString(json['userName']), countryCode: _toString(json['countryCode']), - extConfig: _toString(json['extConfig']), + extConfig: _wireExtConfigJson(json['extConfig']), appFbConfig: _toString(json['appFbConfig']), usign: _toString(json['usign']), creditsRecordUrl: _toString(json['creditsRecordUrl']), @@ -273,7 +286,7 @@ class AccountResponse extends Entity { @override factory AccountResponse.fromJson(Map json) { return AccountResponse( - credits: json['credits'] as int?, + credits: parseUserCreditsBalance(json), avatar: json['avatar'] as String?, userName: json['userName'] as String?, isVip: json['isVip'] as bool?, diff --git a/lib/src/media/video_thumbnail_cache.dart b/lib/src/media/video_thumbnail_cache.dart new file mode 100644 index 0000000..b14e2da --- /dev/null +++ b/lib/src/media/video_thumbnail_cache.dart @@ -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 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 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 _getCacheDir() async { + _cacheDir ??= await getTemporaryDirectory(); + final dir = Directory('${_cacheDir!.path}/video_thumbnails'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } +} diff --git a/lib/src/services/adjust_service.dart b/lib/src/services/adjust_service.dart index 75356c2..d1bb189 100644 --- a/lib/src/services/adjust_service.dart +++ b/lib/src/services/adjust_service.dart @@ -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 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([ + (() 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 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 { AttributionData({ this.trackerToken, diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 105cb90..dc3c8b0 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -1,11 +1,13 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../api/api_client.dart'; import '../api/proxy_client.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 'adjust_service.dart'; import 'analytics_attribution_callbacks.dart'; @@ -198,7 +200,16 @@ abstract class FrameworkAuthService { required String uid, required String deviceId, }) 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 backendApp = defaultTargetPlatform == TargetPlatform.iOS @@ -261,28 +272,36 @@ abstract class FrameworkAuthService { deviceId: deviceId, ); if (commonRes.isSuccess && commonRes.data != null) { + ExtConfigRuntime.applyCommonInfoSuccess(commonRes.data!); _callbacks?.onCommonInfoLoaded(commonRes.data!); + unawaited( + VideoHomeRuntime.hydrateAfterCommonInfo( + userId: uid, + app: backendApp, + ), + ); if (kDebugMode) { debugPrint('[AuthService] common_info: 获取成功'); } - } else if (kDebugMode) { - debugPrint( - '[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}'); + } else { + VideoHomeRuntime.reset(); + ExtConfigRuntime.applyCommonInfoFailure(); + if (kDebugMode) { + debugPrint( + '[AuthService] common_info: 失败 code=${commonRes.code} msg=${commonRes.msg}'); + } } } catch (e) { + VideoHomeRuntime.reset(); + ExtConfigRuntime.applyCommonInfoFailure(); if (kDebugMode) { debugPrint('[AuthService] common_info: 异常 $e'); } } } - /// 解析 extConfig JSON 字符串 + /// 解析 extConfig JSON 字符串为 Map(兼容旧代码;结构化解析请用 [ExtConfigData.parse])。 static Map? parseExtConfig(String? extConfigStr) { - if (extConfigStr == null || extConfigStr.isEmpty) return null; - try { - return json.decode(extConfigStr) as Map?; - } catch (_) { - return null; - } + return ExtConfigData.parseRawMap(extConfigStr); } } diff --git a/lib/src/services/feedback_api.dart b/lib/src/services/feedback_api.dart index 797b955..fa5eb74 100644 --- a/lib/src/services/feedback_api.dart +++ b/lib/src/services/feedback_api.dart @@ -2,7 +2,7 @@ import '../api/api_client.dart'; import '../api/proxy_client.dart'; import '../entities/feedback_entities.dart'; -/// 举报/反馈相关 API(使用原始字段名) +/// 举报/反馈相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名) /// /// **Body**(`submit`):`fileUrls`、`contentType`、`content`(与文档顺序一致)。 abstract final class FeedbackApi { diff --git a/lib/src/services/image_api.dart b/lib/src/services/image_api.dart index d0e32cf..e288d53 100644 --- a/lib/src/services/image_api.dart +++ b/lib/src/services/image_api.dart @@ -2,32 +2,101 @@ import '../api/api_client.dart'; import '../api/proxy_client.dart'; import '../entities/image_entities.dart'; -/// 图片/视频生成相关 API(使用原始字段名) +/// 图片/视频生成相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名)。 /// /// **请求头**:需登录接口自动附带 `pkg` 与 `User_token`(参见 [ProxyClient.request])。 abstract final class ImageApi { static ProxyClient get _client => ApiClient.instance.proxy; /// 获取图转视频分类列表 + /// + /// 兼容 `data` 为 `{ "categories": [...] }` 或 **直接为数组**(后者若走 [ProxyClient.requestEntity] + /// 会因非 Map 而丢失 [EntityResponse.data])。 static Future> getCategoryList() async { - return _client.requestEntity( + final response = await _client.request( path: '/v1/image/img2video/categories', 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 = []; + for (final e in raw) { + if (e is Map) { + final m = mapping.mapResponse(Map.from(e)); + items.add(CategoryItem.fromJson(m)); + } + } + return CategoryListResponse(categories: items); + } + if (raw is Map) { + final m = Map.from(raw); + return CategoryListResponse.fromJson(m); + } + return null; + } + + /// 获取图转视频任务列表(按分类拉取模板列表;见 FunyMee `GET /v1/image/img2video/tasks`)。 + /// + /// 兼容 `data` 为数组或 `{ "tasks": [...] }`([ProxyClient.requestEntity] 在 `data` 非 Map 时会丢实体)。 static Future> getImg2VideoTasks({ int? categoryId, }) async { - return _client.requestEntity( + final response = await _client.request( path: '/v1/image/img2video/tasks', method: 'GET', - entityFactory: TasksResponse.fromJson, queryParams: 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 = []; + for (final e in raw) { + if (e is Map) { + final m = mapping.mapResponse(Map.from(e)); + items.add(TaskItem.fromJson(m)); + } + } + return TasksResponse(tasks: items); + } + if (raw is Map) { + return TasksResponse.fromJson(Map.from(raw)); + } + return null; } /// 获取推荐提示词 @@ -50,7 +119,7 @@ abstract final class ImageApi { /// 创建文生图任务 /// - /// **Body**(原始字段):`imgCount`(默认 1)、`aspectRatio`(可选)、`prompt`(必填)。 + /// **Body**(逻辑字段):`imgCount`(默认 1)、`aspectRatio`(可选)、`prompt`(必填)。 static Future> createTxt2Img({ required String app, 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> createTask({ required String userId, + String? srcImg1Url, String? resolution, String? srcImgUrls, + String? srcImg2, String? prompt, - String? cipher, - String? heatmap, + /// 输出尺寸(如 `480p` / `720p`);与 [heatmap] 二选一,见 [heatmap]。 + String? size, + @Deprecated('Use size') String? heatmap, + String? taskType, + String? templateName, String? imgUrl, - bool allowance = false, + /// 是否优化;文档固定 `false`,映射如 `needopt`→`team`。 + bool needopt = false, String? ext, }) 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( path: '/v1/image/create-task', method: 'POST', entityFactory: CreateTaskResponse.fromJson, queryParams: {'userId': userId}, body: { - if (resolution != null) 'resolution': resolution, - if (srcImgUrls != null) 'srcImgUrls': srcImgUrls, - if (prompt != null) 'prompt': prompt, - if (cipher != null) 'cipher': cipher, - if (heatmap != null) 'heatmap': heatmap, - if (imgUrl != null) 'imgUrl': imgUrl, - if (ext != null) 'ext': ext, - 'allowance': allowance, + if (path != null) 'srcImg1Url': path, + if (srcImg2 != null && srcImg2.isNotEmpty) 'srcImg2': srcImg2, + if (prompt != null && prompt.isNotEmpty) 'prompt': prompt, + if (taskTypeVal != null) 'taskType': taskTypeVal, + if (sizeVal != null) 'size': sizeVal, + if (templateName != null && templateName.isNotEmpty) + 'templateName': templateName, + 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> getMyTasks({ required String app, String? page, + /// 每页条数;逻辑字段名为 **`size`**(换皮如 `seminar`),勿使用未映射的 `pageSize` 键。 String? pageSize, String? cursor, }) async { @@ -212,7 +308,7 @@ abstract final class ImageApi { queryParams: { 'app': app, if (page != null) 'page': page, - if (pageSize != null) 'pageSize': pageSize, + if (pageSize != null) 'size': pageSize, if (cursor != null) 'cursor': cursor, }, ); diff --git a/lib/src/services/image_compress.dart b/lib/src/services/image_compress.dart new file mode 100644 index 0000000..8b3d68a --- /dev/null +++ b/lib/src/services/image_compress.dart @@ -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 compressImageForUpload( + File source, { + int maxSide = 2048, + int jpegQuality = 85, +}) async { + return compressImageForUploadWithOptions( + source, + CompressImageForUploadOptions(maxSide: maxSide, jpegQuality: jpegQuality), + ); +} + +Future 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; + } +} diff --git a/lib/src/services/image_presigned_upload_create_flow.dart b/lib/src/services/image_presigned_upload_create_flow.dart new file mode 100644 index 0000000..3c7a2ec --- /dev/null +++ b/lib/src/services/image_presigned_upload_create_flow.dart @@ -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 _mergePutHeaders( + Map? server, + Map? extra, + String contentType, + ) { + final out = {}; + void putAll(Map? 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? 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 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? 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 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? 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, + ); + } +} diff --git a/lib/src/services/image_progress_poll.dart b/lib/src/services/image_progress_poll.dart new file mode 100644 index 0000000..b641c74 --- /dev/null +++ b/lib/src/services/image_progress_poll.dart @@ -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 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 loop() async { + if (delayBeforeFirst > Duration.zero) { + await Future.delayed(delayBeforeFirst); + } + while (!cancelled) { + EntityResponse? 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.delayed(interval); + } + } + + unawaited(loop()); + return ImageProgressPollHandle._(cancel); + } +} diff --git a/lib/src/services/image_task_history.dart b/lib/src/services/image_task_history.dart new file mode 100644 index 0000000..d910cc7 --- /dev/null +++ b/lib/src/services/image_task_history.dart @@ -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 parseGalleryTasksFromData( + Map? data, + ) { + if (data == null) return []; + final list = (data['tasks'] ?? data['intensify']) as List? ?? []; + return list + .whereType>() + .map(GalleryTaskItem.fromJson) + .toList(); + } + + static bool? parseHasNextFromData(Map? data) { + if (data == null) return null; + return data['hasNext'] as bool? ?? data['manifest'] as bool?; + } + + /// [GalleryTaskItem.taskId] 与 [TaskUploadCoverStore] 使用的数字 id 一致。 + static Future> localCoverPathsForGalleryTasks( + Iterable tasks, + ) { + final ids = tasks.map((t) => t.taskId).where((id) => id > 0); + return TaskUploadCoverStore.existingPathsForTaskIdsInt(ids); + } + + static Future> localCoverPathsForMyTaskItems( + Iterable items, + ) { + final ids = items.map((e) => e.taskId).whereType(); + return TaskUploadCoverStore.existingPathsForTaskIds(ids); + } +} diff --git a/lib/src/services/payment_api.dart b/lib/src/services/payment_api.dart index 282c88a..f70f176 100644 --- a/lib/src/services/payment_api.dart +++ b/lib/src/services/payment_api.dart @@ -2,7 +2,7 @@ import '../api/api_client.dart'; import '../api/proxy_client.dart'; import '../entities/payment_entities.dart'; -/// 支付相关 API(使用原始字段名) +/// 支付相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名) /// /// **请求头**:需登录接口自动附带 `pkg` 与 `User_token`。 abstract final class PaymentApi { @@ -74,18 +74,15 @@ abstract final class PaymentApi { /// 创建支付订单 /// /// **Query**:`app`、`userId`。 - /// **Body**:必填字段 + 文档中的可选字段(以下为原始名,非空才写入): - /// `lastName`、`country`、`expireMonth`、`accountName`、`userInfoType`、 - /// `automaticRenewal`、`channel`、`cvcCode`、`channelType`、`firstName`、 - /// `subPaymentMethod`、`phone`、`tgOrderId`、`tgId`、`name`、`expireYear`、 - /// `card`、`status`;另保留换皮用的 `lineage`、`armor`(视 [FieldMapping] 而定)。 + /// **Body**:必填字段 + 可选字段(逻辑名,非空才写入;线网名见 `skin_config.fieldMapping`): + /// `lastName`、`country`、…、`card`、`status`;其中 `fps` 常映射为线网 `lineage` 等。 static Future> createPayment({ required String app, required String userId, required String activityId, required String paymentMethod, String? paymentType, - String? lineage, + String? fps, String? armor, String? lastName, String? country, @@ -118,7 +115,7 @@ abstract final class PaymentApi { 'paymentMethod': paymentMethod, if (paymentType != null && paymentType.isNotEmpty) 'paymentType': paymentType, - if (lineage != null && lineage.isNotEmpty) 'lineage': lineage, + if (fps != null && fps.isNotEmpty) 'fps': fps, if (armor != null && armor.isNotEmpty) 'armor': armor, if (lastName != null && lastName.isNotEmpty) 'lastName': lastName, if (country != null && country.isNotEmpty) 'country': country, @@ -183,7 +180,7 @@ abstract final class PaymentApi { ); } - /// 获取订单详情(query 使用原始字段 `id` 表示订单/支付 ID) + /// 获取订单详情(query 使用逻辑字段 `id` 表示订单/支付 ID) static Future> getOrderDetail({ required String userId, required String orderId, diff --git a/lib/src/services/payment_flow/native_iap_coordinator.dart b/lib/src/services/payment_flow/native_iap_coordinator.dart new file mode 100644 index 0000000..fb3115d --- /dev/null +++ b/lib/src/services/payment_flow/native_iap_coordinator.dart @@ -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 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 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 _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'; + } +} diff --git a/lib/src/services/payment_flow/payment_checkout_launcher.dart b/lib/src/services/payment_flow/payment_checkout_launcher.dart new file mode 100644 index 0000000..3d6f26c --- /dev/null +++ b/lib/src/services/payment_flow/payment_checkout_launcher.dart @@ -0,0 +1,2 @@ +/// 宿主注入:打开 H5/三方收银台(应用内 WebView / 系统浏览器 / 外链)。 +typedef PaymentCheckoutUrlLauncher = Future Function(Uri url); diff --git a/lib/src/services/payment_flow/payment_flow.dart b/lib/src/services/payment_flow/payment_flow.dart new file mode 100644 index 0000000..10c6fd7 --- /dev/null +++ b/lib/src/services/payment_flow/payment_flow.dart @@ -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'; diff --git a/lib/src/services/payment_flow/payment_flow_catalog.dart b/lib/src/services/payment_flow/payment_flow_catalog.dart new file mode 100644 index 0000000..24f8812 --- /dev/null +++ b/lib/src/services/payment_flow/payment_flow_catalog.dart @@ -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> 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, + ); + } +} diff --git a/lib/src/services/payment_flow/payment_flow_models.dart b/lib/src/services/payment_flow/payment_flow_models.dart new file mode 100644 index 0000000..7a01d40 --- /dev/null +++ b/lib/src/services/payment_flow/payment_flow_models.dart @@ -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? 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? 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? 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 successStatuses; + final Set failureStatuses; +} diff --git a/lib/src/services/payment_flow/payment_settlement_sink.dart b/lib/src/services/payment_flow/payment_settlement_sink.dart new file mode 100644 index 0000000..2fee183 --- /dev/null +++ b/lib/src/services/payment_flow/payment_settlement_sink.dart @@ -0,0 +1,6 @@ +import 'payment_flow_models.dart'; + +/// 宿主实现:在任意分支结束时刷新积分 / common_info / UI。 +abstract class PaymentSettlementSink { + void onPaymentSettled(PaymentSettlement settlement); +} diff --git a/lib/src/services/payment_flow/third_party_checkout_coordinator.dart b/lib/src/services/payment_flow/third_party_checkout_coordinator.dart new file mode 100644 index 0000000..9514531 --- /dev/null +++ b/lib/src/services/payment_flow/third_party_checkout_coordinator.dart @@ -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 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 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); + } +} diff --git a/lib/src/services/payment_flow/third_party_payment_watch.dart b/lib/src/services/payment_flow/third_party_payment_watch.dart new file mode 100644 index 0000000..68e10d1 --- /dev/null +++ b/lib/src/services/payment_flow/third_party_payment_watch.dart @@ -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 _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(); +} diff --git a/lib/src/services/task_upload_cover_store.dart b/lib/src/services/task_upload_cover_store.dart new file mode 100644 index 0000000..0c1ed9c --- /dev/null +++ b/lib/src/services/task_upload_cover_store.dart @@ -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() async { + final base = await getApplicationSupportDirectory(); + final dir = Directory('${base.path}/$_subdir'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static Future _directoryAfterPurge() async { + final dir = await _directory(); + await _purgeExpired(dir); + return dir; + } + + static Future _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 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 saveForTaskInt(int taskId, File source) => + saveForTask(taskId.toString(), source); + + static Future 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 pathIfExistsInt(int taskId) async { + if (taskId <= 0) return null; + return pathIfExists(taskId.toString()); + } + + /// 仅返回已存在文件的 path,用于列表刷新后填充状态。 + static Future> existingPathsForTaskIds( + Iterable ids, + ) async { + final dir = await _directoryAfterPurge(); + final out = {}; + 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> existingPathsForTaskIdsInt( + Iterable ids, + ) async { + final dir = await _directoryAfterPurge(); + final out = {}; + 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 saveAfterCreateTaskBody({ + required Map? 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 saveAfterCreateTaskResponse({ + required CreateTaskResponse? response, + required File source, + }) async { + final id = response?.taskId?.trim(); + if (id != null && id.isNotEmpty) { + await saveForTask(id, source); + } + } +} diff --git a/lib/src/services/user_account_refresh.dart b/lib/src/services/user_account_refresh.dart new file mode 100644 index 0000000..ae24997 --- /dev/null +++ b/lib/src/services/user_account_refresh.dart @@ -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 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; + } +} diff --git a/lib/src/services/user_api.dart b/lib/src/services/user_api.dart index 238197f..6e56e36 100644 --- a/lib/src/services/user_api.dart +++ b/lib/src/services/user_api.dart @@ -4,12 +4,12 @@ import '../api/proxy_client.dart'; import '../entities/image_entities.dart'; import '../entities/user_entities.dart'; -/// 用户相关 API(使用原始字段名) +/// 用户相关 API(**业务逻辑字段名**,经 [FieldMapping] 映射为线网字段名) /// /// **请求头**:除 [UserApi.fastLogin] 外,需登录接口均由 [ProxyClient] 自动附带 /// `pkg`(包名)与 `User_token`(已设置 token 时)。fast_login 仅带 `pkg`,不带 token。 /// -/// **请求体**:与《客户端指南》一致,使用解密后的**原始字段名**(如 `referer`、`deviceId`)。 +/// **请求体**:与《客户端指南》一致,使用**业务逻辑字段名**(如 `referer`、`deviceId`)。 abstract final class UserApi { 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 一致) /// - [pkg] 必填:应用包名 /// - 其余可选:[client]、[userId]、[ch]、[inviteBy]、[deviceId]、[clientId] diff --git a/lib/src/util/device_memory_profile.dart b/lib/src/util/device_memory_profile.dart new file mode 100644 index 0000000..5290f4e --- /dev/null +++ b/lib/src/util/device_memory_profile.dart @@ -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 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('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; +} diff --git a/pubspec.lock b/pubspec.lock index a0c4fd3..d4cfb89 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.5.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -49,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -115,6 +131,22 @@ packages: description: flutter source: sdk 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: dependency: "direct main" description: @@ -131,6 +163,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -163,6 +203,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -187,6 +243,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -203,6 +267,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -211,6 +299,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -235,6 +347,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -267,6 +387,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -368,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -384,6 +528,22 @@ packages: url: "https://pub.dev" source: hosted 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: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 702eeb8..07c8326 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,3 +28,6 @@ dependencies: in_app_purchase_android: ^0.4.0+8 play_install_referrer: ^0.5.0 shared_preferences: ^2.2.2 + path_provider: ^2.1.2 + image: ^4.3.0 + video_thumbnail: ^0.5.6