Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d364c6d908 | ||
|
|
6997a65c96 | ||
|
|
e29ed0a2c8 | ||
|
|
1f9a8b67a2 | ||
|
|
7b1852d9a6 | ||
|
|
3b089e2205 | ||
|
|
b4343ea528 | ||
|
|
05e89b80bb | ||
|
|
2a88320228 | ||
|
|
b503cb7e6e | ||
|
|
afee4b5aec | ||
|
|
33235f450b | ||
|
|
e834ec7586 |
No files matched your search
@@ -1,9 +1,18 @@
|
|||||||
.PHONY: runner run test lint apk ipa build-web
|
.PHONY: runner run test lint apk ipa build-web rename_release_apk jinzhi_shorebird_android jinzhi_shorebird_ios jinzhi_shorebird_android_patch jinzhi_shorebird_ios_patch
|
||||||
|
|
||||||
WEB_API_URL ?= https://jinzhi.api.local
|
WEB_API_URL ?= https://jinzhi.api.local
|
||||||
WEB_BASE_HREF ?= /flutter/
|
WEB_BASE_HREF ?= /flutter/
|
||||||
|
SHOREBIRD_TOKEN_FILE ?= $(HOME)/.shorebird_tokens/jinzhi.env
|
||||||
|
SHOREBIRD = if [ ! -f "$(SHOREBIRD_TOKEN_FILE)" ]; then echo "错误: 缺少 Shorebird token 文件: $(SHOREBIRD_TOKEN_FILE)"; exit 1; fi; set -a; . "$(SHOREBIRD_TOKEN_FILE)"; set +a; shorebird
|
||||||
GITVERSION := $(shell git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)
|
GITVERSION := $(shell git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)
|
||||||
COMMON_DART_DEFINES := --dart-define=GITVERSION=$(GITVERSION)
|
RELEASE_VERSION ?= latest
|
||||||
|
PATCH_TRACK ?= staging
|
||||||
|
COMMON_DART_DEFINES := --dart-define="GITVERSION=$(GITVERSION)"
|
||||||
|
APP_VERSION := $(shell sed -n 's/^version: *//p' pubspec.yaml | head -n 1)
|
||||||
|
APK_VERSION_NAME := $(subst .,,${word 1,$(subst +, ,$(APP_VERSION))})
|
||||||
|
APK_VERSION_CODE := $(word 2,$(subst +, ,$(APP_VERSION)))
|
||||||
|
RELEASE_APK := jinzhi-$(APK_VERSION_NAME)-$(APK_VERSION_CODE)-release.apk
|
||||||
|
RELEASE_APK_DIR := build/app/outputs/flutter-apk
|
||||||
|
|
||||||
runner:
|
runner:
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
@@ -18,10 +27,29 @@ lint:
|
|||||||
flutter analyze
|
flutter analyze
|
||||||
|
|
||||||
apk:
|
apk:
|
||||||
flutter build apk $(COMMON_DART_DEFINES) --release --target-platform android-arm64
|
flutter build apk --obfuscate --split-debug-info=./build/symbos $(COMMON_DART_DEFINES) --release --target-platform android-arm64
|
||||||
|
$(MAKE) rename_release_apk
|
||||||
|
|
||||||
|
rename_release_apk:
|
||||||
|
@test -f "$(RELEASE_APK_DIR)/app-release.apk" || (echo "错误: 未找到 release APK: $(RELEASE_APK_DIR)/app-release.apk"; exit 1)
|
||||||
|
cp "$(RELEASE_APK_DIR)/app-release.apk" "$(RELEASE_APK_DIR)/$(RELEASE_APK)"
|
||||||
|
@echo "Release APK: $(RELEASE_APK_DIR)/$(RELEASE_APK)"
|
||||||
|
|
||||||
ipa:
|
ipa:
|
||||||
flutter build ipa $(COMMON_DART_DEFINES) --release
|
flutter build ipa --obfuscate --split-debug-info=./build/symbos $(COMMON_DART_DEFINES) --release
|
||||||
|
|
||||||
|
jinzhi_shorebird_android:
|
||||||
|
$(SHOREBIRD) release android --flutter-version=3.35.6 --split-debug-info=./build/symbos --target-platform android-arm64 --artifact apk -- --dart-define="GITVERSION=$(GITVERSION)"
|
||||||
|
$(MAKE) rename_release_apk
|
||||||
|
|
||||||
|
jinzhi_shorebird_ios:
|
||||||
|
$(SHOREBIRD) release ios --flutter-version=3.35.6 --split-debug-info=./build/symbos -- --dart-define="GITVERSION=$(GITVERSION)"
|
||||||
|
|
||||||
|
jinzhi_shorebird_android_patch:
|
||||||
|
$(SHOREBIRD) patch --platforms=android --release-version=$(RELEASE_VERSION) --split-debug-info=./build/symbos --track=$(PATCH_TRACK) -- --dart-define="GITVERSION=$(GITVERSION)"
|
||||||
|
|
||||||
|
jinzhi_shorebird_ios_patch:
|
||||||
|
$(SHOREBIRD) patch --platforms=ios --release-version=$(RELEASE_VERSION) --split-debug-info=./build/symbos --track=$(PATCH_TRACK) -- --dart-define="GITVERSION=$(GITVERSION)"
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
API_BASE_URL=$(WEB_API_URL) BASE_HREF=$(WEB_BASE_HREF) bash scripts/build_web.sh
|
API_BASE_URL=$(WEB_API_URL) BASE_HREF=$(WEB_BASE_HREF) bash scripts/build_web.sh
|
||||||
@@ -14,6 +14,16 @@ if (!androidSigningPropertiesFile.isFile) {
|
|||||||
}
|
}
|
||||||
androidSigningPropertiesFile.inputStream().use { androidSigningProperties.load(it) }
|
androidSigningPropertiesFile.inputStream().use { androidSigningProperties.load(it) }
|
||||||
|
|
||||||
|
val umengProperties = Properties()
|
||||||
|
val umengPropertiesFile = rootProject.file("../assets/env/.env")
|
||||||
|
if (umengPropertiesFile.isFile) {
|
||||||
|
umengPropertiesFile.inputStream().use { umengProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun umengBuildValue(name: String): String =
|
||||||
|
System.getenv(name)?.takeIf { it.isNotBlank() }
|
||||||
|
?: umengProperties.getProperty(name)?.trim().orEmpty()
|
||||||
|
|
||||||
fun requireSigningProperty(name: String): String {
|
fun requireSigningProperty(name: String): String {
|
||||||
val value = androidSigningProperties.getProperty(name)?.trim()
|
val value = androidSigningProperties.getProperty(name)?.trim()
|
||||||
if (value.isNullOrBlank()) {
|
if (value.isNullOrBlank()) {
|
||||||
@@ -23,12 +33,12 @@ fun requireSigningProperty(name: String): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.jinzhi.app"
|
namespace = "com.jinzhi.butler"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("yanting") {
|
||||||
keyAlias = requireSigningProperty("keyAlias")
|
keyAlias = requireSigningProperty("keyAlias")
|
||||||
keyPassword = requireSigningProperty("keyPassword")
|
keyPassword = requireSigningProperty("keyPassword")
|
||||||
storeFile = file(requireSigningProperty("storeFile"))
|
storeFile = file(requireSigningProperty("storeFile"))
|
||||||
@@ -46,8 +56,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// Keep the registered Jinzhi application identity. One-key login and
|
||||||
applicationId = "com.jinzhi.app"
|
// push providers validate the package-and-certificate pair.
|
||||||
|
applicationId = "com.jinzhi.butler"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -55,17 +66,37 @@ android {
|
|||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
val umengChannel = System.getenv("UMENG_CHANNEL") ?: ""
|
ndk {
|
||||||
|
abiFilters += "arm64-v8a"
|
||||||
|
}
|
||||||
|
|
||||||
|
val umengChannel = umengBuildValue("UMENG_CHANNEL").ifBlank { "test" }
|
||||||
manifestPlaceholders.putAll(
|
manifestPlaceholders.putAll(
|
||||||
mutableMapOf(
|
mutableMapOf(
|
||||||
"UMENG_CHANNEL" to umengChannel,
|
"UMENG_CHANNEL" to umengChannel,
|
||||||
|
"UMENG_APPKEY" to umengBuildValue("UMENG_ANDROID_APP_KEY"),
|
||||||
|
"UMENG_MESSAGE_SECRET" to umengBuildValue("UMENG_ANDROID_PUSH_SECRET"),
|
||||||
|
"XIAOMI_APP_ID" to umengBuildValue("XIAOMI_APP_ID"),
|
||||||
|
"XIAOMI_APP_KEY" to umengBuildValue("XIAOMI_APP_KEY"),
|
||||||
|
"OPPO_APP_KEY" to umengBuildValue("OPPO_APP_KEY"),
|
||||||
|
"OPPO_APP_SECRET" to umengBuildValue("OPPO_APP_SECRET"),
|
||||||
|
"MEIZU_APP_ID" to umengBuildValue("MEIZU_APP_ID"),
|
||||||
|
"MEIZU_APP_KEY" to umengBuildValue("MEIZU_APP_KEY"),
|
||||||
|
"HUAWEI_APP_ID" to umengBuildValue("HUAWEI_APP_ID"),
|
||||||
|
"HONOR_APP_ID" to umengBuildValue("HONOR_APP_ID"),
|
||||||
|
"VIVO_APP_KEY" to umengBuildValue("VIVO_APP_KEY"),
|
||||||
|
"VIVO_APP_ID" to umengBuildValue("VIVO_APP_ID"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
// U-Verify validates the registered package-and-certificate pair.
|
||||||
|
signingConfig = signingConfigs.getByName("yanting")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("yanting")
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />-->
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<!-- <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />-->
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:label="金值"
|
android:label="金值"
|
||||||
android:name="${applicationName}"
|
android:name=".JinzhiApplication"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:usesCleartextTraffic="false">
|
android:usesCleartextTraffic="false">
|
||||||
@@ -31,6 +35,12 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="jinzhi" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
@@ -41,6 +51,22 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="UMENG_CHANNEL"
|
android:name="UMENG_CHANNEL"
|
||||||
android:value="${UMENG_CHANNEL}" />
|
android:value="${UMENG_CHANNEL}" />
|
||||||
|
<meta-data
|
||||||
|
android:name="UMENG_APPKEY"
|
||||||
|
android:value="${UMENG_APPKEY}" />
|
||||||
|
<meta-data
|
||||||
|
android:name="UMENG_MESSAGE_SECRET"
|
||||||
|
android:value="${UMENG_MESSAGE_SECRET}" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/web_capture_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package com.jinzhi.app
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.jinzhi.butler
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import com.umeng.commonsdk.UMConfigure
|
||||||
|
import io.flutter.app.FlutterApplication
|
||||||
|
|
||||||
|
/** Runs before Flutter so UMeng has no device collection before consent. */
|
||||||
|
class JinzhiApplication : FlutterApplication() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val metadata = packageManager.getApplicationInfo(
|
||||||
|
packageName,
|
||||||
|
PackageManager.GET_META_DATA,
|
||||||
|
).metaData
|
||||||
|
val appKey = metadata?.getString("UMENG_APPKEY")?.trim().orEmpty()
|
||||||
|
val channel = metadata?.getString("UMENG_CHANNEL")?.trim().orEmpty()
|
||||||
|
.ifBlank { "test" }
|
||||||
|
if (appKey.isNotBlank()) UMConfigure.preInit(this, appKey, channel)
|
||||||
|
registerActivityLifecycleCallbacks(OneKeyLoginButtonForceDarkGuard())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aligns Android 10+ force-dark handling with Yanting's U-Verify page. */
|
||||||
|
private class OneKeyLoginButtonForceDarkGuard : ActivityLifecycleCallbacks {
|
||||||
|
override fun onActivityResumed(activity: Activity) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
|
||||||
|
activity.javaClass.name != "com.mobile.auth.gatewayauth.LoginAuthActivity"
|
||||||
|
) return
|
||||||
|
val decor = activity.window.decorView
|
||||||
|
decor.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
val id = activity.resources.getIdentifier("authsdk_login_view", "id", activity.packageName)
|
||||||
|
activity.findViewById<View>(id)?.let { button ->
|
||||||
|
button.isForceDarkAllowed = false
|
||||||
|
if (decor.viewTreeObserver.isAlive) decor.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
override fun onActivityCreated(activity: Activity, state: android.os.Bundle?) = Unit
|
||||||
|
override fun onActivityStarted(activity: Activity) = Unit
|
||||||
|
override fun onActivityPaused(activity: Activity) = Unit
|
||||||
|
override fun onActivityStopped(activity: Activity) = Unit
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, state: android.os.Bundle) = Unit
|
||||||
|
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package com.jinzhi.butler
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.umeng.commonsdk.UMConfigure
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity() {
|
||||||
|
companion object {
|
||||||
|
private const val WEB_FILE_PICKER_CHANNEL = "com.jinzhi.butler/web_file_picker"
|
||||||
|
private const val WEB_FILE_PICKER_REQUEST_CODE = 9814
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pendingFilePickerResult: MethodChannel.Result? = null
|
||||||
|
private var pendingCapturedFile: File? = null
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jinzhi/privacy")
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method == "submitUmengPrivacyGranted") {
|
||||||
|
UMConfigure.submitPolicyGrantResult(applicationContext, true)
|
||||||
|
result.success(null)
|
||||||
|
} else {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WEB_FILE_PICKER_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method != "pickFiles") {
|
||||||
|
result.notImplemented()
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
openWebFilePicker(call, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openWebFilePicker(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (pendingFilePickerResult != null) {
|
||||||
|
result.error("picker_in_progress", "A file picker is already open.", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val acceptTypes = call.argument<List<String>>("acceptTypes")
|
||||||
|
.orEmpty().map(String::trim).filter(String::isNotEmpty).distinct()
|
||||||
|
val allowMultiple = call.argument<Boolean>("allowMultiple") ?: false
|
||||||
|
val captureEnabled = call.argument<Boolean>("captureEnabled") ?: false
|
||||||
|
val isQiyuSession = call.argument<Boolean>("isQiyuSession") ?: false
|
||||||
|
|
||||||
|
if (captureEnabled && !allowMultiple) {
|
||||||
|
captureIntentFor(acceptTypes)?.let { intent ->
|
||||||
|
pendingFilePickerResult = result
|
||||||
|
launchWebFilePicker(intent, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isQiyuSession && captureEnabled && isImageOrVideoRequest(acceptTypes)) {
|
||||||
|
val captureIntent = captureIntentFor(acceptTypes)
|
||||||
|
if (captureIntent != null) {
|
||||||
|
pendingFilePickerResult = result
|
||||||
|
val chooser = Intent.createChooser(
|
||||||
|
fileContentIntent(acceptTypes, allowMultiple),
|
||||||
|
"选择操作",
|
||||||
|
).putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(captureIntent))
|
||||||
|
launchWebFilePicker(chooser, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingFilePickerResult = result
|
||||||
|
launchWebFilePicker(
|
||||||
|
Intent.createChooser(fileContentIntent(acceptTypes, allowMultiple), "选择文件"),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileContentIntent(acceptTypes: List<String>, allowMultiple: Boolean) =
|
||||||
|
Intent(Intent.ACTION_GET_CONTENT)
|
||||||
|
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple)
|
||||||
|
.apply {
|
||||||
|
if (acceptTypes.size == 1) type = acceptTypes.first()
|
||||||
|
else {
|
||||||
|
type = "*/*"
|
||||||
|
if (acceptTypes.isNotEmpty()) {
|
||||||
|
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isImageOrVideoRequest(acceptTypes: List<String>): Boolean {
|
||||||
|
if (acceptTypes.isEmpty()) return false
|
||||||
|
val onlyImages = acceptTypes.all {
|
||||||
|
it.equals("image/*", ignoreCase = true) || it.startsWith("image/", ignoreCase = true)
|
||||||
|
}
|
||||||
|
val onlyVideos = acceptTypes.all {
|
||||||
|
it.equals("video/*", ignoreCase = true) || it.startsWith("video/", ignoreCase = true)
|
||||||
|
}
|
||||||
|
return onlyImages || onlyVideos
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureIntentFor(acceptTypes: List<String>): Intent? {
|
||||||
|
val acceptsVideo = acceptTypes.any {
|
||||||
|
it.equals("video/*", ignoreCase = true) || it.startsWith("video/", ignoreCase = true)
|
||||||
|
}
|
||||||
|
val acceptsImage = acceptTypes.isEmpty() || acceptTypes.any {
|
||||||
|
it.equals("image/*", ignoreCase = true) || it.startsWith("image/", ignoreCase = true)
|
||||||
|
}
|
||||||
|
val action = when {
|
||||||
|
acceptsVideo && !acceptsImage -> MediaStore.ACTION_VIDEO_CAPTURE
|
||||||
|
acceptsImage -> MediaStore.ACTION_IMAGE_CAPTURE
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val directory = File(cacheDir, "web_captures").apply { mkdirs() }
|
||||||
|
val suffix = if (action == MediaStore.ACTION_VIDEO_CAPTURE) ".mp4" else ".jpg"
|
||||||
|
val output = File.createTempFile("web_", suffix, directory)
|
||||||
|
val uri: Uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", output)
|
||||||
|
pendingCapturedFile = output
|
||||||
|
return Intent(action)
|
||||||
|
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.apply { clipData = ClipData.newRawUri("web_capture", uri) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchWebFilePicker(intent: Intent, result: MethodChannel.Result) {
|
||||||
|
try {
|
||||||
|
startActivityForResult(intent, WEB_FILE_PICKER_REQUEST_CODE)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
pendingFilePickerResult = null
|
||||||
|
pendingCapturedFile?.delete()
|
||||||
|
pendingCapturedFile = null
|
||||||
|
result.success(emptyList<String>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Android, retained for FlutterActivity compatibility")
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
if (requestCode != WEB_FILE_PICKER_REQUEST_CODE) return
|
||||||
|
val result = pendingFilePickerResult ?: return
|
||||||
|
pendingFilePickerResult = null
|
||||||
|
if (resultCode != Activity.RESULT_OK) {
|
||||||
|
pendingCapturedFile?.delete()
|
||||||
|
pendingCapturedFile = null
|
||||||
|
result.success(emptyList<String>())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val capturedFile = pendingCapturedFile
|
||||||
|
val hasCapturedFile = capturedFile?.length()?.let { it > 0L } == true
|
||||||
|
val uris = buildList {
|
||||||
|
if (hasCapturedFile) {
|
||||||
|
add(FileProvider.getUriForFile(
|
||||||
|
this@MainActivity, "$packageName.fileprovider", capturedFile!!,
|
||||||
|
).toString())
|
||||||
|
} else {
|
||||||
|
data?.data?.let { add(it.toString()) }
|
||||||
|
data?.clipData?.let { clip ->
|
||||||
|
for (index in 0 until clip.itemCount) add(clip.getItemAt(index).uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.distinct()
|
||||||
|
if (!hasCapturedFile) capturedFile?.delete()
|
||||||
|
pendingCapturedFile = null
|
||||||
|
result.success(uris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="52dp"
|
||||||
|
android:height="60dp"
|
||||||
|
android:viewportWidth="104"
|
||||||
|
android:viewportHeight="120">
|
||||||
|
<path
|
||||||
|
android:fillColor="#F7F7F7"
|
||||||
|
android:pathData="M31,48L15,60L31,72L36,67L27,61L43,61L43,53L27,53L36,53Z" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 394 B |
|
After Width: | Height: | Size: 751 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 369 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="52dp"
|
||||||
|
android:height="60dp"
|
||||||
|
android:viewportWidth="104"
|
||||||
|
android:viewportHeight="120">
|
||||||
|
<path
|
||||||
|
android:fillColor="#252529"
|
||||||
|
android:pathData="M31,48L15,60L31,72L36,67L27,61L43,61L43,53L27,53L36,53Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="web_captures" path="web_captures/" />
|
||||||
|
</paths>
|
||||||
@@ -2,8 +2,6 @@ allprojects {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
// android_id on the Git main branch uses EasyProtector 1.1.2. Its
|
|
||||||
// original JCenter publication is retired; Appodeal maintains it.
|
|
||||||
maven("https://maven.aliyun.com/repository/public")
|
maven("https://maven.aliyun.com/repository/public")
|
||||||
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
||||||
maven("https://maven.aliyun.com/repository/google")
|
maven("https://maven.aliyun.com/repository/google")
|
||||||
@@ -25,6 +23,20 @@ subprojects {
|
|||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Gitea U-Verify plugin still targets an older Android Gradle Plugin and
|
||||||
|
// declares only the manifest package. AGP 8 requires an explicit namespace;
|
||||||
|
// keep the dependency as a Git package and supply its published namespace here.
|
||||||
|
subprojects {
|
||||||
|
plugins.withId("com.android.library") {
|
||||||
|
if (project.name == "umeng_verify_sdk") {
|
||||||
|
val androidExtension = extensions.getByName("android")
|
||||||
|
androidExtension.javaClass
|
||||||
|
.getMethod("setNamespace", String::class.java)
|
||||||
|
.invoke(androidExtension, "com.umeng.umeng_verify_sdk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!-- Required meta tags -->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||||
|
html {
|
||||||
|
line-height: 1.15;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
article, aside, footer, header, nav, section {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin: .67em 0
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption, figure, main {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 1em 40px
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
box-sizing: content-box;
|
||||||
|
height: 0;
|
||||||
|
overflow: visible
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
background-color: transparent;
|
||||||
|
-webkit-text-decoration-skip: objects
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
border-bottom: none;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration: underline dotted
|
||||||
|
}
|
||||||
|
|
||||||
|
b, strong {
|
||||||
|
font-weight: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
b, strong {
|
||||||
|
font-weight: bolder
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, samp {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
dfn {
|
||||||
|
font-style: italic
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background-color: #ff0;
|
||||||
|
color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -.25em
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -.5em
|
||||||
|
}
|
||||||
|
|
||||||
|
audio, video {
|
||||||
|
display: inline-block
|
||||||
|
}
|
||||||
|
|
||||||
|
audio:not([controls]) {
|
||||||
|
display: none;
|
||||||
|
height: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-style: none
|
||||||
|
}
|
||||||
|
|
||||||
|
svg:not(:root) {
|
||||||
|
overflow: hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, optgroup, select, textarea {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input {
|
||||||
|
overflow: visible
|
||||||
|
}
|
||||||
|
|
||||||
|
button, select {
|
||||||
|
text-transform: none
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=reset], [type=submit], button, html [type=button] {
|
||||||
|
-webkit-appearance: button
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {
|
||||||
|
border-style: none;
|
||||||
|
padding: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {
|
||||||
|
outline: 1px dotted ButtonText
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
padding: .35em .75em .625em
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: inherit;
|
||||||
|
display: table;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
white-space: normal
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: baseline
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
overflow: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=checkbox], [type=radio] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {
|
||||||
|
height: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search]::-webkit-search-cancel-button, [type=search]::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
font: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
details, menu {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: inline-block
|
||||||
|
}
|
||||||
|
|
||||||
|
template {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
/* configuration cache styles */
|
||||||
|
|
||||||
|
.report-wrapper {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradle-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 24px;
|
||||||
|
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAYCAYAAACbU/80AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAGAAAAAA915G0AAAD5klEQVRIDbVWC0xTZxT+emmhVUEeA1/ROh/tFAFFGK7oJisIKsNVoOwBbJPowEWHzikRxeiMRpwwjDWRBHQLIzOmiRhe22BT40TitiyaMBQFfMEeLMIEaSmk+/+rvd7be4no6Elu7n++c/5zzv845/wyOyG4iGyDgzCdNOPLM9W41n4bnmNUiHo5DNsz0hGsmcV6lbkyAOOWXJjrz4qWp1C4o3z/LqzWL4VcJB1FIHmZHn/f78a6pDcxbeIEfNvQiPwTZbDZBpC24zOEaGfDpTsgtZby6u+QlrubFWUY3nh6AH39/ahr/Bn1jZfxW3ML2js60dtvgbtcQVblj8CZM7A0PBSrol6Ft+c4KZ8iTB1nwN0//8IEP9/hA2i924Gir0/iq8oa/NvbJzLiDKiUSqTE6pGVbEBY4BxnsYAPSnwXTa3tLCZ5BF3dPdAkGNHzoFcwcaRMnC4CeZkZiAgKFE252nITC1Pew9Dj5GNEGgS4Rbb5eZ1Te7UXG6FLX4cV6zeh5kIDaDpSunL9Boyf5nLOpwT4Sx+BxWrFK8QAnTAapPRQwofcj86uLoG59cbVEOzA0NAQNh38Atn5RSjY8rFAmc/I3dyQvOx1PsSNVy7Roa3ajHDePbBYLSLn1MaGd5KFAXy07xAOl59C6elK+I73hIHcbGd6wXs8qkyH8FZcjLOI5X/9/TrOnLsAldJDUu4As1NToFFPe3IEpm/M2HigwCFnU6t4Zw6Ck1JhGRhgcXq5juXloKyqFnlHirmz5CaNcEAv59kSE9wVikcB3O78A/MSU0Fznk/H9+yAetJEnPr+B8RFLsLcGS8ia28+qQuX+WrPNNZOV+Nc6VH4+3iz89g0pEaLzRUiQ3LGDWsM8Qidq2WL0PGKKlgf74ZIeQTAfFJ6a44WIsDXh9OW/dPdY58aawC9KK6kpOgolO7JxViVSuBGXnvxksudZ5F0O5yzGYxMJnBOGaau4fnPU2RNAtCFBKFoa7akczaAptY2iWmjB33+yQa4kZwfjpi2ex3Dyf43vuAljWQ/4Btmei1WPj+q45hF4U+1J4fEizCEvNf0EWHoIW244sfzoN1RipaT2kDfdjfv3MNpojdISjmfIheE8Fnp8WR9vJ2Zr+O+bYUmO+kJ9KnIUtf9bnvY2x9wcqrrvnCJvfL8Tw4V9v9LU7PdKzJaoNdy645AR4ph1JMncZHRKrVvYyYY5kmP8iO1v2T3dk6HDtYmrgJtOnwKnaPFrg8z+BBX7QSgEyOPJfX9Qd9DFs40GgTOHbrBs2ch4bXFuEG2mmFkeD9hpUMk+NMXEe0TNtsg/Ly94DVurEAuxfwHC1WiVbe0U7MAAAAASUVORK5CYII=");
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24px 24px 0 24px;
|
||||||
|
background-color: white;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.learn-more {
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 0 24px 0;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 240px 0 48px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ol:first-of-type {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-btn.collapsed {
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path d="M166.9 264.5l-117.8 116c-4.7 4.7-12.3 4.7-17 0l-7.1-7.1c-4.7-4.7-4.7-12.3 0-17L127.3 256 25.1 155.6c-4.7-4.7-4.7-12.3 0-17l7.1-7.1c4.7-4.7 12.3-4.7 17 0l117.8 116c4.6 4.7 4.6 12.3-.1 17z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-btn.expanded {
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path d="M119.5 326.9L3.5 209.1c-4.7-4.7-4.7-12.3 0-17l7.1-7.1c4.7-4.7 12.3-4.7 17 0L128 287.3l100.4-102.2c4.7-4.7 12.3-4.7 17 0l7.1 7.1c4.7 4.7 4.7 12.3 0 17L136.5 327c-4.7 4.6-12.3 4.6-17-.1z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||||
|
}
|
||||||
|
|
||||||
|
ul .tree-btn {
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaf-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path d="M32 256 H224" stroke="%23999999" stroke-width="48" stroke-linecap="round"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invisible-text {
|
||||||
|
user-select: all; /* Allow the text to be selectable */
|
||||||
|
color: transparent; /* Hide the text */
|
||||||
|
text-indent: -9999px; /* Move the text out of view */
|
||||||
|
position: relative;
|
||||||
|
white-space: pre; /* Preserve meaningful whitespace in the invisible text for copying */
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-for-copy {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enum-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="200" /></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-inline-end: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M193.94 256L296.5 153.44l21.15-21.15c3.12-3.12 3.12-8.19 0-11.31l-22.63-22.63c-3.12-3.12-8.19-3.12-11.31 0L160 222.06 36.29 98.34c-3.12-3.12-8.19-3.12-11.31 0L2.34 120.97c-3.12 3.12-3.12 8.19 0 11.31L126.06 256 2.34 379.71c-3.12 3.12-3.12 8.19 0 11.31l22.63 22.63c3.12 3.12 8.19 3.12 11.31 0L160 289.94 262.56 392.5l21.15 21.15c3.12 3.12 8.19 3.12 11.31 0l22.63-22.63c3.12-3.12 3.12-8.19 0-11.31L193.94 256z" fill="%23FC461E" stroke="%23FC461E"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-inline-end: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg width="800px" height="800px" viewBox="-4.93 0 122.88 122.88" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="enable-background:new 0 0 113.01 122.88" xml:space="preserve"><g><path d="M44.13,102.06c-1.14,0.03-2.14-0.81-2.3-1.96c-0.17-1.2,0.64-2.31,1.82-2.54c-1.3-7.37-4.85-11.43-8.6-15.72 c-2.92-3.34-5.95-6.81-8.34-11.92c-2.35-5.03-3.64-10.23-3.6-15.63c0.05-5.4,1.42-10.96,4.4-16.71c0.02-0.04,0.04-0.07,0.06-0.11 l0,0c3.91-6.62,9.38-11.04,15.47-13.52c5.11-2.09,10.66-2.8,16.1-2.3c5.42,0.5,10.73,2.2,15.37,4.94 c5.9,3.49,10.75,8.67,13.42,15.21c1.44,3.54,2.42,7.49,2.54,11.82c0.12,4.31-0.62,8.96-2.61,13.88 c-2.66,6.59-6.18,10.68-9.47,14.51c-3.03,3.53-5.85,6.81-7.42,11.84c0.89,0.21,1.59,0.94,1.73,1.9c0.17,1.24-0.7,2.39-1.94,2.56 l-0.77,0.11c-0.14,1.09-0.23,2.26-0.27,3.51l0.25-0.04c1.24-0.17,2.39,0.7,2.56,1.94c0.17,1.24-0.7,2.39-1.94,2.56l-0.78,0.11 c0.01,0.15,0.02,0.3,0.03,0.45l0,0c0.07,0.88,0.08,1.73,0.03,2.54l0.13-0.02c1.25-0.15,2.38,0.74,2.54,1.98 c0.15,1.25-0.74,2.38-1.98,2.54l-1.68,0.21c-1.2,3.11-3.34,5.48-5.87,6.94c-1.74,1.01-3.67,1.59-5.61,1.71 c-1.97,0.12-3.96-0.25-5.78-1.13c-2.08-1.02-3.94-2.71-5.29-5.14c-0.65-0.33-1.13-0.97-1.23-1.75c-0.04-0.31-0.01-0.61,0.07-0.89 c-0.39-1.16-0.68-2.43-0.87-3.83l-0.07,0.01c-1.24,0.17-2.39-0.7-2.56-1.94c-0.17-1.24,0.7-2.39,1.94-2.56l0.54-0.08 C44.19,104.32,44.18,103.16,44.13,102.06L44.13,102.06z M2.18,58.86C1.01,58.89,0.04,57.98,0,56.81c-0.04-1.17,0.88-2.14,2.05-2.18 l8.7-0.3c1.17-0.04,2.14,0.88,2.18,2.05c0.04,1.17-0.88,2.14-2.05,2.18L2.18,58.86L2.18,58.86z M110.68,50.25 c1.16-0.12,2.2,0.73,2.32,1.89c0.12,1.16-0.73,2.2-1.89,2.32l-8.66,0.91c-1.16,0.12-2.2-0.73-2.32-1.89 c-0.12-1.16,0.73-2.2,1.89-2.32L110.68,50.25L110.68,50.25z M94.91,14.78c0.65-0.97,1.96-1.23,2.93-0.58 c0.97,0.65,1.23,1.96,0.58,2.93l-4.84,7.24c-0.65,0.97-1.96,1.23-2.93,0.58c-0.97-0.65-1.23-1.96-0.58-2.93L94.91,14.78 L94.91,14.78z M57.63,2.06c0.03-1.17,1-2.09,2.16-2.06c1.17,0.03,2.09,1,2.06,2.16l-0.22,8.7c-0.03,1.17-1,2.09-2.16,2.06 c-1.17-0.03-2.09-1-2.06-2.16L57.63,2.06L57.63,2.06z M13.88,15.53c-0.86-0.8-0.9-2.14-0.11-2.99c0.8-0.86,2.14-0.9,2.99-0.11 l6.37,5.94c0.86,0.8,0.9,2.14,0.11,2.99c-0.8,0.86-2.14,0.9-2.99,0.11L13.88,15.53L13.88,15.53z M47.88,96.95l18.49-2.63 c1.59-6.7,5.05-10.73,8.8-15.08c3.08-3.58,6.36-7.4,8.76-13.34c1.76-4.35,2.41-8.43,2.31-12.19c-0.1-3.75-0.96-7.21-2.24-10.34 c-2.3-5.63-6.51-10.11-11.65-13.15c-4.11-2.43-8.8-3.94-13.59-4.37c-4.77-0.44-9.64,0.19-14.13,2.02 c-5.26,2.15-9.99,5.97-13.39,11.72c-2.64,5.12-3.86,10.02-3.9,14.73c-0.04,4.74,1.11,9.33,3.2,13.8c2.13,4.56,4.97,7.8,7.69,10.92 C42.47,83.9,46.48,88.49,47.88,96.95L47.88,96.95z M65.62,99.02l-17.27,2.45c0.05,1.1,0.07,2.25,0.05,3.47l17.05-2.42 C65.47,101.29,65.52,100.12,65.62,99.02L65.62,99.02z M48.49,109.52c0.12,0.92,0.3,1.76,0.53,2.54l16.55-2.04 c0.11-0.86,0.13-1.77,0.05-2.74l0,0l0-0.02l-0.01-0.17L48.49,109.52L48.49,109.52z M51.37,116.36c0.64,0.67,1.35,1.19,2.1,1.55 c1.15,0.56,2.42,0.79,3.67,0.72c1.29-0.08,2.57-0.47,3.74-1.15c1.1-0.64,2.09-1.53,2.88-2.65L51.37,116.36L51.37,116.36z"/></g></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-inline-end: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M270.2 160h35.5c3.4 0 6.1 2.8 6 6.2l-7.5 196c-.1 3.2-2.8 5.8-6 5.8h-20.5c-3.2 0-5.9-2.5-6-5.8l-7.5-196c-.1-3.4 2.6-6.2 6-6.2zM288 388c-15.5 0-28 12.5-28 28s12.5 28 28 28 28-12.5 28-28-12.5-28-28-28zm281.5 52L329.6 24c-18.4-32-64.7-32-83.2 0L6.5 440c-18.4 31.9 4.6 72 41.6 72H528c36.8 0 60-40 41.5-72zM528 480H48c-12.3 0-20-13.3-13.9-24l240-416c6.1-10.6 21.6-10.7 27.7 0l240 416c6.2 10.6-1.5 24-13.8 24z" fill="%23DEAD22" stroke="%23DEAD22"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.3ex;
|
||||||
|
margin-inline-end: 1.1ex;
|
||||||
|
margin-top: -0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 340c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zm7.67-24h-16c-6.627 0-12-5.373-12-12v-.381c0-70.343 77.44-63.619 77.44-107.408 0-20.016-17.761-40.211-57.44-40.211-29.144 0-44.265 9.649-59.211 28.692-3.908 4.98-11.054 5.995-16.248 2.376l-13.134-9.15c-5.625-3.919-6.86-11.771-2.645-17.177C185.658 133.514 210.842 116 255.67 116c52.32 0 97.44 29.751 97.44 80.211 0 67.414-77.44 63.849-77.44 107.408V304c0 6.627-5.373 12-12 12zM256 40c118.621 0 216 96.075 216 216 0 119.291-96.61 216-216 216-119.244 0-216-96.562-216-216 0-119.203 96.602-216 216-216m0-32C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-inline-end: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-button::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-button:hover {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M433.941 193.941l-51.882-51.882A48 48 0 0 0 348.118 128H320V80c0-26.51-21.49-48-48-48h-66.752C198.643 13.377 180.858 0 160 0s-38.643 13.377-45.248 32H48C21.49 32 0 53.49 0 80v288c0 26.51 21.49 48 48 48h80v48c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48V227.882a48 48 0 0 0-14.059-33.941zm-22.627 22.627a15.888 15.888 0 0 1 4.195 7.432H352v-63.509a15.88 15.88 0 0 1 7.431 4.195l51.883 51.882zM160 30c9.941 0 18 8.059 18 18s-8.059 18-18 18-18-8.059-18-18 8.059-18 18-18zM48 384c-8.822 0-16-7.178-16-16V80c0-8.822 7.178-16 16-16h66.752c6.605 18.623 24.389 32 45.248 32s38.643-13.377 45.248-32H272c8.822 0 16 7.178 16 16v48H176c-26.51 0-48 21.49-48 48v208H48zm352 96H176c-8.822 0-16-7.178-16-16V176c0-8.822 7.178-16 16-16h144v72c0 13.2 10.8 24 24 24h72v208c0 8.822-7.178 16-16 16z" fill="%23999999" stroke="%23999999"/></svg>');
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-start: 0.5ex;
|
||||||
|
margin-top: -0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups{
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #EDEEEF;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uncategorized {
|
||||||
|
display: flex;
|
||||||
|
border-top: 4px solid #EDEEEF;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector {
|
||||||
|
padding: 0 52px 24px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #999999;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector__count {
|
||||||
|
margin: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #999;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1px 8px 2px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector--active {
|
||||||
|
color: #02303A;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector--active .group-selector__count {
|
||||||
|
background-color: #686868;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding-left: 0.5em;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacktrace {
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lato (bold, regular) */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap;
|
||||||
|
font-family: Lato;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("https://assets.gradle.com/lato/fonts/lato-semibold/lato-semibold.woff2") format("woff2"),
|
||||||
|
url("https://assets.gradle.com/lato/fonts/lato-semibold/lato-semibold.woff") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-display: swap;
|
||||||
|
font-family: Lato;
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
src: url("https://assets.gradle.com/lato/fonts/lato-bold/lato-bold.woff2") format("woff2"),
|
||||||
|
url("https://assets.gradle.com/lato/fonts/lato-bold/lato-bold.woff") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: "Lato", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: #02303A;
|
||||||
|
background-color: #ffffff;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* typography */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: #02303A;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4, h5, h6 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 code {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol, dl {
|
||||||
|
list-style-position: outside;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0 20px;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #1DA2BD;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease, visibility 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #35c1e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* code */
|
||||||
|
code, pre {
|
||||||
|
font-family: Inconsolata, Monaco, "Courier New", monospace;
|
||||||
|
font-style: normal;
|
||||||
|
font-variant-ligatures: normal;
|
||||||
|
font-variant-caps: normal;
|
||||||
|
font-variant-numeric: normal;
|
||||||
|
font-variant-east-asian: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
font-stretch: normal;
|
||||||
|
color: #686868;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:not(pre) > code {
|
||||||
|
letter-spacing: 0;
|
||||||
|
padding: 0.1em 0.5ex;
|
||||||
|
text-rendering: optimizeSpeed;
|
||||||
|
word-spacing: -0.15em;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-size: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a code {
|
||||||
|
color: #1BA8CB;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.code, pre.programlisting, pre.screen, pre.tt {
|
||||||
|
background-color: #f7f7f8;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li em, p em {
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code em, tt em {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
code + .copy-button {
|
||||||
|
margin-inline-start: 0.2ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.java-exception {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.java-exception ul {
|
||||||
|
margin: 0;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.java-exception code {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.java-exception-part-toggle {
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0.1em 0.2em;
|
||||||
|
background: azure;
|
||||||
|
color: #686868;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<!-- Inconsolata is used as a default monospace font in the report. -->
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inconsolata:400,700" />
|
||||||
|
|
||||||
|
<title>Gradle Configuration Cache</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="playground"></div>
|
||||||
|
|
||||||
|
<div class="report" id="report">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
function configurationCacheProblems() { return (
|
||||||
|
// begin-report-data
|
||||||
|
{"diagnostics":[{"locations":[{"path":"build file '/Users/mac/.pub-cache/git/flutter-packages-51fd4ba38d57fdc8fb23e744aa65bf2368878b7f/packages/android_id/android/build.gradle'","line":9},{"path":"build file '/Users/mac/.pub-cache/git/flutter-packages-51fd4ba38d57fdc8fb23e744aa65bf2368878b7f/packages/android_id/android/build.gradle'","line":9}],"problem":[{"text":"Space-assignment syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10.0."}],"contextualLabel":"Space-assignment syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/8.12/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"space-assignment-syntax-in-groovy-dsl","displayName":"Space-assignment syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]},{"locations":[{"path":"build file '/Users/mac/.pub-cache/git/flutter-packages-51fd4ba38d57fdc8fb23e744aa65bf2368878b7f/packages/android_id/android/build.gradle'","line":22},{"path":"build file '/Users/mac/.pub-cache/git/flutter-packages-51fd4ba38d57fdc8fb23e744aa65bf2368878b7f/packages/android_id/android/build.gradle'","line":22}],"problem":[{"text":"Space-assignment syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10.0."}],"contextualLabel":"Space-assignment syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/8.12/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"space-assignment-syntax-in-groovy-dsl","displayName":"Space-assignment syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]},{"locations":[{"path":"build file '/Users/mac/.pub-cache/git/flutter-packages-51fd4ba38d57fdc8fb23e744aa65bf2368878b7f/packages/android_oaid/android/build.gradle'","line":9},{"path":"build file '/Users/mac/.pub-cache/git/flutter-packages-51fd4ba38d57fdc8fb23e744aa65bf2368878b7f/packages/android_oaid/android/build.gradle'","line":9}],"problem":[{"text":"Space-assignment syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10.0."}],"contextualLabel":"Space-assignment syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/8.12/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"space-assignment-syntax-in-groovy-dsl","displayName":"Space-assignment syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]},{"locations":[{"path":"build file '/Users/mac/.pub-cache/git/flutter-packages-51fd4ba38d57fdc8fb23e744aa65bf2368878b7f/packages/android_oaid/android/build.gradle'","line":22},{"path":"build file '/Users/mac/.pub-cache/git/flutter-packages-51fd4ba38d57fdc8fb23e744aa65bf2368878b7f/packages/android_oaid/android/build.gradle'","line":22}],"problem":[{"text":"Space-assignment syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10.0."}],"contextualLabel":"Space-assignment syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/8.12/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"space-assignment-syntax-in-groovy-dsl","displayName":"Space-assignment syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]},{"locations":[{"path":"build file '/Users/mac/.pub-cache/hosted/pub.dev/device_info_plus-12.3.0/android/build.gradle'","line":1},{"path":"build file '/Users/mac/.pub-cache/hosted/pub.dev/device_info_plus-12.3.0/android/build.gradle'","line":1}],"problem":[{"text":"Space-assignment syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10.0."}],"contextualLabel":"Space-assignment syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/8.12/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"space-assignment-syntax-in-groovy-dsl","displayName":"Space-assignment syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('group = <value>') instead."}]]},{"locations":[{"path":"build file '/Users/mac/.pub-cache/hosted/pub.dev/device_info_plus-12.3.0/android/build.gradle'","line":2},{"path":"build file '/Users/mac/.pub-cache/hosted/pub.dev/device_info_plus-12.3.0/android/bLine truncated
|
||||||
|
// end-report-data
|
||||||
|
);}
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
!function(n,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports["configuration-cache-report"]=t():n["configuration-cache-report"]=t()}(this,(()=>(({70:function(){void 0===ArrayBuffer.isView&&(ArrayBuffer.isView=function(n){return null!=n&&null!=n.__proto__&&n.__proto__.__proto__===Int8Array.prototype.__proto__}),void 0===Math.imul&&(Math.imul=function(n,t){return(4294901760&n)*(65535&t)+(65535&n)*(0|t)|0}),this["configuration-cache-report"]=function(n){"use strict";var t,r,i,e,u,o,f,s,c,a,h,l,_,v,d,g,w,b,p,m,k,y,q,B,C,x,j,P,I,S,z,E,T,L,N,A,M,F,D,O,R,H,$,G,U,V,Q,Z,Y,W,K,X,J,nn,tn,rn,en,un,on,fn,sn,cn,an,hn,ln,_n,vn,dn,gn,wn,bn,pn,mn,kn,yn,qn,Bn,Cn,xn,jn,Pn,In,Sn,zn=Math.imul,En=ArrayBuffer.isView;function Tn(n,t){if(!(t>=0))throw fu(ie("Requested element count "+t+" is less than zero."));return function(n,t){if(!(t>=0))throw fu(ie("Requested element count "+t+" is less than zero."));if(0===t)return pt();if(t>=n.length)return function(n){switch(n.length){case 0:return pt();case 1:return gr(n[0]);default:return function(n){return Ar(function(n){return new qt(n,!1)}(n))}(n)}}(n);if(1===t)return gr(n[0]);var r=0,i=Nr(),e=0,u=n.length;n:for(;e<u;){var o=n[e];if(e=e+1|0,i.d(o),(r=r+1|0)===t)break n}return i}(n,Wn(n.length-t|0,0))}function Ln(n,t,r,i,e,u,o){return t=t===A?", ":t,r=r===A?"":r,i=i===A?"":i,e=e===A?-1:e,u=u===A?"...":u,o=o===A?null:o,function(n,t,r,i,e,u,o,f){r=r===A?", ":r,i=i===A?"":i,e=e===A?"":e,u=u===A?-1:u,o=o===A?"...":o,f=f===A?null:f,t.e(i);var s=0,c=0,a=n.length;n:for(;c<a;){var h=n[c];if(c=c+1|0,(s=s+1|0)>1&&t.e(r),!(u<0||s<=u))break n;Qt(t,h,f)}return u>=0&&s>u&&t.e(o),t.e(e),t}(n,bi(),t,r,i,e,u,o).toString()}function Nn(n){return n.length-1|0}function An(n,t){if(null==t){var r=0,i=n.length-1|0;if(r<=i)do{var e=r;if(r=r+1|0,null==n[e])return e}while(r<=i)}else{var u=0,o=n.length-1|0;if(u<=o)do{var f=u;if(u=u+1|0,oe(t,n[f]))return f}while(u<=o)}return-1}function Mn(n,t,r,i,e,u,o){return t=t===A?", ":t,r=r===A?"":r,i=i===A?"":i,e=e===A?-1:e,u=u===A?"...":u,o=o===A?null:o,Fn(n,bi(),t,r,i,e,u,o).toString()}function Fn(n,t,r,i,e,u,o,f){r=r===A?", ":r,i=i===A?"":i,e=e===A?"":e,u=u===A?-1:u,o=o===A?"...":o,f=f===A?null:f,t.e(i);var s=0,c=n.f();n:for(;c.g();){var a=c.h();if((s=s+1|0)>1&&t.e(r),!(u<0||s<=u))break n;Qt(t,a,f)}return u>=0&&s>u&&t.e(o),t.e(e),t}function Dn(n){if(n.i())throw mu("List is empty.");return n.j(0)}function On(n){return new Yn(n)}function Rn(n){if(Ue(n,Ti)){var t;switch(n.k()){case 0:t=pt();break;case 1:t=gr(Ue(n,Ei)?n.j(0):n.f().h());break;default:t=Hn(n)}return t}return mt(Vn(n))}function Hn(n){return Ar(n)}function $n(n){if(Ue(n,Ti)&&n.k()<=1)return Rn(n);var t=Vn(n);return function(n){var t=(n.k()/2|0)-1|0;if(t<0)return lr();var r=kt(n),i=0;if(i<=t)do{var e=i;i=i+1|0;var u=n.j(e);n.f4(e,n.j(r)),n.f4(r,u),r=r-1|0}while(e!==t)}(t),t}function Gn(n,t){if(!(t>=0))throw fu(ie("Requested element count "+t+" is less than zero."));return function(n,t){if(!(t>=0))throw fu(ie("Requested element count "+t+" is less than zero."));if(0===t)return pt();if(Ue(n,Ti)){if(t>=n.k())return Rn(n);if(1===t)return gr(function(n){if(Ue(n,Ei))return Dn(n);var t=n.f();if(!t.g())throw mu("Collection is empty.");return t.h()}(n))}var r=0,i=Nr(),e=n.f();n:for(;e.g();){var u=e.h();if(i.d(u),(r=r+1|0)===t)break n}return mt(i)}(n,Wn(n.k()-t|0,0))}function Un(n,t){if(!(t>=0))throw fu(ie("Requested element count "+t+" is less than zero."));if(0===t)return pt();var r=n.k();if(t>=r)return Rn(n);if(1===t)return gr(Qn(n));var i=Nr();if(Ue(n,li)){var e=r-t|0;if(e<r)do{var u=e;e=e+1|0,i.d(n.j(u))}while(e<r)}else for(var o=n.l(r-t|0);o.g();){var f=o.h();i.d(f)}return i}function Vn(n){return Ue(n,Ti)?Hn(n):Zn(n,Lr())}function Qn(n){if(n.i())throw mu("List is empty.");return n.j(kt(n))}function Zn(n,t){for(var r=n.f();r.g();){var i=r.h();t.d(i)}return t}function Yn(n){this.n_1=n}function Wn(n,t){return n<t?t:n}function Kn(n,t){return n>t?t:n}function Xn(n,t){return Ut().q(n,t,-1)}function Jn(n,t){return new Et(n,t)}function nt(n){var t=n.f();if(!t.g())return pt();var r=t.h();if(!t.g())return gr(r);var i=Lr();for(i.d(r);t.g();)i.d(t.h());return i}function tt(n){this.r_1=n}function rt(n,t){this.s_1=n,this.t_1=t}function it(){}function et(n){this.x_1=n,this.w_1=0}function ut(n,t){this.a1_1=n,et.call(this,n),ft().b1(t,this.a1_1.k()),this.w_1=t}function ot(){t=this}function ft(){return null==t&&new ot,t}function st(){ft(),it.call(this)}function ct(n){this.h1_1=n}function at(n,t){return t===n?"(this Map)":Oi(t)}function ht(n,t){var r;n:{for(var i=n.o().f();i.g();){var e=i.h();if(oe(e.j1(),t)){r=e;break n}}r=null}return r}function lt(){r=this}function _t(){return null==r&&new lt,r}function vt(n){this.q1_1=n,it.call(this)}function dt(){_t(),this.n1_1=null,this.o1_1=null}function gt(){i=this}function wt(){return null==i&&new gt,i}function bt(n){return n.length>0?nu(n):pt()}function pt(){return null==e&&nLine truncated
|
||||||
|
//# sourceMappingURL=configuration-cache-report.js.map
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="26.9924" viewBox="0 0 20 26.9924" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="装饰星星">
|
||||||
|
<path id="Vector" d="M7.7027 26.5261L8.41064 24.9055C9.02993 23.4731 10.1634 22.3247 11.5875 21.6867L13.5356 20.8223C14.1548 20.5473 14.1548 19.6467 13.5356 19.3709L11.6488 18.534C10.1882 17.879 9.03458 16.6885 8.42596 15.2079L7.70835 13.4808C7.65191 13.3389 7.55418 13.2173 7.42782 13.1315C7.30147 13.0458 7.15229 13 6.9996 13C6.84691 13 6.69773 13.0458 6.57137 13.1315C6.44501 13.2173 6.34729 13.3389 6.29085 13.4808L5.57404 15.2079C4.96542 16.6885 3.81184 17.879 2.35121 18.534L0.464436 19.3717C-0.154812 19.6459 -0.154812 20.5473 0.464436 20.8223L2.41168 21.6867C3.83568 22.3245 4.9691 23.4726 5.58855 24.9047L6.29649 26.5253C6.35471 26.6638 6.45248 26.7819 6.57756 26.865C6.70263 26.9481 6.84944 26.9924 6.9996 26.9924C7.14975 26.9924 7.29656 26.9481 7.42164 26.865C7.54671 26.7819 7.64448 26.6646 7.7027 26.5261Z" fill="white"/>
|
||||||
|
<path id="Vector_2" d="M16.4666 7.6867L16.7133 7.12119C17.1465 6.12115 17.9381 5.31928 18.9324 4.87314L19.6918 4.53443C19.7838 4.49211 19.8617 4.4243 19.9163 4.33903C19.971 4.25376 20 4.15462 20 4.05335C20 3.95208 19.971 3.85294 19.9163 3.76767C19.8617 3.6824 19.7838 3.61459 19.6918 3.57226L18.9754 3.25354C17.9551 2.79573 17.1494 1.9639 16.7243 0.929556L16.4706 0.319085C16.433 0.224921 16.3681 0.144177 16.2842 0.0872928C16.2003 0.0304083 16.1013 0 16 0C15.8986 0 15.7996 0.0304083 15.7157 0.0872928C15.6318 0.144177 15.5669 0.224921 15.5294 0.319085L15.2756 0.928557C14.8507 1.96328 14.045 2.7955 13.0245 3.25354L12.3072 3.57327C12.2154 3.61571 12.1378 3.68351 12.0833 3.76867C12.0289 3.85383 12 3.95278 12 4.05385C12 4.15491 12.0289 4.25387 12.0833 4.33903C12.1378 4.42419 12.2154 4.49199 12.3072 4.53443L13.0665 4.87214C14.0614 5.31832 14.8533 6.1206 15.2866 7.12119L15.5334 7.6867C15.7132 8.10034 16.2867 8.10034 16.4666 7.6867Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="72" height="64" viewBox="0 0 72 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Frame">
|
||||||
|
<path id="Vector" d="M52 35.0088V49.0088C52 49.5393 51.7893 50.048 51.4142 50.4231C51.0391 50.7981 50.5304 51.0088 50 51.0088H22C21.4696 51.0088 20.9609 50.7981 20.5858 50.4231C20.2107 50.048 20 49.5393 20 49.0088V35.0088H52ZM41 13.0088C42.181 13.0086 43.3429 13.3072 44.3775 13.8768C45.4121 14.4464 46.2858 15.2685 46.9171 16.2666C47.5485 17.2647 47.9171 18.4063 47.9886 19.5851C48.0601 20.764 47.8322 21.9418 47.326 23.0088H54C54.5304 23.0088 55.0391 23.2196 55.4142 23.5946C55.7893 23.9697 56 24.4784 56 25.0088V31.0088C56 31.5393 55.7893 32.048 55.4142 32.4231C55.0391 32.7981 54.5304 33.0088 54 33.0088H18C17.4696 33.0088 16.9609 32.7981 16.5858 32.4231C16.2107 32.048 16 31.5393 16 31.0088V25.0088C16 24.4784 16.2107 23.9697 16.5858 23.5946C16.9609 23.2196 17.4696 23.0088 18 23.0088H24.674C23.9615 21.5122 23.8033 19.8113 24.2274 18.2088C24.6515 16.6064 25.6306 15.2065 26.9902 14.2583C28.3498 13.3102 30.0018 12.8752 31.6521 13.031C33.3023 13.1867 34.8438 13.923 36.002 15.1088C36.6522 14.4423 37.4296 13.9129 38.2881 13.5522C39.1466 13.1915 40.0688 13.0067 41 13.0088ZM31 17.0088C30.2294 17.0087 29.4883 17.3051 28.9303 17.8366C28.3723 18.3681 28.0403 19.094 28.003 19.8637C27.9657 20.6334 28.226 21.3879 28.73 21.9709C29.234 22.5538 29.943 22.9205 30.71 22.9948L31 23.0088H34V20.0088C34 19.2631 33.7222 18.5441 33.2208 17.992C32.7194 17.44 32.0303 17.0944 31.288 17.0228L31 17.0088ZM41 17.0088L40.71 17.0228C40.0178 17.0901 39.3704 17.3957 38.8786 17.8875C38.3869 18.3793 38.0812 19.0266 38.014 19.7188L38 20.0088V23.0088H41L41.288 22.9948C42.0301 22.9229 42.7187 22.5772 43.2198 22.0252C43.7209 21.4732 43.9985 20.7544 43.9985 20.0088C43.9985 19.2633 43.7209 18.5445 43.2198 17.9925C42.7187 17.4404 42.0301 17.0948 41.288 17.0228L41 17.0088Z" fill="black"/>
|
||||||
|
<path id="Vector_2" d="M7.36867 18.1835L7.954 16.8435C8.46603 15.6592 9.40317 14.7097 10.5807 14.1822L12.1913 13.4675C12.7033 13.2402 12.7033 12.4955 12.1913 12.2675L10.6313 11.5755C9.42367 11.034 8.46988 10.0497 7.96667 8.82555L7.37333 7.39755C7.32667 7.28024 7.24587 7.17964 7.1414 7.10877C7.03692 7.03789 6.91358 7 6.78733 7C6.66109 7 6.53775 7.03789 6.43327 7.10877C6.3288 7.17964 6.248 7.28024 6.20133 7.39755L5.60867 8.82555C5.10546 10.0497 4.15166 11.034 2.944 11.5755L1.384 12.2682C0.872 12.4949 0.872 13.2402 1.384 13.4675L2.994 14.1822C4.17138 14.7096 5.1085 15.6588 5.62067 16.8429L6.206 18.1829C6.25414 18.2973 6.33497 18.395 6.43839 18.4637C6.5418 18.5324 6.66319 18.569 6.78733 18.569C6.91148 18.569 7.03287 18.5324 7.13628 18.4637C7.23969 18.395 7.32053 18.298 7.36867 18.1835Z" fill="black"/>
|
||||||
|
<path id="Vector_3" d="M65.9606 51.2578L66.2899 50.5031C66.868 49.1686 67.9243 48.0985 69.2512 47.5031L70.2646 47.0512C70.3873 46.9947 70.4914 46.9042 70.5643 46.7904C70.6372 46.6766 70.6759 46.5443 70.6759 46.4092C70.6759 46.274 70.6372 46.1417 70.5643 46.0279C70.4914 45.9141 70.3873 45.8236 70.2646 45.7671L69.3086 45.3418C67.947 44.7309 66.8718 43.6208 66.3046 42.2405L65.9659 41.4258C65.9158 41.3002 65.8292 41.1924 65.7173 41.1165C65.6053 41.0406 65.4732 41 65.3379 41C65.2026 41 65.0705 41.0406 64.9585 41.1165C64.8466 41.1924 64.76 41.3002 64.7099 41.4258L64.3712 42.2391C63.8043 43.62 62.729 44.7306 61.3672 45.3418L60.4099 45.7685C60.2875 45.8251 60.1839 45.9156 60.1112 46.0292C60.0386 46.1429 60 46.2749 60 46.4098C60 46.5447 60.0386 46.6767 60.1112 46.7904C60.1839 46.904 60.2875 46.9945 60.4099 47.0512L61.4232 47.5018C62.7509 48.0972 63.8077 49.1679 64.3859 50.5031L64.7152 51.2578C64.9552 51.8098 65.7206 51.8098 65.9606 51.2578Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path id="Vector 1" d="M8.74286 16L0 0H18L8.74286 16Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
# platform :ios, '13.0'
|
platform :ios, '15.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
@@ -39,5 +39,14 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||||
|
# 对齐研听:permission_handler 的通知策略默认不编译,必须显式开启;
|
||||||
|
# 否则 Permission.notification.request() 是空实现,iOS 不会有系统通知项。
|
||||||
|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
|
||||||
|
'$(inherited)',
|
||||||
|
'PERMISSION_NOTIFICATIONS=1',
|
||||||
|
]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- app_tracking_transparency (0.0.1):
|
||||||
|
- Flutter
|
||||||
- device_info_plus (0.0.1):
|
- device_info_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- flutter_app_update (0.0.1):
|
||||||
|
- Flutter
|
||||||
- flutter_native_splash (2.4.3):
|
- flutter_native_splash (2.4.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_paid (0.0.1):
|
- flutter_paid (0.0.1):
|
||||||
@@ -16,36 +20,85 @@ PODS:
|
|||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- permission_handler_apple (9.4.8):
|
||||||
|
- Flutter
|
||||||
- platform_metadata (0.0.1):
|
- platform_metadata (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- sqflite_darwin (0.0.4):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- UMCCommonLog (2.0.2)
|
||||||
|
- UMCommon (7.6.4):
|
||||||
|
- UMDevice
|
||||||
|
- UMDevice (3.6.0)
|
||||||
|
- umeng_common_sdk (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- UMCommon
|
||||||
|
- UMDevice
|
||||||
|
- umeng_push_sdk (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- UMCCommonLog
|
||||||
|
- UMCommon
|
||||||
|
- UMDevice
|
||||||
|
- UMPush
|
||||||
|
- umeng_verify_sdk (2.1.0):
|
||||||
|
- Flutter
|
||||||
|
- UMVerify (>= 3.3.0)
|
||||||
|
- UMPush (4.1.4):
|
||||||
|
- UMCommon
|
||||||
|
- UMVerify (4.0.0):
|
||||||
|
- UMCommon
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- video_player_avfoundation (0.0.1):
|
- video_player_avfoundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- app_tracking_transparency (from `.symlinks/plugins/app_tracking_transparency/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
- flutter_paid (from `.symlinks/plugins/flutter_paid/ios`)
|
- flutter_paid (from `.symlinks/plugins/flutter_paid/ios`)
|
||||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- platform_metadata (from `.symlinks/plugins/platform_metadata/ios`)
|
- platform_metadata (from `.symlinks/plugins/platform_metadata/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
|
- umeng_common_sdk (from `.symlinks/plugins/umeng_common_sdk/ios`)
|
||||||
|
- umeng_push_sdk (from `.symlinks/plugins/umeng_push_sdk/ios`)
|
||||||
|
- umeng_verify_sdk (from `.symlinks/plugins/umeng_verify_sdk/ios`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||||
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- UMCCommonLog
|
||||||
|
- UMCommon
|
||||||
|
- UMDevice
|
||||||
|
- UMPush
|
||||||
|
- UMVerify
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
app_tracking_transparency:
|
||||||
|
:path: ".symlinks/plugins/app_tracking_transparency/ios"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
flutter_app_update:
|
||||||
|
:path: ".symlinks/plugins/flutter_app_update/ios"
|
||||||
flutter_native_splash:
|
flutter_native_splash:
|
||||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
flutter_paid:
|
flutter_paid:
|
||||||
@@ -58,29 +111,54 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
|
permission_handler_apple:
|
||||||
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
platform_metadata:
|
platform_metadata:
|
||||||
:path: ".symlinks/plugins/platform_metadata/ios"
|
:path: ".symlinks/plugins/platform_metadata/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
sqflite_darwin:
|
||||||
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
|
umeng_common_sdk:
|
||||||
|
:path: ".symlinks/plugins/umeng_common_sdk/ios"
|
||||||
|
umeng_push_sdk:
|
||||||
|
:path: ".symlinks/plugins/umeng_push_sdk/ios"
|
||||||
|
umeng_verify_sdk:
|
||||||
|
:path: ".symlinks/plugins/umeng_verify_sdk/ios"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
video_player_avfoundation:
|
video_player_avfoundation:
|
||||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
flutter_paid: 6055d15f439071fefb849e3cebc279915ed70db5
|
flutter_paid: 6055d15f439071fefb849e3cebc279915ed70db5
|
||||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
|
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
||||||
platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023
|
platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
|
UMCCommonLog: bea707e50c85cef4b0eb47cc5c7226bb843245ca
|
||||||
|
UMCommon: 001691e59cb7961ec21d3312687499c8516dddc6
|
||||||
|
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
|
||||||
|
umeng_common_sdk: 095b63e6f83b71548725a7c03a6c17b23be6674b
|
||||||
|
umeng_push_sdk: 557ebb6834df105bd828b86eeb8ae0b80959ee79
|
||||||
|
umeng_verify_sdk: 64a823bd4528bf46c40759c613d6ab34935b8293
|
||||||
|
UMPush: e9e81f259799b2b238ee21cc152e4cea31909604
|
||||||
|
UMVerify: d961ffe2e4b9f00ad578c373e420ad17f72a1f7c
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||||
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: c5e833ba06eec2784e07140760ce1599fc8e3bfc
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
@@ -198,6 +198,7 @@
|
|||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */,
|
38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */,
|
||||||
|
90B1F719ECC32B8C9ADF27D1 /* [CP] Copy Pods Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -350,6 +351,27 @@
|
|||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
|
90B1F719ECC32B8C9ADF27D1 /* [CP] Copy Pods Resources */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "[CP] Copy Pods Resources";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -473,17 +495,18 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = F38D68G8T6;
|
DEVELOPMENT_TEAM = 3QMFZJWPLS;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -659,17 +682,18 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = F38D68G8T6;
|
DEVELOPMENT_TEAM = 3QMFZJWPLS;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -685,17 +709,18 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = F38D68G8T6;
|
DEVELOPMENT_TEAM = 3QMFZJWPLS;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"images":[{"filename":"jinzhi_onetap_back.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
||||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "jinzhi_onetap_back_day.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "jinzhi_onetap_back_day@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "jinzhi_onetap_back_day@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 382 B |
|
After Width: | Height: | Size: 637 B |
|
After Width: | Height: | Size: 463 B |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "jinzhi_onetap_back_night.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "jinzhi_onetap_back_night@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "jinzhi_onetap_back_night@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 322 B |
|
After Width: | Height: | Size: 598 B |
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "jinzhi_onetap_button.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "jinzhi_onetap_button@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "jinzhi_onetap_button@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "jinzhi_onetap_button_pressed.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "jinzhi_onetap_button_pressed@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "jinzhi_onetap_button_pressed@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"images":[{"filename":"jinzhi_onetap_checked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
||||||
|
After Width: | Height: | Size: 479 B |
@@ -0,0 +1 @@
|
|||||||
|
{"images":[{"filename":"jinzhi_onetap_unchecked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
||||||
|
After Width: | Height: | Size: 369 B |
@@ -22,6 +22,15 @@
|
|||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>jinzhi</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
@@ -33,12 +42,24 @@
|
|||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>用于在商城客服中拍摄图片或视频并发送给客服。</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>用于在商城客服中录制语音或视频并发送给客服。</string>
|
||||||
|
<key>NSUserTrackingUsageDescription</key>
|
||||||
|
<string>用于衡量广告投放效果并提供更相关的内容。</string>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
|
<key>UIStatusBarHidden</key>
|
||||||
|
<true/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
@@ -52,8 +73,6 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIStatusBarHidden</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>$(APS_ENVIRONMENT)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,19 +1,132 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:app_links/app_links.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../common/router/app_router.dart';
|
import '../common/router/app_router.dart';
|
||||||
import '../common/lifecycle/refresh_coordinator.dart';
|
import '../common/lifecycle/refresh_coordinator.dart';
|
||||||
|
import '../common/push/device_push_registrar.dart';
|
||||||
|
import '../common/push/umeng_push_service.dart';
|
||||||
import '../common/theme/jinzhi_theme.dart';
|
import '../common/theme/jinzhi_theme.dart';
|
||||||
import '../common/theme/spacing.dart';
|
import '../common/theme/spacing.dart';
|
||||||
import '../common/theme/theme_controller.dart';
|
import '../common/theme/theme_controller.dart';
|
||||||
|
import '../features/ads/application/ad_config_foreground_refresher.dart';
|
||||||
|
import '../features/settings/application/update_foreground_service.dart';
|
||||||
|
import '../features/web/application/mall_web_host_controller.dart';
|
||||||
|
import '../features/auth/application/auth_controller.dart';
|
||||||
|
import '../features/auth/application/auth_session_coordinator.dart';
|
||||||
|
import '../features/auth/data/auth_models.dart';
|
||||||
|
import '../features/assets/application/asset_portfolio_controller.dart';
|
||||||
|
import '../common/router/deep_link_service.dart';
|
||||||
|
import '../common/services/device_header_service.dart';
|
||||||
|
|
||||||
class JinzhiApp extends ConsumerWidget {
|
class JinzhiApp extends ConsumerStatefulWidget {
|
||||||
const JinzhiApp({super.key});
|
const JinzhiApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<JinzhiApp> createState() => _JinzhiAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _JinzhiAppState extends ConsumerState<JinzhiApp>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
late final DevicePushRegistrar _pushRegistrar;
|
||||||
|
ProviderSubscription<AsyncValue<AuthState>>? _authSubscription;
|
||||||
|
StreamSubscription<Uri>? _appLinkSubscription;
|
||||||
|
late final SessionEndListener _sessionEndListener;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_pushRegistrar = DevicePushRegistrar(ref.read)..start();
|
||||||
|
_sessionEndListener = (reason) {
|
||||||
|
if (reason == SessionEndReason.accountDeleted) {
|
||||||
|
ref.read(assetPortfolioControllerProvider.notifier).clearHoldings();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ref.read(authSessionCoordinatorProvider).addListener(_sessionEndListener);
|
||||||
|
_authSubscription = ref.listenManual<AsyncValue<AuthState>>(authProvider, (
|
||||||
|
previous,
|
||||||
|
next,
|
||||||
|
) {
|
||||||
|
final oldUser = previous?.valueOrNull is LoggedInAuthState
|
||||||
|
? (previous!.valueOrNull! as LoggedInAuthState).user.id
|
||||||
|
: null;
|
||||||
|
final newUser = next.valueOrNull is LoggedInAuthState
|
||||||
|
? (next.valueOrNull! as LoggedInAuthState).user.id
|
||||||
|
: null;
|
||||||
|
if (oldUser != newUser) _pushRegistrar.forceRegisterCurrentToken();
|
||||||
|
DeepLinkService.notifyAuthChanged();
|
||||||
|
});
|
||||||
|
UmengPushService.configureNotificationOpen(DeepLinkService.handle);
|
||||||
|
unawaited(_startAppLinks());
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
DeepLinkService.attachRouter((location) {
|
||||||
|
final context = rootNavigatorKey.currentContext;
|
||||||
|
if (context == null) return false;
|
||||||
|
context.go(location);
|
||||||
|
return true;
|
||||||
|
}, authGuard: (_) => !ref.read(authProvider).isLoading);
|
||||||
|
// 首次 iOS 启动在欢迎页点击进入后才注册 APNs,确保系统弹窗不被启动页
|
||||||
|
// 或其他 Overlay 吞掉;后续启动和 Android 保持原有自动初始化。
|
||||||
|
if (!DeviceHeaderService.to.iosStartupRequired) {
|
||||||
|
unawaited(UmengPushService.startIfAllowed());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startAppLinks() async {
|
||||||
|
try {
|
||||||
|
final appLinks = AppLinks();
|
||||||
|
final initial = await appLinks.getInitialLink();
|
||||||
|
if (initial != null) DeepLinkService.handleUri(initial);
|
||||||
|
_appLinkSubscription = appLinks.uriLinkStream.listen(
|
||||||
|
DeepLinkService.handleUri,
|
||||||
|
onError: (_) {},
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Desktop tests and unsupported platforms may not register the plugin.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
unawaited(_pushRegistrar.stop());
|
||||||
|
_authSubscription?.close();
|
||||||
|
ref
|
||||||
|
.read(authSessionCoordinatorProvider)
|
||||||
|
.removeListener(_sessionEndListener);
|
||||||
|
unawaited(_appLinkSubscription?.cancel());
|
||||||
|
DeepLinkService.detachRouter();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
unawaited(_pushRegistrar.refresh());
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(adConfigForegroundRefresherProvider)
|
||||||
|
.refresh(
|
||||||
|
refreshMallTabConfig:
|
||||||
|
!MallWebHostController.instance.state.value.visible,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final context = rootNavigatorKey.currentContext;
|
||||||
|
if (context != null) {
|
||||||
|
unawaited(UpdateForegroundService.check(context, ref));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
// Keep the app-wide foreground refresh coordinator alive. Features opt in
|
// Keep the app-wide foreground refresh coordinator alive. Features opt in
|
||||||
// by registering only while their screen is visible.
|
// by registering only while their screen is visible.
|
||||||
ref.watch(refreshCoordinatorProvider);
|
ref.watch(refreshCoordinatorProvider);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import '../common/services/device_header_service.dart';
|
import '../common/services/device_header_service.dart';
|
||||||
import '../common/storage/secure_identity_storage.dart';
|
import '../common/config/shorebird_service.dart';
|
||||||
|
import '../common/config/umeng_config.dart';
|
||||||
import '../common/storage/storage_service.dart';
|
import '../common/storage/storage_service.dart';
|
||||||
import '../common/widgets/easy_refresh_localization.dart';
|
import '../common/widgets/easy_refresh_localization.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
@@ -12,10 +14,8 @@ import 'app.dart';
|
|||||||
Future<Widget> bootstrap() async {
|
Future<Widget> bootstrap() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
configureEasyRefreshChineseLocalization();
|
configureEasyRefreshChineseLocalization();
|
||||||
|
await UmengConfig.load();
|
||||||
await StorageService.instance;
|
await StorageService.instance;
|
||||||
await SecureIdentityStorage(
|
|
||||||
StorageService.to.preferences,
|
|
||||||
).restoreAccessToken();
|
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
// Before consent this only initializes app version and channel. The
|
// Before consent this only initializes app version and channel. The
|
||||||
@@ -25,9 +25,10 @@ Future<Widget> bootstrap() async {
|
|||||||
await DeviceHeaderService.to.ensureInitialized();
|
await DeviceHeaderService.to.ensureInitialized();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
// await ShorebirdService.instance.initCurrentPatch();
|
// Shorebird's native updater performs the automatic launch-time update.
|
||||||
// ShorebirdService.instance.checkForUpdateInBackground();
|
// Reading the running patch is diagnostic state and must not delay startup.
|
||||||
// }
|
unawaited(ShorebirdService.instance.initCurrentPatch());
|
||||||
|
}
|
||||||
return const JinzhiApp();
|
return const JinzhiApp();
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,11 @@ class AppConfig {
|
|||||||
factory AppConfig.fromEnvironment() {
|
factory AppConfig.fromEnvironment() {
|
||||||
const url = String.fromEnvironment(
|
const url = String.fromEnvironment(
|
||||||
'JINZHI_API_BASE_URL',
|
'JINZHI_API_BASE_URL',
|
||||||
defaultValue: 'https://jinzhi.api.local',
|
// defaultValue: 'http://localhost:8088',
|
||||||
|
// defaultValue: 'https://jince.api.xianlin88.top',
|
||||||
|
defaultValue: 'https://jinzhi.api.yqzhice.top',
|
||||||
|
|
||||||
|
// defaultValue: 'http://8.153.17.57',可测试ip
|
||||||
);
|
);
|
||||||
return const AppConfig(apiBaseUrl: url);
|
return const AppConfig(apiBaseUrl: url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class AppH5Urls {
|
|||||||
|
|
||||||
static const baseUrl = String.fromEnvironment(
|
static const baseUrl = String.fromEnvironment(
|
||||||
'JINZHI_H5_BASE_URL',
|
'JINZHI_H5_BASE_URL',
|
||||||
defaultValue: 'https://jinzhi.h5.local',
|
defaultValue: 'https://jinzhi.h5.yqzhice.top',
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
|
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ class UmengConfig {
|
|||||||
static String _androidAppKey =
|
static String _androidAppKey =
|
||||||
'6a27b1f46f259537c7b616be'; // Default placeholder
|
'6a27b1f46f259537c7b616be'; // Default placeholder
|
||||||
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
|
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
|
||||||
static String _androidPushSecret = 'xxx'; // Default placeholder
|
static String _androidPushSecret =
|
||||||
|
'261fc7656f0c34f4cf122a3c2c81f704'; // Default placeholder
|
||||||
|
static String _androidLoginSecret =
|
||||||
|
'oNaEhNEwZGQHvngXI4kyxR90tavJRDHMyDDQhkQouty+SAUb66szdNQivqE99ZIQO+QyNabsyyTJ7CN8iA3vb8EgPwLF/iSUHMWpGVacHpukG1sqySo1cwbRmDpOFcdNzaO8PavGfqkkCse/oRPNFBW40LhCjSJaI/roAEVNGpepMXvufJtjnyB4OX6sav+isHU/Lsz84bcdwnzD6fFyDSyE+YqN9gjUoUOv8Ghcy3H1FXUMvw4iD9nD+tE4jODLXvvzlpVbHYknn6Y2UAj5dvGvNUszocXqGAr4j3z8naKPBhz7Liofog==';
|
||||||
|
static String _iosLoginSecret =
|
||||||
|
'vcutZmDFJ2HBdFk7herCz2M9dPHuoa303NPGrm8c9gkzu/Lr5njFa85y33w7htgp0cx9JCMbsk3ecrREhVd8yeq73kFFiVqgkk/BMzCQ6VG55gSATEoiRaLnqXkKvuv6X4kcnSqANMI07up5rTPdAtVQ5JGPh9ts7LUBzRZWQRnDvdX8pjPy8LKxXJKoNagz4sHd5m9MbX0o0uep2OaFFFBTOyI+g4vjK1oZkpaUnIKGU8cTH+wvL8qwp5BLtkc6XCXe5PXqClU=';
|
||||||
|
|
||||||
static Future<void> load() async {
|
static Future<void> load() async {
|
||||||
await dotenv.load(
|
await dotenv.load(
|
||||||
@@ -17,9 +22,14 @@ class UmengConfig {
|
|||||||
_iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey;
|
_iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey;
|
||||||
_androidPushSecret =
|
_androidPushSecret =
|
||||||
dotenv.env['UMENG_ANDROID_PUSH_SECRET'] ?? _androidPushSecret;
|
dotenv.env['UMENG_ANDROID_PUSH_SECRET'] ?? _androidPushSecret;
|
||||||
|
_androidLoginSecret =
|
||||||
|
dotenv.env['UMENG_ANDROID_LOGIN_SECRET'] ?? _androidLoginSecret;
|
||||||
|
_iosLoginSecret = dotenv.env['UMENG_IOS_LOGIN_SECRET'] ?? _iosLoginSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String get androidAppKey => _androidAppKey;
|
static String get androidAppKey => _androidAppKey;
|
||||||
static String get iosAppKey => _iosAppKey;
|
static String get iosAppKey => _iosAppKey;
|
||||||
static String get androidPushSecret => _androidPushSecret;
|
static String get androidPushSecret => _androidPushSecret;
|
||||||
|
static String get androidLoginSecret => _androidLoginSecret;
|
||||||
|
static String get iosLoginSecret => _iosLoginSecret;
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'api_exception.dart';
|
||||||
|
|
||||||
String? kratosReason(Object e) {
|
String? kratosReason(Object e) {
|
||||||
if (e is! DioException) return null;
|
if (e is DioException || e is ApiException || e is FormatException) {
|
||||||
final body = e.response?.data;
|
return ApiException.from(e).reason;
|
||||||
if (body is Map && body['reason'] is String) return body['reason'] as String;
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String kratosDisplayMessage(Object e, {required String fallback}) {
|
String kratosDisplayMessage(Object e, {required String fallback}) {
|
||||||
if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
|
if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
|
||||||
if (e is DioException) {
|
final exception = ApiException.from(e);
|
||||||
final body = e.response?.data;
|
if (exception.serverMessage case final message? when message.isNotEmpty) {
|
||||||
if (body is Map && body['message'] is String) {
|
return message;
|
||||||
final m = body['message'] as String;
|
|
||||||
if (m.isNotEmpty) return m;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract final class ErrorMessageMapper {
|
||||||
|
static String message(Object error, {required String fallback}) =>
|
||||||
|
kratosDisplayMessage(error, fallback: fallback);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/pb/errors/errors.pb.dart' as kerrors;
|
||||||
|
|
||||||
|
enum ApiExceptionKind { transport, http, backend, decode, cancelled }
|
||||||
|
|
||||||
|
/// The single network failure shape exposed to application code.
|
||||||
|
class ApiException implements Exception {
|
||||||
|
const ApiException({
|
||||||
|
required this.kind,
|
||||||
|
this.httpStatus,
|
||||||
|
this.backendCode,
|
||||||
|
this.reason,
|
||||||
|
this.serverMessage,
|
||||||
|
this.requestOptions,
|
||||||
|
this.cause,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ApiExceptionKind kind;
|
||||||
|
final int? httpStatus;
|
||||||
|
final int? backendCode;
|
||||||
|
final String? reason;
|
||||||
|
final String? serverMessage;
|
||||||
|
final RequestOptions? requestOptions;
|
||||||
|
final Object? cause;
|
||||||
|
|
||||||
|
bool get isUnauthorized =>
|
||||||
|
httpStatus == 401 ||
|
||||||
|
const {'INVALID_TOKEN', 'TOKEN_EXPIRED'}.contains(reason?.toUpperCase());
|
||||||
|
|
||||||
|
bool get isRetryable {
|
||||||
|
if (kind == ApiExceptionKind.transport) return true;
|
||||||
|
final status = httpStatus;
|
||||||
|
return status == 408 || status == 429 || (status != null && status >= 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? get sessionGeneration =>
|
||||||
|
requestOptions?.extra[authSessionGenerationExtra] as int?;
|
||||||
|
|
||||||
|
static ApiException from(Object error, {StackTrace? stackTrace}) {
|
||||||
|
if (error is ApiException) return error;
|
||||||
|
if (error is DioException) {
|
||||||
|
if (error.error is ApiException) return error.error! as ApiException;
|
||||||
|
return fromDio(error);
|
||||||
|
}
|
||||||
|
if (error is FormatException) {
|
||||||
|
return ApiException(kind: ApiExceptionKind.decode, cause: error);
|
||||||
|
}
|
||||||
|
return ApiException(kind: ApiExceptionKind.decode, cause: error);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ApiException fromDio(DioException error) {
|
||||||
|
final response = error.response;
|
||||||
|
final status = response?.statusCode;
|
||||||
|
final kratos = parseKratosError(response?.data);
|
||||||
|
if (error.type == DioExceptionType.cancel) {
|
||||||
|
return ApiException(
|
||||||
|
kind: ApiExceptionKind.cancelled,
|
||||||
|
httpStatus: status,
|
||||||
|
requestOptions: error.requestOptions,
|
||||||
|
cause: error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final kind = kratos != null
|
||||||
|
? ApiExceptionKind.backend
|
||||||
|
: status != null
|
||||||
|
? ApiExceptionKind.http
|
||||||
|
: ApiExceptionKind.transport;
|
||||||
|
return ApiException(
|
||||||
|
kind: kind,
|
||||||
|
httpStatus: status,
|
||||||
|
backendCode: kratos?.code,
|
||||||
|
reason: _normalized(kratos?.reason),
|
||||||
|
serverMessage: _normalized(kratos?.message),
|
||||||
|
requestOptions: error.requestOptions,
|
||||||
|
cause: error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'ApiException($kind, status: $httpStatus, code: $backendCode, '
|
||||||
|
'reason: $reason, message: $serverMessage)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const authSessionGenerationExtra = 'authSessionGeneration';
|
||||||
|
|
||||||
|
kerrors.Status? parseKratosError(Object? raw) {
|
||||||
|
if (raw is List<int> && raw.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final status = kerrors.Status.fromBuffer(raw);
|
||||||
|
if (status.code != 0 || status.reason.isNotEmpty) return status;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw is Map) {
|
||||||
|
final code = raw['code'];
|
||||||
|
final reason = '${raw['reason'] ?? ''}';
|
||||||
|
final message = '${raw['message'] ?? ''}';
|
||||||
|
if (code is num || reason.isNotEmpty || message.isNotEmpty) {
|
||||||
|
return kerrors.Status(
|
||||||
|
code: code is num ? code.toInt() : 0,
|
||||||
|
reason: reason,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _normalized(String? value) {
|
||||||
|
final result = value?.trim() ?? '';
|
||||||
|
return result.isEmpty ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiExceptionInterceptor extends Interceptor {
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
final apiException = ApiException.fromDio(err);
|
||||||
|
handler.next(err.copyWith(error: apiException));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../services/device_header_service.dart';
|
import '../services/device_header_service.dart';
|
||||||
import '../storage/secure_identity_storage.dart';
|
import '../storage/secure_identity_storage.dart';
|
||||||
|
import '../storage/preferences_storage.dart';
|
||||||
import '../storage/storage_service.dart';
|
import '../storage/storage_service.dart';
|
||||||
|
import 'api_exception.dart';
|
||||||
|
|
||||||
final _defaultIdentityStorage = SecureIdentityStorage(
|
final _defaultIdentityStorage = SecureIdentityStorage(
|
||||||
StorageService.to.preferences,
|
PreferencesStorage(StorageService.to.preferences),
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
|
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
|
||||||
@@ -13,22 +15,51 @@ Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
|
|||||||
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
|
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
|
||||||
|
|
||||||
class AuthInterceptor extends Interceptor {
|
class AuthInterceptor extends Interceptor {
|
||||||
AuthInterceptor(this._identityStorage);
|
AuthInterceptor(this._sessionStorage, {required Uri backendOrigin})
|
||||||
|
: _backendOrigin = backendOrigin;
|
||||||
|
|
||||||
final SecureIdentityStorage _identityStorage;
|
final AuthSessionStorage _sessionStorage;
|
||||||
|
final Uri _backendOrigin;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onRequest(
|
Future<void> onRequest(
|
||||||
RequestOptions options,
|
RequestOptions options,
|
||||||
RequestInterceptorHandler handler,
|
RequestInterceptorHandler handler,
|
||||||
) async {
|
) async {
|
||||||
|
if (!_sameOrigin(options.uri, _backendOrigin)) {
|
||||||
|
_stripSensitiveHeaders(options.headers);
|
||||||
|
handler.next(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await DeviceHeaderService.to.ensureInitialized();
|
await DeviceHeaderService.to.ensureInitialized();
|
||||||
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
|
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
|
||||||
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
|
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
|
||||||
final jwt = await _identityStorage.readJwt();
|
final jwt = _sessionStorage.accessToken ?? await _sessionStorage.restore();
|
||||||
if (jwt != null && jwt.isNotEmpty) {
|
if (jwt != null && jwt.isNotEmpty) {
|
||||||
options.headers['Authorization'] = 'Bearer $jwt';
|
options.headers['Authorization'] = 'Bearer $jwt';
|
||||||
}
|
}
|
||||||
|
options.extra[authSessionGenerationExtra] = _sessionStorage.generation;
|
||||||
handler.next(options);
|
handler.next(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _sameOrigin(Uri request, Uri backend) =>
|
||||||
|
request.scheme.toLowerCase() == backend.scheme.toLowerCase() &&
|
||||||
|
request.host.toLowerCase() == backend.host.toLowerCase() &&
|
||||||
|
request.port == backend.port;
|
||||||
|
|
||||||
|
void _stripSensitiveHeaders(Map<String, dynamic> headers) {
|
||||||
|
const sensitive = {
|
||||||
|
'authorization',
|
||||||
|
'x-device-id',
|
||||||
|
'user-devicetoken',
|
||||||
|
'user-android-id',
|
||||||
|
'user-android-oaid',
|
||||||
|
'user-android-imei',
|
||||||
|
'user-android-mac',
|
||||||
|
'user-ios-idfv',
|
||||||
|
'user-ios-idfa',
|
||||||
|
'user-ios-paid',
|
||||||
|
};
|
||||||
|
headers.removeWhere((key, _) => sensitive.contains(key.toLowerCase()));
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../storage/secure_identity_storage.dart';
|
import '../storage/secure_identity_storage.dart';
|
||||||
|
import '../../features/auth/application/auth_session_coordinator.dart';
|
||||||
|
import 'api_exception.dart';
|
||||||
import 'auth_interceptor.dart';
|
import 'auth_interceptor.dart';
|
||||||
import 'kratos_error_interceptor.dart';
|
import 'kratos_error_interceptor.dart';
|
||||||
|
|
||||||
@@ -26,7 +28,9 @@ AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
|
|||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
Dio dio(Ref ref) {
|
Dio dio(Ref ref) {
|
||||||
final config = ref.watch(appConfigProvider);
|
final config = ref.watch(appConfigProvider);
|
||||||
final identityStorage = ref.watch(secureIdentityStorageProvider);
|
final sessionStorage = ref.watch(authSessionStorageProvider);
|
||||||
|
final coordinator = ref.watch(authSessionCoordinatorProvider);
|
||||||
|
final backendOrigin = Uri.parse(config.apiBaseUrl);
|
||||||
final d = Dio(
|
final d = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: config.apiBaseUrl,
|
baseUrl: config.apiBaseUrl,
|
||||||
@@ -40,11 +44,57 @@ Dio dio(Ref ref) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
d.interceptors.add(AuthInterceptor(identityStorage));
|
d.interceptors.add(
|
||||||
|
AuthInterceptor(sessionStorage, backendOrigin: backendOrigin),
|
||||||
|
);
|
||||||
d.interceptors.add(KratosErrorInterceptor());
|
d.interceptors.add(KratosErrorInterceptor());
|
||||||
|
d.interceptors.add(_SessionExpiryInterceptor(coordinator));
|
||||||
if (!kReleaseMode) {
|
if (!kReleaseMode) {
|
||||||
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
||||||
}
|
|
||||||
d.interceptors.add(FancyDioInterceptor());
|
d.interceptors.add(FancyDioInterceptor());
|
||||||
|
}
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit name for new code. [dioProvider] remains as a compatibility alias
|
||||||
|
/// while existing feature clients are migrated incrementally.
|
||||||
|
final backendDioProvider = dioProvider;
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
Dio publicDio(Ref ref) => Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 15),
|
||||||
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _SessionExpiryInterceptor extends Interceptor {
|
||||||
|
_SessionExpiryInterceptor(this._coordinator);
|
||||||
|
|
||||||
|
final AuthSessionCoordinator _coordinator;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onError(
|
||||||
|
DioException err,
|
||||||
|
ErrorInterceptorHandler handler,
|
||||||
|
) async {
|
||||||
|
final exception = ApiException.from(err);
|
||||||
|
if (exception.isUnauthorized) {
|
||||||
|
// Network code only emits the session event. Navigation and presentation
|
||||||
|
// observe AuthState elsewhere.
|
||||||
|
try {
|
||||||
|
await _coordinator.expireIfNeeded(exception);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
FlutterError.reportError(
|
||||||
|
FlutterErrorDetails(
|
||||||
|
exception: error,
|
||||||
|
stack: stackTrace,
|
||||||
|
library: 'jinzhi network',
|
||||||
|
context: ErrorDescription('while expiring an unauthorized session'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'api_exception.dart';
|
||||||
|
|
||||||
class KratosErrorInterceptor extends Interceptor {
|
class KratosErrorInterceptor extends Interceptor {
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
if (err.error is ApiException) {
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Preserve response.data for diagnostics and retries. The parsed error is
|
||||||
|
// carried as DioException.error and normalized by ApiException.from().
|
||||||
|
handler.next(err.copyWith(error: ApiException.fromDio(err)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Minimal reader retained only for endpoints that do not have an
|
||||||
|
/// authoritative generated protobuf model yet.
|
||||||
|
class ProtobufWireMessage {
|
||||||
|
ProtobufWireMessage(this.bytes);
|
||||||
|
|
||||||
|
final List<int> bytes;
|
||||||
|
Map<int, List<Object>>? _cache;
|
||||||
|
|
||||||
|
Map<int, List<Object>> get _fields => _cache ??= _read();
|
||||||
|
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
|
||||||
|
|
||||||
|
String string(int tag) {
|
||||||
|
final value = _fields[tag]?.lastOrNull;
|
||||||
|
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<int, List<Object>> _read() {
|
||||||
|
final result = <int, List<Object>>{};
|
||||||
|
var index = 0;
|
||||||
|
|
||||||
|
int varint() {
|
||||||
|
var value = 0;
|
||||||
|
var shift = 0;
|
||||||
|
while (index < bytes.length) {
|
||||||
|
final byte = bytes[index++];
|
||||||
|
value |= (byte & 127) << shift;
|
||||||
|
if (byte < 128) return value;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
throw const FormatException('Invalid protobuf');
|
||||||
|
}
|
||||||
|
|
||||||
|
while (index < bytes.length) {
|
||||||
|
final key = varint();
|
||||||
|
final tag = key >> 3;
|
||||||
|
final Object field;
|
||||||
|
if ((key & 7) == 0) {
|
||||||
|
field = varint();
|
||||||
|
} else if ((key & 7) == 2) {
|
||||||
|
final length = varint();
|
||||||
|
final end = index + length;
|
||||||
|
if (end > bytes.length) throw const FormatException('Invalid protobuf');
|
||||||
|
field = bytes.sublist(index, end);
|
||||||
|
index = end;
|
||||||
|
} else {
|
||||||
|
throw const FormatException('Unsupported protobuf');
|
||||||
|
}
|
||||||
|
(result[tag] ??= []).add(field);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on List<Object> {
|
||||||
|
Object? get lastOrNull => isEmpty ? null : last;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
abstract final class UmengPrivacyConsent {
|
||||||
|
static const _channel = MethodChannel('jinzhi/privacy');
|
||||||
|
|
||||||
|
static Future<void> submitGranted() async {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
|
||||||
|
await _channel.invokeMethod<void>('submitUmengPrivacyGranted');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../models/pb/jinzhi/v1/push.pb.dart' as pb;
|
||||||
|
import '../network/api_exception.dart';
|
||||||
|
import 'notification_permission.dart';
|
||||||
|
import 'push_api_client.dart';
|
||||||
|
import 'umeng_push_service.dart';
|
||||||
|
|
||||||
|
class DevicePushRegistrar {
|
||||||
|
DevicePushRegistrar(this._read);
|
||||||
|
final T Function<T>(ProviderListenable<T>) _read;
|
||||||
|
StreamSubscription<String>? _subscription;
|
||||||
|
String? _lastToken;
|
||||||
|
bool? _lastPermission;
|
||||||
|
Future<void> _registrationQueue = Future.value();
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
_subscription ??= UmengPushService.onDeviceToken.listen(_enqueue);
|
||||||
|
UmengPushService.emitCachedToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async => _subscription?.cancel();
|
||||||
|
Future<void> refresh() => UmengPushService.refreshDeviceToken();
|
||||||
|
void forceRegisterCurrentToken() {
|
||||||
|
// BLOCKED(server contract): guest registration is intentionally preserved.
|
||||||
|
// Do not add a client-side unbind call until the backend confirms whether
|
||||||
|
// guest registration reliably clears the previous user binding.
|
||||||
|
_lastToken = null;
|
||||||
|
_lastPermission = null;
|
||||||
|
UmengPushService.emitCachedToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enqueue(String token) {
|
||||||
|
_registrationQueue = _registrationQueue
|
||||||
|
.catchError((_) {})
|
||||||
|
.then((_) => _register(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _register(String token) async {
|
||||||
|
final permission = await NotificationPermission.isGranted();
|
||||||
|
if (token == _lastToken && permission == _lastPermission) return;
|
||||||
|
final package = await PackageInfo.fromPlatform();
|
||||||
|
final request = pb.RegisterPushDeviceRequest(
|
||||||
|
umengDeviceToken: token,
|
||||||
|
platform: defaultTargetPlatform == TargetPlatform.iOS
|
||||||
|
? pb.Platform.PLATFORM_IOS
|
||||||
|
: pb.Platform.PLATFORM_ANDROID,
|
||||||
|
appVersion: package.version,
|
||||||
|
permissionGranted: permission,
|
||||||
|
);
|
||||||
|
for (var attempt = 0; attempt < 4; attempt++) {
|
||||||
|
try {
|
||||||
|
await _read(pushApiClientProvider).registerDevice(request);
|
||||||
|
_lastToken = token;
|
||||||
|
_lastPermission = permission;
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt == 3 || !ApiException.from(error).isRetryable) return;
|
||||||
|
await Future<void>.delayed(Duration(seconds: 1 << attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
import '../storage/storage_keys.dart';
|
||||||
|
import '../storage/storage_service.dart';
|
||||||
|
import '../widgets/notification_permission_tip.dart';
|
||||||
|
|
||||||
|
abstract final class NotificationPermission {
|
||||||
|
static Future<bool> isGranted() async =>
|
||||||
|
!kIsWeb && (await Permission.notification.status).isGranted;
|
||||||
|
|
||||||
|
/// 对齐研听:Android 13+ 由应用解释后主动申请;iOS 由 U-Push 注册 APNs
|
||||||
|
/// 时触发系统授权,绝不展示 Android 的自定义顶部提示。
|
||||||
|
static Future<bool> requestIfNeeded(BuildContext context) async {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
|
return isGranted();
|
||||||
|
}
|
||||||
|
final android = await DeviceInfoPlugin().androidInfo;
|
||||||
|
if (android.version.sdkInt < 33 ||
|
||||||
|
StorageService.to.getBool(storagePushPermissionPrompted)) {
|
||||||
|
return isGranted();
|
||||||
|
}
|
||||||
|
if (await isGranted() || !context.mounted) return true;
|
||||||
|
final tip = showNotificationPermissionTip(context);
|
||||||
|
await StorageService.to.setBool(storagePushPermissionPrompted, true);
|
||||||
|
try {
|
||||||
|
// Match the legacy app's persistent top snackbar: keep the in-app
|
||||||
|
// explanation on screen until the system authorization dialog closes.
|
||||||
|
return (await Permission.notification.request()).isGranted;
|
||||||
|
} finally {
|
||||||
|
tip.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置页不使用顶部说明;iOS/Android 都直接请求系统授权。
|
||||||
|
static Future<bool> requestFromSettings() async {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
final current = await Permission.notification.status;
|
||||||
|
if (current.isGranted) return true;
|
||||||
|
await StorageService.to.setBool(storagePushPermissionPrompted, true);
|
||||||
|
return (await Permission.notification.request()).isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> openSystemSettings() => openAppSettings();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
// ignore: unused_import — compute() is referenced by generated Retrofit code.
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
|
||||||
|
import '../../models/pb/jinzhi/v1/push.pb.dart';
|
||||||
|
import '../network/dio_provider.dart';
|
||||||
|
|
||||||
|
part 'push_api_client.g.dart';
|
||||||
|
|
||||||
|
@RestApi()
|
||||||
|
abstract class PushApiClient {
|
||||||
|
factory PushApiClient(Dio dio, {String? baseUrl}) = _PushApiClient;
|
||||||
|
|
||||||
|
@POST('/api/v1/push/devices')
|
||||||
|
Future<RegisterPushDeviceReply> registerDevice(
|
||||||
|
@Body() RegisterPushDeviceRequest request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final pushApiClientProvider = Provider<PushApiClient>(
|
||||||
|
(ref) => PushApiClient(ref.watch(backendDioProvider)),
|
||||||
|
);
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:umeng_common_sdk/umeng_common_sdk.dart';
|
||||||
|
import 'package:umeng_push_sdk/umeng_push_sdk.dart';
|
||||||
|
|
||||||
|
import '../config/umeng_config.dart';
|
||||||
|
import '../privacy/privacy_gate.dart';
|
||||||
|
import '../services/device_header_service.dart';
|
||||||
|
import '../storage/storage_keys.dart';
|
||||||
|
import '../storage/storage_service.dart';
|
||||||
|
|
||||||
|
abstract final class UmengPushService {
|
||||||
|
static const _maxPendingNotificationOpens = 20;
|
||||||
|
static final _tokens = StreamController<String>.broadcast();
|
||||||
|
static Future<void>? _starting;
|
||||||
|
static bool _started = false;
|
||||||
|
static void Function(String payload)? _onNotificationOpen;
|
||||||
|
static final List<String> _pendingNotificationOpens = [];
|
||||||
|
|
||||||
|
static Stream<String> get onDeviceToken => _tokens.stream;
|
||||||
|
|
||||||
|
static void configureNotificationOpen(
|
||||||
|
void Function(String payload) callback,
|
||||||
|
) {
|
||||||
|
_onNotificationOpen = callback;
|
||||||
|
final pending = List<String>.of(_pendingNotificationOpens);
|
||||||
|
_pendingNotificationOpens.clear();
|
||||||
|
for (final payload in pending) {
|
||||||
|
callback(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> startIfAllowed() {
|
||||||
|
if (kIsWeb ||
|
||||||
|
_started ||
|
||||||
|
requiresAndroidPrivacyConsent(
|
||||||
|
isWeb: kIsWeb,
|
||||||
|
platform: defaultTargetPlatform,
|
||||||
|
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
|
||||||
|
)) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
return _starting ??= _start().whenComplete(() => _starting = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _start() async {
|
||||||
|
await DeviceHeaderService.to.ensureInitialized();
|
||||||
|
await UmengCommonSdk.initCommon(
|
||||||
|
UmengConfig.androidAppKey,
|
||||||
|
UmengConfig.iosAppKey,
|
||||||
|
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test',
|
||||||
|
UmengConfig.androidPushSecret,
|
||||||
|
);
|
||||||
|
UmengPushSdk.setTokenCallback(_emit);
|
||||||
|
UmengPushSdk.setNotificationCallback((_) {}, (payload) {
|
||||||
|
final callback = _onNotificationOpen;
|
||||||
|
if (callback == null) {
|
||||||
|
if (_pendingNotificationOpens.length == _maxPendingNotificationOpens) {
|
||||||
|
_pendingNotificationOpens.removeAt(0);
|
||||||
|
}
|
||||||
|
_pendingNotificationOpens.add(payload);
|
||||||
|
} else {
|
||||||
|
callback(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await UmengPushSdk.register(
|
||||||
|
UmengConfig.iosAppKey,
|
||||||
|
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test',
|
||||||
|
);
|
||||||
|
if (Platform.isAndroid) await UmengPushSdk.setPushEnable(true);
|
||||||
|
_started = true;
|
||||||
|
await refreshDeviceToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> refreshDeviceToken() async {
|
||||||
|
if (!_started || kIsWeb) return;
|
||||||
|
_emit(await UmengPushSdk.getRegisteredId());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void emitCachedToken() =>
|
||||||
|
_emit(StorageService.to.getString(storageDeviceToken));
|
||||||
|
|
||||||
|
static void _emit(String? token) {
|
||||||
|
if (token == null || token.isEmpty) return;
|
||||||
|
_tokens.add(token);
|
||||||
|
unawaited(StorageService.to.setString(storageDeviceToken, token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ import '../../features/news/presentation/news_page.dart';
|
|||||||
import '../../features/profile/presentation/profile_page.dart';
|
import '../../features/profile/presentation/profile_page.dart';
|
||||||
import '../../features/settings/presentation/settings_page.dart';
|
import '../../features/settings/presentation/settings_page.dart';
|
||||||
import '../../features/settings/presentation/privacy_consent_page.dart';
|
import '../../features/settings/presentation/privacy_consent_page.dart';
|
||||||
|
import '../../features/web/presentation/web_page.dart';
|
||||||
import '../services/device_header_service.dart';
|
import '../services/device_header_service.dart';
|
||||||
import 'tab_shell.dart';
|
import 'tab_shell.dart';
|
||||||
|
import 'route_error_page.dart';
|
||||||
|
|
||||||
abstract final class AppRoutes {
|
abstract final class AppRoutes {
|
||||||
static const assets = '/assets';
|
static const assets = '/assets';
|
||||||
@@ -33,13 +35,18 @@ abstract final class AppRoutes {
|
|||||||
static const debugTypography = '/debug/typography';
|
static const debugTypography = '/debug/typography';
|
||||||
static const fancyDioInspector = '/debug/fancy_dio_inspector';
|
static const fancyDioInspector = '/debug/fancy_dio_inspector';
|
||||||
static const privacyConsent = '/privacy-consent';
|
static const privacyConsent = '/privacy-consent';
|
||||||
|
static const web = '/web';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final startupListenable = ValueNotifier<int>(0);
|
final startupListenable = ValueNotifier<int>(0);
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
navigatorKey: rootNavigatorKey,
|
||||||
initialLocation: AppRoutes.assets,
|
initialLocation: AppRoutes.assets,
|
||||||
refreshListenable: startupListenable,
|
refreshListenable: startupListenable,
|
||||||
|
errorBuilder: (_, state) => RouteErrorPage(error: state.error),
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final loc = state.matchedLocation;
|
final loc = state.matchedLocation;
|
||||||
final onConsent = loc == AppRoutes.privacyConsent;
|
final onConsent = loc == AppRoutes.privacyConsent;
|
||||||
@@ -131,6 +138,19 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: AppRoutes.privacyConsent,
|
path: AppRoutes.privacyConsent,
|
||||||
builder: (_, s) => const PrivacyConsentPage(),
|
builder: (_, s) => const PrivacyConsentPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.web,
|
||||||
|
builder: (_, state) {
|
||||||
|
final uri = parseSafeWebUri(state.uri.queryParameters['url']);
|
||||||
|
if (uri == null) {
|
||||||
|
return const Scaffold(body: Center(child: Text('页面地址不可用')));
|
||||||
|
}
|
||||||
|
return AppWebPage(
|
||||||
|
initialUri: uri,
|
||||||
|
hideTitle: state.uri.queryParameters['hideTitle'] == '1',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
enum DeepLinkSource { push, appLink, universalLink, customScheme }
|
||||||
|
|
||||||
|
typedef DeepLinkNavigator = bool Function(String location);
|
||||||
|
typedef DeepLinkAuthGuard = bool Function(String location);
|
||||||
|
|
||||||
|
class DeepLinkEvent {
|
||||||
|
const DeepLinkEvent({
|
||||||
|
required this.raw,
|
||||||
|
required this.source,
|
||||||
|
required this.receivedAt,
|
||||||
|
this.eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String raw;
|
||||||
|
final DeepLinkSource source;
|
||||||
|
final DateTime receivedAt;
|
||||||
|
final String? eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source adapters feed one parser and a bounded FIFO. Navigation starts only
|
||||||
|
/// after GoRouter has a context and the auth guard accepts the target.
|
||||||
|
abstract final class DeepLinkService {
|
||||||
|
static const maxPending = 20;
|
||||||
|
static const expiry = Duration(minutes: 15);
|
||||||
|
static const _environmentHosts = String.fromEnvironment(
|
||||||
|
'JINZHI_DEEP_LINK_HOSTS',
|
||||||
|
);
|
||||||
|
|
||||||
|
static final List<DeepLinkEvent> _pending = [];
|
||||||
|
static final Set<String> _pendingEventIds = {};
|
||||||
|
static final Map<String, DateTime> _seenEventIds = {};
|
||||||
|
static final Set<String> _configuredHttpsHosts = _normalizedHosts(
|
||||||
|
_environmentHosts.split(','),
|
||||||
|
);
|
||||||
|
static Set<String> _trustedHttpsHosts = {..._configuredHttpsHosts};
|
||||||
|
static DeepLinkNavigator? _navigator;
|
||||||
|
static DeepLinkAuthGuard _authGuard = (_) => true;
|
||||||
|
|
||||||
|
static int get pendingCount => _pending.length;
|
||||||
|
|
||||||
|
static void handle(String raw) => enqueue(raw, source: DeepLinkSource.push);
|
||||||
|
|
||||||
|
static void handleUri(Uri uri) => enqueue(
|
||||||
|
uri.toString(),
|
||||||
|
source: uri.scheme.toLowerCase() == 'jinzhi'
|
||||||
|
? DeepLinkSource.customScheme
|
||||||
|
: DeepLinkSource.universalLink,
|
||||||
|
);
|
||||||
|
|
||||||
|
static void enqueue(
|
||||||
|
String raw, {
|
||||||
|
required DeepLinkSource source,
|
||||||
|
DateTime? receivedAt,
|
||||||
|
}) {
|
||||||
|
final normalized = raw.trim();
|
||||||
|
if (normalized.isEmpty) return;
|
||||||
|
final event = DeepLinkEvent(
|
||||||
|
raw: normalized,
|
||||||
|
source: source,
|
||||||
|
receivedAt: receivedAt ?? DateTime.now(),
|
||||||
|
eventId: _eventId(normalized),
|
||||||
|
);
|
||||||
|
_discardExpired(event.receivedAt);
|
||||||
|
_seenEventIds.removeWhere(
|
||||||
|
(_, seenAt) => event.receivedAt.difference(seenAt) > expiry,
|
||||||
|
);
|
||||||
|
if (event.eventId case final id? when _seenEventIds.containsKey(id)) return;
|
||||||
|
if (_pending.length == maxPending) _removeAt(0);
|
||||||
|
_pending.add(event);
|
||||||
|
if (event.eventId case final id?) {
|
||||||
|
_pendingEventIds.add(id);
|
||||||
|
_seenEventIds[id] = event.receivedAt;
|
||||||
|
}
|
||||||
|
_drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void attachRouter(
|
||||||
|
DeepLinkNavigator navigator, {
|
||||||
|
DeepLinkAuthGuard? authGuard,
|
||||||
|
}) {
|
||||||
|
_navigator = navigator;
|
||||||
|
_authGuard = authGuard ?? (_) => true;
|
||||||
|
_drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void notifyAuthChanged() => _drain();
|
||||||
|
|
||||||
|
static void detachRouter() => _navigator = null;
|
||||||
|
|
||||||
|
/// Universal/App Link domains are supplied by the release environment. An
|
||||||
|
/// empty allowlist intentionally disables HTTPS deep links until the native
|
||||||
|
/// association domain is confirmed.
|
||||||
|
static void configureTrustedHttpsHosts(Iterable<String> hosts) {
|
||||||
|
_trustedHttpsHosts = _normalizedHosts(hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void resetForTest() {
|
||||||
|
_pending.clear();
|
||||||
|
_pendingEventIds.clear();
|
||||||
|
_seenEventIds.clear();
|
||||||
|
_trustedHttpsHosts = {..._configuredHttpsHosts};
|
||||||
|
_navigator = null;
|
||||||
|
_authGuard = (_) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _drain() {
|
||||||
|
final navigator = _navigator;
|
||||||
|
if (navigator == null) return;
|
||||||
|
_discardExpired(DateTime.now());
|
||||||
|
while (_pending.isNotEmpty) {
|
||||||
|
final event = _pending.first;
|
||||||
|
final target = _route(event.raw);
|
||||||
|
if (target == null) {
|
||||||
|
_removeAt(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!_authGuard(target) || !navigator(target)) return;
|
||||||
|
_removeAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _discardExpired(DateTime now) {
|
||||||
|
while (_pending.isNotEmpty &&
|
||||||
|
now.difference(_pending.first.receivedAt) > expiry) {
|
||||||
|
_removeAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _removeAt(int index) {
|
||||||
|
final removed = _pending.removeAt(index);
|
||||||
|
if (removed.eventId case final id?) _pendingEventIds.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _eventId(String raw) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is Map) {
|
||||||
|
final id = '${decoded['event_id'] ?? decoded['eventId'] ?? ''}'.trim();
|
||||||
|
if (id.isNotEmpty) return id;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
final uri = Uri.tryParse(raw);
|
||||||
|
final id = uri?.queryParameters['event_id']?.trim();
|
||||||
|
return id == null || id.isEmpty ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _route(String raw) {
|
||||||
|
var source = raw.trim();
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(source);
|
||||||
|
if (decoded is Map) {
|
||||||
|
source =
|
||||||
|
'${decoded['deep_link'] ?? decoded['url'] ?? decoded['link'] ?? ''}';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
final uri = Uri.tryParse(source);
|
||||||
|
if (uri == null) return null;
|
||||||
|
if (uri.scheme.toLowerCase() == 'jinzhi') {
|
||||||
|
final path = uri.path.isNotEmpty
|
||||||
|
? uri.path
|
||||||
|
: uri.host.isNotEmpty
|
||||||
|
? '/${uri.host}'
|
||||||
|
: '/assets';
|
||||||
|
return _allowed(path)
|
||||||
|
? '$path${uri.hasQuery ? '?${uri.query}' : ''}'
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (uri.scheme.toLowerCase() == 'https' &&
|
||||||
|
uri.port == 443 &&
|
||||||
|
uri.userInfo.isEmpty &&
|
||||||
|
_trustedHttpsHosts.contains(uri.host.toLowerCase()) &&
|
||||||
|
_allowed(uri.path)) {
|
||||||
|
return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _allowed(String path) => const {
|
||||||
|
'/assets',
|
||||||
|
'/market',
|
||||||
|
'/news',
|
||||||
|
'/profile',
|
||||||
|
'/settings',
|
||||||
|
}.contains(path);
|
||||||
|
|
||||||
|
static Set<String> _normalizedHosts(Iterable<String> hosts) => {
|
||||||
|
for (final host in hosts)
|
||||||
|
if (host.trim().isNotEmpty) host.trim().toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
class RouteErrorPage extends StatelessWidget {
|
||||||
|
const RouteErrorPage({super.key, required this.error});
|
||||||
|
final Object? error;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.explore_off_outlined, size: 40),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Text('页面不存在或已失效'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => context.go('/assets'),
|
||||||
|
child: const Text('回到首页'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import '../../features/mall/application/mall_entry_controller.dart';
|
||||||
|
import '../../features/mall/application/mall_entry_promo_store.dart';
|
||||||
|
import '../../features/mall/application/mall_tab_config.dart';
|
||||||
|
import '../../features/mall/presentation/widgets/mall_entry_promo.dart';
|
||||||
|
import '../../features/ads/application/startup_ad_service.dart';
|
||||||
|
import '../../features/web/application/mall_bridge_state.dart';
|
||||||
|
import '../../features/web/application/mall_web_host_controller.dart';
|
||||||
|
import '../../features/web/presentation/mall_web_host.dart';
|
||||||
import '../theme/app_icons.dart';
|
import '../theme/app_icons.dart';
|
||||||
import '../widgets/adaptive.dart';
|
import '../widgets/app_toast.dart';
|
||||||
import 'app_router.dart';
|
import 'app_router.dart';
|
||||||
|
import '../storage/preferences_storage.dart';
|
||||||
|
|
||||||
class TabShell extends StatefulWidget {
|
class TabShell extends ConsumerStatefulWidget {
|
||||||
const TabShell({
|
const TabShell({
|
||||||
super.key,
|
super.key,
|
||||||
required this.navigationShell,
|
required this.navigationShell,
|
||||||
@@ -18,36 +32,225 @@ class TabShell extends StatefulWidget {
|
|||||||
final String location;
|
final String location;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TabShell> createState() => _TabShellState();
|
ConsumerState<TabShell> createState() => _TabShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TabShellState extends State<TabShell> {
|
class _TabShellState extends ConsumerState<TabShell>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
static const double _compactHeaderThreshold = 34;
|
static const double _compactHeaderThreshold = 34;
|
||||||
|
static const double _scrollVisibilityThreshold = 16;
|
||||||
|
static const double _topRevealEpsilon = 1;
|
||||||
|
static const Curve _expandSpring = Cubic(0.32, -0.9, 0.28, 1);
|
||||||
|
|
||||||
bool _showCompactHeader = false;
|
bool _showCompactHeader = false;
|
||||||
|
Brightness? _mallBrightness;
|
||||||
|
bool _mallPresented = false;
|
||||||
|
bool _mallActive = false;
|
||||||
|
late final AnimationController _minimizeController;
|
||||||
|
late final CurvedAnimation _minimizeFactor;
|
||||||
|
late final AnimationController _mallTransition;
|
||||||
|
late final CurvedAnimation _mallProgress;
|
||||||
|
late final ProviderSubscription<MallTabConfig> _mallConfigSubscription;
|
||||||
|
late final MallEntryPromoStore _mallEntryPromoStore;
|
||||||
|
late bool _mallEntryTipConsumed;
|
||||||
|
late bool _mallHomeBadgeSeen;
|
||||||
|
bool _showMallHomeBadgeForPresentation = false;
|
||||||
|
bool _userScrollSession = false;
|
||||||
|
ScrollDirection _userScrollDirection = ScrollDirection.idle;
|
||||||
|
double _directionAccum = 0;
|
||||||
|
bool _barMinimized = false;
|
||||||
|
|
||||||
int get _index => widget.navigationShell.currentIndex;
|
int get _index => widget.navigationShell.currentIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_mallEntryPromoStore = MallEntryPromoStore(
|
||||||
|
ref.read(preferencesStorageProvider),
|
||||||
|
);
|
||||||
|
_mallEntryTipConsumed = _mallEntryPromoStore.hasOpenedMall;
|
||||||
|
_mallHomeBadgeSeen = _mallEntryPromoStore.hasSeenHomeBadge;
|
||||||
|
MallWebHostController.instance.state.addListener(_syncMallHost);
|
||||||
|
MallBridgeController.instance.state.addListener(_refreshMallDock);
|
||||||
|
_minimizeController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
reverseDuration: const Duration(milliseconds: 380),
|
||||||
|
);
|
||||||
|
_minimizeFactor = CurvedAnimation(
|
||||||
|
parent: _minimizeController,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: _expandSpring,
|
||||||
|
);
|
||||||
|
_mallTransition = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 520),
|
||||||
|
reverseDuration: const Duration(milliseconds: 520),
|
||||||
|
);
|
||||||
|
_mallProgress = CurvedAnimation(
|
||||||
|
parent: _mallTransition,
|
||||||
|
curve: const Cubic(0.8, 0.05, 0.2, 0.95),
|
||||||
|
reverseCurve: const Cubic(0.8, 0.05, 0.2, 0.95),
|
||||||
|
);
|
||||||
|
final initialMallState = MallWebHostController.instance.state.value;
|
||||||
|
_mallPresented = initialMallState.url != null;
|
||||||
|
_mallActive = initialMallState.visible;
|
||||||
|
_mallTransition.value = _mallActive ? 1 : 0;
|
||||||
|
_mallConfigSubscription = ref.listenManual<MallTabConfig>(
|
||||||
|
mallTabConfigProvider,
|
||||||
|
(_, next) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _preloadMall(next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fireImmediately: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final brightness = Theme.of(context).brightness;
|
||||||
|
if (_mallBrightness == brightness) return;
|
||||||
|
_mallBrightness = brightness;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _preloadMall(ref.read(mallTabConfigProvider));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _preloadMall(MallTabConfig config) {
|
||||||
|
if (!config.isVisible) return;
|
||||||
|
final uri = config.attributedUri(
|
||||||
|
clickId: '',
|
||||||
|
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||||
|
);
|
||||||
|
if (uri != null) MallWebHostController.instance.preload(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
MallWebHostController.instance.state.removeListener(_syncMallHost);
|
||||||
|
MallBridgeController.instance.state.removeListener(_refreshMallDock);
|
||||||
|
_mallConfigSubscription.close();
|
||||||
|
_minimizeFactor.dispose();
|
||||||
|
_minimizeController.dispose();
|
||||||
|
_mallProgress.dispose();
|
||||||
|
_mallTransition.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncMallHost() {
|
||||||
|
final state = MallWebHostController.instance.state.value;
|
||||||
|
if (!mounted) return;
|
||||||
|
final changed = _mallActive != state.visible;
|
||||||
|
final showHomeBadge =
|
||||||
|
changed &&
|
||||||
|
state.visible &&
|
||||||
|
!_mallHomeBadgeSeen &&
|
||||||
|
ref.read(mallTabConfigProvider).showsEntryBadge;
|
||||||
|
setState(() {
|
||||||
|
_mallPresented |= state.url != null;
|
||||||
|
_mallActive = state.visible;
|
||||||
|
if (changed) {
|
||||||
|
_showMallHomeBadgeForPresentation = showHomeBadge;
|
||||||
|
if (showHomeBadge) _mallHomeBadgeSeen = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (showHomeBadge) {
|
||||||
|
unawaited(_mallEntryPromoStore.markHomeBadgeSeen());
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
if (state.visible) {
|
||||||
|
_mallTransition.forward();
|
||||||
|
} else {
|
||||||
|
_mallTransition.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshMallDock() {
|
||||||
|
if (mounted && _mallActive) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openMall(MallTabConfig config) async {
|
||||||
|
await _consumeMallEntryTip(config);
|
||||||
|
if (!mounted) return;
|
||||||
|
final result = await ref
|
||||||
|
.read(mallEntryControllerProvider)
|
||||||
|
.open(context, initialConfig: config);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (result == MallEntryOpenResult.unavailable) {
|
||||||
|
toastInfo(msg: '商城暂未开放');
|
||||||
|
} else if (result == MallEntryOpenResult.failed) {
|
||||||
|
toastInfo(msg: '商城打开失败,请稍后重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _consumeMallEntryTip(MallTabConfig config) async {
|
||||||
|
if (!config.showsEntryTip || _mallEntryTipConsumed) return;
|
||||||
|
setState(() => _mallEntryTipConsumed = true);
|
||||||
|
await _mallEntryPromoStore.markMallOpened();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isPrimaryTabRoot => switch (widget.location) {
|
||||||
|
AppRoutes.assets ||
|
||||||
|
AppRoutes.market ||
|
||||||
|
AppRoutes.news ||
|
||||||
|
AppRoutes.profile => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant TabShell oldWidget) {
|
void didUpdateWidget(covariant TabShell oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.location != widget.location && _showCompactHeader) {
|
if (oldWidget.location != widget.location) {
|
||||||
_showCompactHeader = false;
|
_expandBar(spring: false);
|
||||||
|
_resetScrollTracking();
|
||||||
|
if (_showCompactHeader) _showCompactHeader = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = ShadTheme.of(context).colorScheme;
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
final mall = ref.watch(mallTabConfigProvider);
|
||||||
|
final startupAdResolved = ref.watch(startupAdResolvedProvider);
|
||||||
|
final isQiyuKeyboardActive =
|
||||||
|
MallWebHostController.instance.state.value.isQiyuKeyboardActive;
|
||||||
final header = _headerForPath(widget.location);
|
final header = _headerForPath(widget.location);
|
||||||
final canPop = GoRouter.of(context).canPop();
|
final canPop = GoRouter.of(context).canPop();
|
||||||
|
final isAndroid =
|
||||||
|
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: cs.background,
|
backgroundColor: cs.background,
|
||||||
resizeToAvoidBottomInset: false,
|
// The resident mall stays visually fixed for ordinary pages. Qiyu is
|
||||||
|
// different: its WebView editor must receive the resized Android body
|
||||||
|
// when the IME opens, otherwise its delayed focus-scroll ends below the
|
||||||
|
// keyboard.
|
||||||
|
resizeToAvoidBottomInset: _mallActive && isQiyuKeyboardActive,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Scaffold(
|
AnimatedBuilder(
|
||||||
|
animation: _mallProgress,
|
||||||
|
builder: (context, child) => Transform.translate(
|
||||||
|
offset: Offset(
|
||||||
|
-MediaQuery.sizeOf(context).width * _mallProgress.value,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: _mallActive,
|
||||||
|
child: PopScope(
|
||||||
|
canPop: !isAndroid || !_mallActive,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop && isAndroid && _mallActive) {
|
||||||
|
MallWebHostController.instance.handleSystemBack();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
extendBody: true,
|
||||||
backgroundColor: cs.background,
|
backgroundColor: cs.background,
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -108,51 +311,62 @@ class _TabShellState extends State<TabShell> {
|
|||||||
child: widget.navigationShell,
|
child: widget.navigationShell,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: SafeArea(
|
|
||||||
top: false,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: cs.background,
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(color: cs.border, width: 1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 62,
|
|
||||||
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
|
||||||
child: Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
maxWidth: Adaptive.tabsMaxWidth,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < _labels.length; i++)
|
|
||||||
Expanded(
|
|
||||||
child: _TabButton(
|
|
||||||
icon: i == _index
|
|
||||||
? _filledIcons[i]
|
|
||||||
: _outlineIcons[i],
|
|
||||||
label: _labels[i],
|
|
||||||
selected: i == _index,
|
|
||||||
onTap: () =>
|
|
||||||
widget.navigationShell.goBranch(
|
|
||||||
i,
|
|
||||||
initialLocation: i == _index,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_mallPresented &&
|
||||||
|
MallWebHostController.instance.state.value.url != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _mallProgress,
|
||||||
|
builder: (context, child) => Transform.translate(
|
||||||
|
offset: Offset(
|
||||||
|
MediaQuery.sizeOf(context).width *
|
||||||
|
(1 - _mallProgress.value),
|
||||||
|
0,
|
||||||
),
|
),
|
||||||
],
|
child: child,
|
||||||
),
|
),
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_mallActive,
|
||||||
|
child: MallWebHost(
|
||||||
|
url: MallWebHostController.instance.state.value.url!,
|
||||||
|
visible: _mallActive,
|
||||||
|
onRequestExit: MallWebHostController.instance.dismiss,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!(_mallActive &&
|
||||||
|
(MallBridgeController.instance.state.value.canGoBack ||
|
||||||
|
MallWebHostController
|
||||||
|
.instance
|
||||||
|
.state
|
||||||
|
.value
|
||||||
|
.isShowingQiyuPage)))
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: _MorphingNavigationDock(
|
||||||
|
progress: _mallProgress,
|
||||||
|
minimize: _minimizeFactor,
|
||||||
|
currentIndex: _index,
|
||||||
|
mall: mall,
|
||||||
|
showMallEntryTip:
|
||||||
|
startupAdResolved &&
|
||||||
|
_isPrimaryTabRoot &&
|
||||||
|
!_mallActive &&
|
||||||
|
!_mallEntryTipConsumed &&
|
||||||
|
mall.showsEntryTip,
|
||||||
|
showMallHomeBadge:
|
||||||
|
_showMallHomeBadgeForPresentation && mall.showsEntryBadge,
|
||||||
|
onTabTap: (index) => widget.navigationShell.goBranch(
|
||||||
|
index,
|
||||||
|
initialLocation: index == _index,
|
||||||
|
),
|
||||||
|
onMallTap: () => _openMall(mall),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!kReleaseMode)
|
if (!kReleaseMode)
|
||||||
@@ -176,51 +390,578 @@ class _TabShellState extends State<TabShell> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _handleScrollNotification(ScrollNotification notification) {
|
void _resetScrollTracking() {
|
||||||
if (notification.metrics.axis != Axis.vertical) {
|
_userScrollSession = false;
|
||||||
return false;
|
_userScrollDirection = ScrollDirection.idle;
|
||||||
|
_directionAccum = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _expandBar({bool immediate = false, bool spring = true}) {
|
||||||
|
if (!_barMinimized && _minimizeController.value == 0) return;
|
||||||
|
_barMinimized = false;
|
||||||
|
if (immediate) {
|
||||||
|
_minimizeController.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final alreadyReversing =
|
||||||
|
_minimizeController.isAnimating &&
|
||||||
|
_minimizeController.status == AnimationStatus.reverse;
|
||||||
|
if (!alreadyReversing) {
|
||||||
|
_minimizeFactor.reverseCurve = spring
|
||||||
|
? _expandSpring
|
||||||
|
: Curves.easeOutCubic;
|
||||||
|
}
|
||||||
|
unawaited(_minimizeController.reverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _minimizeBar() {
|
||||||
|
if (_barMinimized && _minimizeController.value == 1) return;
|
||||||
|
_barMinimized = true;
|
||||||
|
unawaited(_minimizeController.forward());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleScrollNotification(ScrollNotification notification) {
|
||||||
|
final metrics = notification.metrics;
|
||||||
|
if (metrics.axis != Axis.vertical) return false;
|
||||||
|
|
||||||
final next = notification.metrics.pixels > _compactHeaderThreshold;
|
final next = notification.metrics.pixels > _compactHeaderThreshold;
|
||||||
if (next != _showCompactHeader && mounted) {
|
if (next != _showCompactHeader && mounted) {
|
||||||
setState(() => _showCompactHeader = next);
|
setState(() => _showCompactHeader = next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notification.depth != 0) return false;
|
||||||
|
if (notification is UserScrollNotification) {
|
||||||
|
_userScrollDirection = notification.direction;
|
||||||
|
_userScrollSession = notification.direction != ScrollDirection.idle;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (notification is ScrollStartNotification) {
|
||||||
|
if (notification.dragDetails != null) {
|
||||||
|
_userScrollSession = true;
|
||||||
|
_directionAccum = 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final atTop = metrics.pixels <= metrics.minScrollExtent + _topRevealEpsilon;
|
||||||
|
if (atTop) {
|
||||||
|
_directionAccum = 0;
|
||||||
|
if (_barMinimized || _minimizeController.value > 0) _expandBar();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (notification is! ScrollUpdateNotification) return false;
|
||||||
|
if (notification.dragDetails != null) _userScrollSession = true;
|
||||||
|
if (!_userScrollSession && _userScrollDirection == ScrollDirection.idle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final delta = notification.scrollDelta;
|
||||||
|
if (delta == null || delta == 0) return false;
|
||||||
|
final direction = _userScrollDirection != ScrollDirection.idle
|
||||||
|
? _userScrollDirection
|
||||||
|
: delta > 0
|
||||||
|
? ScrollDirection.reverse
|
||||||
|
: ScrollDirection.forward;
|
||||||
|
if (direction == ScrollDirection.reverse) {
|
||||||
|
if (_directionAccum < 0) _directionAccum = 0;
|
||||||
|
_directionAccum += delta.abs();
|
||||||
|
if (_directionAccum >= _scrollVisibilityThreshold) {
|
||||||
|
_directionAccum = 0;
|
||||||
|
_minimizeBar();
|
||||||
|
}
|
||||||
|
} else if (direction == ScrollDirection.forward) {
|
||||||
|
if (_directionAccum > 0) _directionAccum = 0;
|
||||||
|
_directionAccum -= delta.abs();
|
||||||
|
if (_directionAccum <= -_scrollVisibilityThreshold) {
|
||||||
|
_directionAccum = 0;
|
||||||
|
_expandBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TabButton extends StatelessWidget {
|
class _MorphingNavigationDock extends StatelessWidget {
|
||||||
const _TabButton({
|
const _MorphingNavigationDock({
|
||||||
required this.icon,
|
required this.progress,
|
||||||
required this.label,
|
required this.minimize,
|
||||||
required this.selected,
|
required this.currentIndex,
|
||||||
|
required this.mall,
|
||||||
|
required this.showMallEntryTip,
|
||||||
|
required this.showMallHomeBadge,
|
||||||
|
required this.onTabTap,
|
||||||
|
required this.onMallTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Animation<double> progress;
|
||||||
|
final Animation<double> minimize;
|
||||||
|
final int currentIndex;
|
||||||
|
final MallTabConfig mall;
|
||||||
|
final bool showMallEntryTip;
|
||||||
|
final bool showMallHomeBadge;
|
||||||
|
final ValueChanged<int> onTabTap;
|
||||||
|
final VoidCallback onMallTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
final bridge = MallBridgeController.instance.state.value;
|
||||||
|
final nativeItems = [
|
||||||
|
for (var index = 0; index < _labels.length; index++)
|
||||||
|
_DockTabItem(
|
||||||
|
label: _labels[index],
|
||||||
|
outline: _outlineIcons[index],
|
||||||
|
filled: _filledIcons[index],
|
||||||
|
symbol: _nativeSymbols[index],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final mallItems = [
|
||||||
|
for (final item in _MallNavigationDock.tabs)
|
||||||
|
_DockTabItem(label: item.$4, outline: item.$2, filled: item.$3),
|
||||||
|
];
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: Listenable.merge([progress, minimize]),
|
||||||
|
builder: (context, _) {
|
||||||
|
final t = progress.value.clamp(0.0, 1.0);
|
||||||
|
final minimized = minimize.value * (1 - t);
|
||||||
|
return SizedBox(
|
||||||
|
height: 84 + bottomInset,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
const side = 16.0;
|
||||||
|
const gap = 12.0;
|
||||||
|
const circle = 64.0;
|
||||||
|
final pill = mall.isVisible
|
||||||
|
? constraints.maxWidth - side * 2 - gap - circle
|
||||||
|
: constraints.maxWidth - side * 2;
|
||||||
|
final leftWidth = mall.isVisible
|
||||||
|
? lerpDouble(pill, circle, t)!
|
||||||
|
: pill;
|
||||||
|
final rightWidth = lerpDouble(circle, pill, t)!;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(side, 8, side, 10 + bottomInset),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: lerpDouble(1, 0.86, minimized)!,
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_MallDockSurface(
|
||||||
|
width: leftWidth,
|
||||||
|
height: circle,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: t > 0.5,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: (1 - t * 2).clamp(0.0, 1.0),
|
||||||
|
child: _ExpandingDockTabStrip(
|
||||||
|
items: nativeItems,
|
||||||
|
selectedIndex: currentIndex,
|
||||||
|
minimize: minimized,
|
||||||
|
onTap: onTabTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: t < 0.5,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: ((t - 0.5) * 2).clamp(0.0, 1.0),
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: '返回',
|
||||||
|
onPressed:
|
||||||
|
MallWebHostController.instance.dismiss,
|
||||||
|
icon: Icon(
|
||||||
|
AppIcons.arrowLeft,
|
||||||
|
size: 30,
|
||||||
|
color: cs.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (mall.isVisible) ...[
|
||||||
|
const SizedBox(width: gap),
|
||||||
|
_MallDockSurface(
|
||||||
|
width: rightWidth,
|
||||||
|
height: circle,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: t > 0.5,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: (1 - t * 2).clamp(0.0, 1.0),
|
||||||
|
child: _MallLauncherButton(
|
||||||
|
mall: mall,
|
||||||
|
showPromoTip: showMallEntryTip,
|
||||||
|
onTap: onMallTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: t < 0.5,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: ((t - 0.5) * 2).clamp(0.0, 1.0),
|
||||||
|
child: _ExpandingDockTabStrip(
|
||||||
|
items: mallItems,
|
||||||
|
selectedIndex: _MallNavigationDock.tabs
|
||||||
|
.indexWhere(
|
||||||
|
(item) =>
|
||||||
|
item.$1 == bridge.activeTab,
|
||||||
|
),
|
||||||
|
onTap: (index) => MallBridgeController
|
||||||
|
.instance
|
||||||
|
.activate(
|
||||||
|
_MallNavigationDock.tabs[index].$1,
|
||||||
|
reselect:
|
||||||
|
bridge.activeTab ==
|
||||||
|
_MallNavigationDock
|
||||||
|
.tabs[index]
|
||||||
|
.$1,
|
||||||
|
),
|
||||||
|
promoBadgeIndex: 0,
|
||||||
|
promoBadgeVisible: showMallHomeBadge,
|
||||||
|
promoBadgeLabel: mall.entryBadgeText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MallLauncherButton extends StatelessWidget {
|
||||||
|
const _MallLauncherButton({
|
||||||
|
required this.mall,
|
||||||
|
required this.showPromoTip,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final MallTabConfig mall;
|
||||||
final String label;
|
final bool showPromoTip;
|
||||||
final bool selected;
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = ShadTheme.of(context).colorScheme;
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
final color = selected ? cs.foreground : cs.mutedForeground;
|
return MallEntryPromoTooltip(
|
||||||
return InkWell(
|
visible: showPromoTip,
|
||||||
|
title: mall.entryTipTitle,
|
||||||
|
body: mall.entryTipBody,
|
||||||
|
onCardTap: onTap,
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: mall.title,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [cs.primary.withValues(alpha: 0.78), cs.primary],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Column(
|
child: Center(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Icon(
|
||||||
|
Icons.shopping_bag,
|
||||||
|
size: 25,
|
||||||
|
color: cs.primaryForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DockTabItem {
|
||||||
|
const _DockTabItem({
|
||||||
|
required this.label,
|
||||||
|
required this.outline,
|
||||||
|
required this.filled,
|
||||||
|
this.symbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData outline;
|
||||||
|
final IconData filled;
|
||||||
|
final AppBottomTabSymbol? symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpandingDockTabStrip extends StatelessWidget {
|
||||||
|
const _ExpandingDockTabStrip({
|
||||||
|
required this.items,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.onTap,
|
||||||
|
this.minimize = 0,
|
||||||
|
this.promoBadgeIndex,
|
||||||
|
this.promoBadgeVisible = false,
|
||||||
|
this.promoBadgeLabel = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
static const double _inset = 6;
|
||||||
|
static const double _selectedWidth = 92;
|
||||||
|
|
||||||
|
final List<_DockTabItem> items;
|
||||||
|
final int selectedIndex;
|
||||||
|
final ValueChanged<int> onTap;
|
||||||
|
final double minimize;
|
||||||
|
final int? promoBadgeIndex;
|
||||||
|
final bool promoBadgeVisible;
|
||||||
|
final String promoBadgeLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final safeIndex = selectedIndex.clamp(0, items.length - 1);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final trackWidth = constraints.maxWidth - _inset * 2;
|
||||||
|
final selectedWidth = lerpDouble(
|
||||||
|
_selectedWidth.clamp(52.0, trackWidth).toDouble(),
|
||||||
|
52,
|
||||||
|
minimize.clamp(0.0, 1.0),
|
||||||
|
)!;
|
||||||
|
final compactWidth = (trackWidth - selectedWidth) / (items.length - 1);
|
||||||
|
final indicatorLeft = _inset + safeIndex * compactWidth;
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 22, color: color),
|
AnimatedPositioned(
|
||||||
const SizedBox(height: 4),
|
duration: const Duration(milliseconds: 260),
|
||||||
Text(
|
curve: Curves.easeOutCubic,
|
||||||
label,
|
left: indicatorLeft,
|
||||||
|
top: _inset,
|
||||||
|
bottom: _inset,
|
||||||
|
width: selectedWidth,
|
||||||
|
child: _DockSelectionHighlight(
|
||||||
|
key: ValueKey('dock-selection-${items[safeIndex].label}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: _inset),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < items.length; index++)
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
width: index == safeIndex ? selectedWidth : compactWidth,
|
||||||
|
child: MallEntryPromoBadge(
|
||||||
|
visible: index == promoBadgeIndex && promoBadgeVisible,
|
||||||
|
label: promoBadgeLabel,
|
||||||
|
top: -2,
|
||||||
|
right: 0,
|
||||||
|
child: _ExpandingDockTab(
|
||||||
|
item: items[index],
|
||||||
|
selected: index == safeIndex,
|
||||||
|
labelFactor: index == safeIndex
|
||||||
|
? (1 - minimize).clamp(0.0, 1.0)
|
||||||
|
: 0,
|
||||||
|
onTap: () => onTap(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DockSelectionHighlight extends StatelessWidget {
|
||||||
|
const _DockSelectionHighlight({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(26),
|
||||||
|
color: cs.primary.withValues(alpha: 0.14),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpandingDockTab extends StatelessWidget {
|
||||||
|
const _ExpandingDockTab({
|
||||||
|
required this.item,
|
||||||
|
required this.selected,
|
||||||
|
required this.labelFactor,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _DockTabItem item;
|
||||||
|
final bool selected;
|
||||||
|
final double labelFactor;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: selected,
|
||||||
|
label: item.label,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(26),
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// `AnimatedContainer` changes a tab's width during a tab switch.
|
||||||
|
// Derive text room from this animation frame's *actual* width,
|
||||||
|
// not the target selected width, as Purse's tab button does.
|
||||||
|
final maxLabelWidth = (constraints.maxWidth - 31)
|
||||||
|
.clamp(0.0, 64.0)
|
||||||
|
.toDouble();
|
||||||
|
return Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (item.symbol case final symbol?)
|
||||||
|
AppBottomTabIcon(
|
||||||
|
symbol: symbol,
|
||||||
|
selected: selected,
|
||||||
|
size: 24,
|
||||||
|
unselectedColor: cs.mutedForeground,
|
||||||
|
selectedColor: cs.primary,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(
|
||||||
|
selected ? item.filled : item.outline,
|
||||||
|
size: 24,
|
||||||
|
color: selected ? cs.primary : cs.mutedForeground,
|
||||||
|
),
|
||||||
|
if (selected)
|
||||||
|
ClipRect(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
widthFactor: labelFactor,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: labelFactor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 7),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: maxLabelWidth,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.clip,
|
||||||
|
softWrap: false,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
color: cs.primary,
|
||||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
fontSize: 16,
|
||||||
color: color,
|
height: 1.1,
|
||||||
letterSpacing: 0,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class _MallNavigationDock {
|
||||||
|
static const tabs = <(MallBridgeTab, IconData, IconData, String)>[
|
||||||
|
(MallBridgeTab.home, Icons.shopping_bag_outlined, Icons.shopping_bag, '首页'),
|
||||||
|
(MallBridgeTab.market, Icons.show_chart, Icons.insert_chart, '行情'),
|
||||||
|
(
|
||||||
|
MallBridgeTab.orders,
|
||||||
|
Icons.receipt_long_outlined,
|
||||||
|
Icons.receipt_long,
|
||||||
|
'订单',
|
||||||
|
),
|
||||||
|
(MallBridgeTab.profile, Icons.person_outline, Icons.person, '我的'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MallDockSurface extends StatelessWidget {
|
||||||
|
const _MallDockSurface({this.width, this.height = 56, required this.child});
|
||||||
|
|
||||||
|
final double? width;
|
||||||
|
final double height;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(height / 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.12),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
// Clip only the frosted background. The mall promotion badge is
|
||||||
|
// intentionally positioned above the pill and must remain visible.
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(height / 2),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.background.withValues(alpha: 0.82),
|
||||||
|
borderRadius: BorderRadius.circular(height / 2),
|
||||||
|
border: Border.all(color: cs.border.withValues(alpha: 0.8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(child: child),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -241,6 +982,13 @@ const _outlineIcons = [
|
|||||||
AppIcons.meLine,
|
AppIcons.meLine,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const _nativeSymbols = [
|
||||||
|
AppBottomTabSymbol.assets,
|
||||||
|
AppBottomTabSymbol.market,
|
||||||
|
AppBottomTabSymbol.news,
|
||||||
|
AppBottomTabSymbol.profile,
|
||||||
|
];
|
||||||
|
|
||||||
const _labels = ['资产', '行情', '资讯', '我的'];
|
const _labels = ['资产', '行情', '资讯', '我的'];
|
||||||
|
|
||||||
_ShellHeader _headerForPath(String path) {
|
_ShellHeader _headerForPath(String path) {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:android_intent_plus/android_intent.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../features/web/application/mall_web_host_controller.dart';
|
||||||
|
import '../storage/storage_service.dart';
|
||||||
|
import '../storage/preferences_storage.dart';
|
||||||
|
|
||||||
|
/// Clears only disposable presentation data. Credentials and user preferences
|
||||||
|
/// deliberately remain outside this service.
|
||||||
|
class CacheClearService {
|
||||||
|
const CacheClearService._();
|
||||||
|
|
||||||
|
static Future<void> clear() async {
|
||||||
|
await DefaultCacheManager().emptyCache();
|
||||||
|
await MallWebHostController.instance.clear();
|
||||||
|
await SplashAdControllerPlaceholder.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get requiresSystemSettings => !kIsWeb && Platform.isAndroid;
|
||||||
|
|
||||||
|
static Future<void> openAndroidSystemSettings() async {
|
||||||
|
final packageName = (await PackageInfo.fromPlatform()).packageName;
|
||||||
|
await AndroidIntent(
|
||||||
|
action: 'android.settings.APPLICATION_DETAILS_SETTINGS',
|
||||||
|
data: 'package:$packageName',
|
||||||
|
).launch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CacheClearService is used outside Riverpod; the splash entry itself lives
|
||||||
|
/// in SharedPreferences and is removed here without touching user data.
|
||||||
|
abstract final class SplashAdControllerPlaceholder {
|
||||||
|
static Future<void> clear() async {
|
||||||
|
final preferences = PreferencesStorage(StorageService.to.preferences);
|
||||||
|
await preferences.remove('ads.splash.entry');
|
||||||
|
await preferences.remove('ads.splash.daily');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,14 @@ import 'package:android_oaid/android_oaid.dart';
|
|||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
|
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
|
||||||
import 'package:flutter_paid/flutter_paid.dart';
|
import 'package:flutter_paid/flutter_paid.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:platform_metadata/platform_metadata.dart';
|
import 'package:platform_metadata/platform_metadata.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../storage/storage_keys.dart';
|
import '../storage/storage_keys.dart';
|
||||||
import '../storage/storage_service.dart';
|
import '../storage/storage_service.dart';
|
||||||
|
import '../storage/preferences_storage.dart';
|
||||||
|
import '../storage/secure_identity_storage.dart';
|
||||||
import '../privacy/privacy_gate.dart';
|
import '../privacy/privacy_gate.dart';
|
||||||
|
|
||||||
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
||||||
@@ -26,14 +27,13 @@ import '../privacy/privacy_gate.dart';
|
|||||||
class DeviceHeaderService {
|
class DeviceHeaderService {
|
||||||
DeviceHeaderService._();
|
DeviceHeaderService._();
|
||||||
static final DeviceHeaderService to = DeviceHeaderService._();
|
static final DeviceHeaderService to = DeviceHeaderService._();
|
||||||
static const _storage = FlutterSecureStorage();
|
|
||||||
static const _kDeviceIdKey = 'device_id';
|
|
||||||
// static const _platformMetadataChannel = MethodChannel('platform_metadata');
|
// static const _platformMetadataChannel = MethodChannel('platform_metadata');
|
||||||
|
|
||||||
// 渠道:优先编译期 --dart-define=UMENG_CHANNEL=xxx;Android 其次读 manifest,
|
// 渠道:优先编译期 --dart-define=UMENG_CHANNEL=xxx;Android 其次读 manifest,
|
||||||
static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL');
|
static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL');
|
||||||
|
|
||||||
String _deviceId = '';
|
String _deviceId = '';
|
||||||
|
String _packageName = '';
|
||||||
String _version = '';
|
String _version = '';
|
||||||
String _build = '';
|
String _build = '';
|
||||||
String _deviceModel = '';
|
String _deviceModel = '';
|
||||||
@@ -47,6 +47,9 @@ class DeviceHeaderService {
|
|||||||
Future<void>? _baseInitialization;
|
Future<void>? _baseInitialization;
|
||||||
Future<void>? _identityInitialization;
|
Future<void>? _identityInitialization;
|
||||||
|
|
||||||
|
SecureIdentityStorage get _identityStorage =>
|
||||||
|
SecureIdentityStorage(PreferencesStorage(StorageService.to.preferences));
|
||||||
|
|
||||||
Future<void> ensureInitialized() async {
|
Future<void> ensureInitialized() async {
|
||||||
_baseInitialization ??= _initializeBase();
|
_baseInitialization ??= _initializeBase();
|
||||||
await _baseInitialization;
|
await _baseInitialization;
|
||||||
@@ -68,6 +71,7 @@ class DeviceHeaderService {
|
|||||||
Future<void> _initializeBase() async {
|
Future<void> _initializeBase() async {
|
||||||
try {
|
try {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
_packageName = info.packageName.trim();
|
||||||
_version = info.version;
|
_version = info.version;
|
||||||
_build = info.buildNumber;
|
_build = info.buildNumber;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -87,14 +91,14 @@ class DeviceHeaderService {
|
|||||||
/// X-Device-Id:iOS 优先 IDFV(per-vendor 稳定),否则 UUIDv4;
|
/// X-Device-Id:iOS 优先 IDFV(per-vendor 稳定),否则 UUIDv4;
|
||||||
/// 首次生成后写入 secure_storage,后续复用。
|
/// 首次生成后写入 secure_storage,后续复用。
|
||||||
Future<String> _resolveDeviceId() async {
|
Future<String> _resolveDeviceId() async {
|
||||||
final stored = await _storage.read(key: _kDeviceIdKey);
|
final stored = await _identityStorage.readDeviceId();
|
||||||
if (stored != null && stored.isNotEmpty) return stored;
|
if (stored != null && stored.isNotEmpty) return stored;
|
||||||
var id = '';
|
var id = '';
|
||||||
if (!kIsWeb && Platform.isIOS) {
|
if (!kIsWeb && Platform.isIOS) {
|
||||||
id = await _resolveIdfv();
|
id = await _resolveIdfv();
|
||||||
}
|
}
|
||||||
if (id.isEmpty) id = const Uuid().v4();
|
if (id.isEmpty) id = const Uuid().v4();
|
||||||
await _storage.write(key: _kDeviceIdKey, value: id);
|
await _identityStorage.saveDeviceId(id);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +189,12 @@ class DeviceHeaderService {
|
|||||||
await StorageService.to.setBool(storagePrivacyAgreed, true);
|
await StorageService.to.setBool(storagePrivacyAgreed, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ATT 授权后写入 IDFA;后续请求通过 platformIdentityHeaders 自动带上。
|
||||||
|
Future<void> saveIosIdfa(String value) async {
|
||||||
|
if (value.trim().isEmpty) return;
|
||||||
|
await StorageService.to.setString(storageIosIdfa, value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备
|
/// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备
|
||||||
/// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
|
/// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
|
||||||
bool get privacyConsentRequired => requiresAndroidPrivacyConsent(
|
bool get privacyConsentRequired => requiresAndroidPrivacyConsent(
|
||||||
@@ -238,6 +248,7 @@ class DeviceHeaderService {
|
|||||||
/// 公共 header(不含 token)。11
|
/// 公共 header(不含 token)。11
|
||||||
Map<String, String> commonHeaders() => _compact({
|
Map<String, String> commonHeaders() => _compact({
|
||||||
'X-Device-Id': _deviceId,
|
'X-Device-Id': _deviceId,
|
||||||
|
'pkg': _packageName,
|
||||||
'User-Version': _version,
|
'User-Version': _version,
|
||||||
'User-BuildID': _build,
|
'User-BuildID': _build,
|
||||||
'User-Platform': _platform(),
|
'User-Platform': _platform(),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'storage_service.dart';
|
||||||
|
|
||||||
|
/// Typed boundary for non-sensitive preferences and display caches.
|
||||||
|
///
|
||||||
|
/// Feature code depends on this class instead of constructing or retaining a
|
||||||
|
/// [SharedPreferences] instance.
|
||||||
|
class PreferencesStorage {
|
||||||
|
const PreferencesStorage(this._preferences);
|
||||||
|
|
||||||
|
final SharedPreferences _preferences;
|
||||||
|
|
||||||
|
bool getBool(String key, {bool defaultValue = false}) =>
|
||||||
|
_preferences.getBool(key) ?? defaultValue;
|
||||||
|
String? getString(String key) => _preferences.getString(key);
|
||||||
|
bool containsKey(String key) => _preferences.containsKey(key);
|
||||||
|
|
||||||
|
Future<void> setBool(String key, bool value) =>
|
||||||
|
_preferences.setBool(key, value);
|
||||||
|
Future<void> setString(String key, String value) =>
|
||||||
|
_preferences.setString(key, value);
|
||||||
|
Future<void> remove(String key) => _preferences.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
final preferencesStorageProvider = Provider<PreferencesStorage>(
|
||||||
|
(ref) => PreferencesStorage(StorageService.to.preferences),
|
||||||
|
);
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
|
import 'preferences_storage.dart';
|
||||||
import 'storage_keys.dart';
|
import 'storage_keys.dart';
|
||||||
import 'storage_service.dart';
|
|
||||||
|
|
||||||
abstract interface class SecureKeyValueStorage {
|
abstract interface class SecureKeyValueStorage {
|
||||||
Future<String?> read(String key);
|
Future<String?> read(String key);
|
||||||
@@ -33,25 +32,33 @@ class SecureIdentityStorage {
|
|||||||
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
|
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
|
||||||
}) : _storage = secureStorage;
|
}) : _storage = secureStorage;
|
||||||
|
|
||||||
static const _accessTokenKey = 'access_token';
|
final PreferencesStorage _preferences;
|
||||||
static const _legacySecureJwtKey = 'jwt_token';
|
|
||||||
|
|
||||||
final SharedPreferences _preferences;
|
|
||||||
final SecureKeyValueStorage _storage;
|
final SecureKeyValueStorage _storage;
|
||||||
|
|
||||||
Future<String?> restoreAccessToken() async {
|
Future<String?> restoreAccessToken() async {
|
||||||
final current = _normalized(await _storage.read(_accessTokenKey));
|
|
||||||
final legacyPreferences = _normalized(
|
final legacyPreferences = _normalized(
|
||||||
_preferences.getString(storageLegacyAccessToken),
|
_preferences.getString(storageLegacyAccessToken),
|
||||||
);
|
);
|
||||||
|
final hasInstallSession = _preferences.getBool(storageAuthSessionActive);
|
||||||
|
|
||||||
|
// A plaintext token proves that this installation predates secure storage
|
||||||
|
// migration. A Keychain token by itself does not: iOS may retain it after
|
||||||
|
// uninstall while SharedPreferences (including this marker) is reset.
|
||||||
|
if (!hasInstallSession && legacyPreferences == null) {
|
||||||
|
await _deleteAllAuthCredentials();
|
||||||
|
await _preferences.remove(storageAuthSessionActive);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final current = _normalized(await _storage.read(secureAccessToken));
|
||||||
final legacySecure = current == null
|
final legacySecure = current == null
|
||||||
? _normalized(await _storage.read(_legacySecureJwtKey))
|
? _normalized(await _storage.read(secureLegacyJwtToken))
|
||||||
: null;
|
: null;
|
||||||
final token = current ?? legacyPreferences ?? legacySecure;
|
final token = current ?? legacyPreferences ?? legacySecure;
|
||||||
if (current == null && token != null) {
|
if (current == null && token != null) {
|
||||||
await _storage.write(_accessTokenKey, token);
|
await _storage.write(secureAccessToken, token);
|
||||||
}
|
}
|
||||||
await _storage.delete(_legacySecureJwtKey);
|
await _storage.delete(secureLegacyJwtToken);
|
||||||
await _preferences.remove(storageLegacyAccessToken);
|
await _preferences.remove(storageLegacyAccessToken);
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
await _preferences.remove(storageAuthSessionActive);
|
await _preferences.remove(storageAuthSessionActive);
|
||||||
@@ -62,10 +69,12 @@ class SecureIdentityStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> readJwt() async {
|
Future<String?> readJwt() async {
|
||||||
final token = _normalized(await _storage.read(_accessTokenKey));
|
if (!_preferences.getBool(storageAuthSessionActive) ||
|
||||||
if (token == null || _preferences.containsKey(storageLegacyAccessToken)) {
|
_preferences.containsKey(storageLegacyAccessToken)) {
|
||||||
return restoreAccessToken();
|
return restoreAccessToken();
|
||||||
}
|
}
|
||||||
|
final token = _normalized(await _storage.read(secureAccessToken));
|
||||||
|
if (token == null) await _preferences.remove(storageAuthSessionActive);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,22 +83,100 @@ class SecureIdentityStorage {
|
|||||||
if (normalized == null) {
|
if (normalized == null) {
|
||||||
throw ArgumentError.value(token, 'token', 'must not be empty');
|
throw ArgumentError.value(token, 'token', 'must not be empty');
|
||||||
}
|
}
|
||||||
await _storage.write(_accessTokenKey, normalized);
|
await _storage.write(secureAccessToken, normalized);
|
||||||
await _storage.delete(_legacySecureJwtKey);
|
await _storage.delete(secureLegacyJwtToken);
|
||||||
await _preferences.remove(storageLegacyAccessToken);
|
await _preferences.remove(storageLegacyAccessToken);
|
||||||
await _preferences.setBool(storageAuthSessionActive, true);
|
await _preferences.setBool(storageAuthSessionActive, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearJwt() async {
|
Future<void> clearJwt() async {
|
||||||
await _storage.delete(_accessTokenKey);
|
await _deleteAllAuthCredentials();
|
||||||
await _storage.delete(_legacySecureJwtKey);
|
|
||||||
await _preferences.remove(storageLegacyAccessToken);
|
await _preferences.remove(storageLegacyAccessToken);
|
||||||
await _preferences.remove(storageAuthSessionActive);
|
await _preferences.remove(storageAuthSessionActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearSessionCredentials() async {
|
||||||
|
await clearJwt();
|
||||||
|
await _storage.delete(secureMallCredential);
|
||||||
|
await _storage.delete(secureRefreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearAccountCredentials() async {
|
||||||
|
await clearSessionCredentials();
|
||||||
|
await _storage.delete(secureBiometricCredential);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> readDeviceId() => _storage.read(secureDeviceId);
|
||||||
|
|
||||||
|
Future<void> saveDeviceId(String deviceId) =>
|
||||||
|
_storage.write(secureDeviceId, deviceId);
|
||||||
|
|
||||||
|
Future<void> _deleteAllAuthCredentials() async {
|
||||||
|
await _storage.delete(secureAccessToken);
|
||||||
|
await _storage.delete(secureLegacyJwtToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
|
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
|
||||||
(ref) => SecureIdentityStorage(StorageService.to.preferences),
|
(ref) => SecureIdentityStorage(ref.watch(preferencesStorageProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Process-memory session snapshot. Interceptors read this synchronously;
|
||||||
|
/// secure storage is touched only when a session starts, restores, or ends.
|
||||||
|
class AuthSessionStorage {
|
||||||
|
AuthSessionStorage(this._identityStorage);
|
||||||
|
|
||||||
|
final SecureIdentityStorage _identityStorage;
|
||||||
|
String? _accessToken;
|
||||||
|
int _generation = 0;
|
||||||
|
Future<String?>? _restoreFuture;
|
||||||
|
Future<void> _operationTail = Future<void>.value();
|
||||||
|
|
||||||
|
String? get accessToken => _accessToken;
|
||||||
|
int get generation => _generation;
|
||||||
|
|
||||||
|
Future<String?> restore() => _restoreFuture ??= _serialize(_restore);
|
||||||
|
|
||||||
|
Future<String?> _restore() async {
|
||||||
|
_accessToken = await _identityStorage.restoreAccessToken();
|
||||||
|
_generation++;
|
||||||
|
return _accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(String token) async {
|
||||||
|
await _serialize(() async {
|
||||||
|
await _identityStorage.saveJwt(token);
|
||||||
|
_accessToken = token.trim();
|
||||||
|
_generation++;
|
||||||
|
_restoreFuture = Future<String?>.value(_accessToken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear({bool accountDeleted = false}) async {
|
||||||
|
await _serialize(() async {
|
||||||
|
if (accountDeleted) {
|
||||||
|
await _identityStorage.clearAccountCredentials();
|
||||||
|
} else {
|
||||||
|
await _identityStorage.clearSessionCredentials();
|
||||||
|
}
|
||||||
|
_accessToken = null;
|
||||||
|
_generation++;
|
||||||
|
_restoreFuture = Future<String?>.value();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _serialize<T>(Future<T> Function() operation) {
|
||||||
|
final result = _operationTail.then((_) => operation());
|
||||||
|
_operationTail = result.then<void>(
|
||||||
|
(_) {},
|
||||||
|
onError: (Object _, StackTrace _) {},
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final authSessionStorageProvider = Provider<AuthSessionStorage>(
|
||||||
|
(ref) => AuthSessionStorage(ref.watch(secureIdentityStorageProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
String? _normalized(String? value) {
|
String? _normalized(String? value) {
|
||||||
|
|||||||
@@ -2,17 +2,33 @@ const storageThemeMode = 'theme_mode';
|
|||||||
const storagePrivacyAgreed = 'privacy_agreed'; // Android 首启隐私同意(设备采集门控)
|
const storagePrivacyAgreed = 'privacy_agreed'; // Android 首启隐私同意(设备采集门控)
|
||||||
const storageIosStartupSeen = 'ios_startup_seen'; // iOS 首次启动页已展示
|
const storageIosStartupSeen = 'ios_startup_seen'; // iOS 首次启动页已展示
|
||||||
const storageJinzhiHoldings = 'jinzhi_holdings';
|
const storageJinzhiHoldings = 'jinzhi_holdings';
|
||||||
|
const storageJinzhiHoldingsDeleted = 'jinzhi_holdings_deleted';
|
||||||
const storageJinzhiAmountHidden = 'jinzhi_amount_hidden';
|
const storageJinzhiAmountHidden = 'jinzhi_amount_hidden';
|
||||||
const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
|
const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
|
||||||
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
||||||
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
|
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
|
||||||
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
|
const storageUserId = 'jinzhi_user_id';
|
||||||
|
const storageUserPhone = 'jinzhi_user_phone';
|
||||||
|
const storageUserCreatedAt = 'jinzhi_user_created_at';
|
||||||
|
const storagePushPermissionPrompted = 'push_permission_prompted';
|
||||||
|
const storageMallEntryPromoOpened = 'jinzhi_mall_entry_promo_opened';
|
||||||
|
const storageMallHomeBadgeSeen = 'jinzhi_mall_home_badge_seen';
|
||||||
|
|
||||||
// Previous builds could persist credentials in SharedPreferences. These keys
|
// Previous builds could persist credentials in SharedPreferences. These keys
|
||||||
// exist only for one-time migration into secure storage.
|
// exist only for one-time migration into secure storage.
|
||||||
const storageLegacyAccessToken = 'access_token';
|
const storageLegacyAccessToken = 'access_token';
|
||||||
const storageAuthSessionActive = 'auth_session_active';
|
const storageAuthSessionActive = 'auth_session_active';
|
||||||
|
|
||||||
|
// Account-scoped secure values. They are intentionally listed even before
|
||||||
|
// every feature is implemented so account deletion has one complete cleanup
|
||||||
|
// boundary. Access tokens must never be reused as biometric credentials.
|
||||||
|
const secureAccessToken = 'access_token';
|
||||||
|
const secureLegacyJwtToken = 'jwt_token';
|
||||||
|
const secureDeviceId = 'device_id';
|
||||||
|
const secureMallCredential = 'mall_credential';
|
||||||
|
const secureRefreshToken = 'refresh_token';
|
||||||
|
const secureBiometricCredential = 'biometric_credential';
|
||||||
|
|
||||||
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
||||||
// header;未写入则为空、注入时被过滤。
|
// header;未写入则为空、注入时被过滤。
|
||||||
const storageDeviceToken = 'device_token'; // 推送 token
|
const storageDeviceToken = 'device_token'; // 推送 token
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_remix/flutter_remix.dart';
|
import 'package:flutter_remix/flutter_remix.dart';
|
||||||
|
import 'package:not_static_icons/not_static_icons.dart' as nsi;
|
||||||
|
|
||||||
// flutter_remix 0.0.3: icon class is FlutterRemix
|
// flutter_remix 0.0.3: icon class is FlutterRemix
|
||||||
class AppIcons {
|
class AppIcons {
|
||||||
@@ -13,3 +17,183 @@ class AppIcons {
|
|||||||
static const arrowRight = FlutterRemix.arrow_right_s_line;
|
static const arrowRight = FlutterRemix.arrow_right_s_line;
|
||||||
static const arrowLeft = FlutterRemix.arrow_left_s_line;
|
static const arrowLeft = FlutterRemix.arrow_left_s_line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AppBottomTabSymbol { assets, market, news, profile }
|
||||||
|
|
||||||
|
class AppBottomTabIcon extends StatefulWidget {
|
||||||
|
const AppBottomTabIcon({
|
||||||
|
required this.symbol,
|
||||||
|
required this.selected,
|
||||||
|
required this.unselectedColor,
|
||||||
|
required this.selectedColor,
|
||||||
|
this.size = 24,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppBottomTabSymbol symbol;
|
||||||
|
final bool selected;
|
||||||
|
final Color unselectedColor;
|
||||||
|
final Color selectedColor;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AppBottomTabIcon> createState() => _AppBottomTabIconState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppBottomTabIconState extends State<AppBottomTabIcon>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
static const _iconMotionDuration = Duration(milliseconds: 420);
|
||||||
|
static const _selectionDuration = Duration(milliseconds: 140);
|
||||||
|
static const _popDuration = Duration(milliseconds: 260);
|
||||||
|
static const _scalePeak = 1.06;
|
||||||
|
|
||||||
|
final nsi.AnimatedIconController _iconController =
|
||||||
|
nsi.AnimatedIconController();
|
||||||
|
late final AnimationController _selectionController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: _selectionDuration,
|
||||||
|
reverseDuration: _selectionDuration,
|
||||||
|
value: widget.selected ? 1 : 0,
|
||||||
|
);
|
||||||
|
late final AnimationController _popController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: _popDuration,
|
||||||
|
);
|
||||||
|
late final CurvedAnimation _colorProgress = CurvedAnimation(
|
||||||
|
parent: _selectionController,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
late final Animation<double> _scaleAnimation = TweenSequence<double>([
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween(
|
||||||
|
begin: 1.0,
|
||||||
|
end: _scalePeak,
|
||||||
|
).chain(CurveTween(curve: Curves.easeOutBack)),
|
||||||
|
weight: 55,
|
||||||
|
),
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween(
|
||||||
|
begin: _scalePeak,
|
||||||
|
end: 1.0,
|
||||||
|
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||||||
|
weight: 45,
|
||||||
|
),
|
||||||
|
]).animate(_popController);
|
||||||
|
|
||||||
|
bool get _reduceMotion => MediaQuery.disableAnimationsOf(context);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.selected) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && !_reduceMotion) _iconController.animate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant AppBottomTabIcon oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.selected == oldWidget.selected) return;
|
||||||
|
if (widget.selected) {
|
||||||
|
if (_reduceMotion) {
|
||||||
|
_selectionController.value = 1;
|
||||||
|
_popController.value = 0;
|
||||||
|
} else {
|
||||||
|
unawaited(_selectionController.forward(from: 0));
|
||||||
|
unawaited(_popController.forward(from: 0));
|
||||||
|
_iconController.animate();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_reduceMotion) {
|
||||||
|
_selectionController.value = 0;
|
||||||
|
} else {
|
||||||
|
unawaited(_selectionController.reverse());
|
||||||
|
}
|
||||||
|
_popController.value = 0;
|
||||||
|
_iconController.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_colorProgress.dispose();
|
||||||
|
_selectionController.dispose();
|
||||||
|
_popController.dispose();
|
||||||
|
_iconController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final reduceMotion = _reduceMotion;
|
||||||
|
return SizedBox.square(
|
||||||
|
dimension: widget.size,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: Listenable.merge([_selectionController, _popController]),
|
||||||
|
builder: (context, _) {
|
||||||
|
final color = Color.lerp(
|
||||||
|
widget.unselectedColor,
|
||||||
|
widget.selectedColor,
|
||||||
|
reduceMotion ? (widget.selected ? 1 : 0) : _colorProgress.value,
|
||||||
|
)!;
|
||||||
|
return Transform.scale(
|
||||||
|
scale: reduceMotion ? 1 : _scaleAnimation.value,
|
||||||
|
child: _buildBottomTabIcon(
|
||||||
|
widget.symbol,
|
||||||
|
size: widget.size,
|
||||||
|
color: color,
|
||||||
|
controller: _iconController,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomTabIcon(
|
||||||
|
AppBottomTabSymbol symbol, {
|
||||||
|
required double size,
|
||||||
|
required Color color,
|
||||||
|
required nsi.AnimatedIconController controller,
|
||||||
|
}) {
|
||||||
|
const strokeWidth = 2.05;
|
||||||
|
const duration = _AppBottomTabIconState._iconMotionDuration;
|
||||||
|
return switch (symbol) {
|
||||||
|
AppBottomTabSymbol.assets => nsi.CoinsIcon(
|
||||||
|
size: size,
|
||||||
|
color: color,
|
||||||
|
animationDuration: duration,
|
||||||
|
strokeWidth: strokeWidth,
|
||||||
|
interactive: false,
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
AppBottomTabSymbol.market => nsi.ChartCandlestickIcon(
|
||||||
|
size: size,
|
||||||
|
color: color,
|
||||||
|
animationDuration: duration,
|
||||||
|
strokeWidth: strokeWidth,
|
||||||
|
interactive: false,
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
AppBottomTabSymbol.news => nsi.BookOpenTextIcon(
|
||||||
|
size: size,
|
||||||
|
color: color,
|
||||||
|
animationDuration: duration,
|
||||||
|
strokeWidth: strokeWidth,
|
||||||
|
interactive: false,
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
AppBottomTabSymbol.profile => nsi.CircleUserIcon(
|
||||||
|
size: size,
|
||||||
|
color: color,
|
||||||
|
animationDuration: duration,
|
||||||
|
strokeWidth: strokeWidth,
|
||||||
|
interactive: false,
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:umeng_common_sdk/umeng_common_sdk.dart';
|
||||||
|
import 'package:umeng_verify_sdk/umeng_verify_sdk.dart';
|
||||||
|
|
||||||
|
import '../config/umeng_config.dart';
|
||||||
|
import '../services/device_header_service.dart';
|
||||||
|
import '../storage/storage_keys.dart';
|
||||||
|
import '../storage/storage_service.dart';
|
||||||
|
|
||||||
|
/// 合规门禁后的友盟公共初始化入口;运营商认证只可由显式登录动作触发。
|
||||||
|
abstract final class UmengService {
|
||||||
|
static Future<void>? _initializing;
|
||||||
|
static bool _ready = false;
|
||||||
|
static Future<void>? _oneKeyInitializing;
|
||||||
|
static bool _oneKeyReady = false;
|
||||||
|
|
||||||
|
static Future<void> initIfAllowed() {
|
||||||
|
if (_ready || kIsWeb) return Future.value();
|
||||||
|
if (Platform.isAndroid &&
|
||||||
|
!StorageService.to.getBool(storagePrivacyAgreed)) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
return _initializing ??= _init().whenComplete(() => _initializing = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _init() async {
|
||||||
|
await DeviceHeaderService.to.ensureInitialized();
|
||||||
|
// Keep the carrier-login launch path independent of UMeng Common's
|
||||||
|
// network initialization. Waiting for this future is what left the login
|
||||||
|
// overlay spinning forever in the base project; U-Verify is configured
|
||||||
|
// immediately below by initOneKeyLoginIfAllowed.
|
||||||
|
unawaited(
|
||||||
|
UmengCommonSdk.initCommon(
|
||||||
|
UmengConfig.androidAppKey,
|
||||||
|
UmengConfig.iosAppKey,
|
||||||
|
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'default',
|
||||||
|
UmengConfig.androidPushSecret,
|
||||||
|
).catchError((Object _) {}),
|
||||||
|
);
|
||||||
|
_ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 研听最新合规策略:禁止启动、隐私同意、推送注册等路径预取运营商认证。
|
||||||
|
/// 该方法仅允许在用户明确点按登录后由 [OneKeyLoginService] 调用。
|
||||||
|
static Future<void> initOneKeyLoginIfAllowed() async {
|
||||||
|
if (_oneKeyReady || kIsWeb) return;
|
||||||
|
if (Platform.isAndroid &&
|
||||||
|
!StorageService.to.getBool(storagePrivacyAgreed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await initIfAllowed();
|
||||||
|
return _oneKeyInitializing ??= _initOneKey().whenComplete(
|
||||||
|
() => _oneKeyInitializing = null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _initOneKey() async {
|
||||||
|
if (Platform.isAndroid) UmengVerifySdk.register_android();
|
||||||
|
await UmengVerifySdk.setVerifySDKInfo(
|
||||||
|
UmengConfig.androidLoginSecret,
|
||||||
|
UmengConfig.iosLoginSecret,
|
||||||
|
);
|
||||||
|
_oneKeyReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> requestTrackingAuthorizationIfNeeded() async {
|
||||||
|
if (kIsWeb || !Platform.isIOS) return;
|
||||||
|
try {
|
||||||
|
final status =
|
||||||
|
await AppTrackingTransparency.requestTrackingAuthorization();
|
||||||
|
if (status != TrackingStatus.authorized) return;
|
||||||
|
final idfa = await AppTrackingTransparency.getAdvertisingIdentifier();
|
||||||
|
await DeviceHeaderService.to.saveIosIdfa(idfa);
|
||||||
|
} catch (_) {
|
||||||
|
// ATT 拒绝/不可用不能阻断启动。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:android_intent_plus/android_intent.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
typedef AndroidIntentLauncher = Future<void> Function(AndroidIntent intent);
|
||||||
|
|
||||||
|
abstract final class AppLinkUtil {
|
||||||
|
static final _androidPackageNamePattern = RegExp(
|
||||||
|
r'^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$',
|
||||||
|
);
|
||||||
|
|
||||||
|
static String targetAppFromUrl(String urlString) {
|
||||||
|
final value = Uri.tryParse(urlString)?.queryParameters['targetApp']?.trim();
|
||||||
|
if (value == null || !_androidPackageNamePattern.hasMatch(value)) return '';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> openExternal(Uri uri, {String packageName = ''}) =>
|
||||||
|
AppLinkLauncher(
|
||||||
|
isAndroid: () => !kIsWeb && Platform.isAndroid,
|
||||||
|
launchAndroidIntent: (intent) => intent.launch(),
|
||||||
|
).openExternal(uri, packageName: packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppLinkLauncher {
|
||||||
|
const AppLinkLauncher({
|
||||||
|
required this.isAndroid,
|
||||||
|
required this.launchAndroidIntent,
|
||||||
|
this.launchExternal,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool Function() isAndroid;
|
||||||
|
final AndroidIntentLauncher launchAndroidIntent;
|
||||||
|
final Future<bool> Function(Uri uri)? launchExternal;
|
||||||
|
|
||||||
|
Future<bool> openExternal(Uri uri, {String packageName = ''}) async {
|
||||||
|
if (uri.host.isEmpty ||
|
||||||
|
(uri.scheme.toLowerCase() != 'http' &&
|
||||||
|
uri.scheme.toLowerCase() != 'https')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isAndroid() && packageName.trim().isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await launchAndroidIntent(
|
||||||
|
AndroidIntent(
|
||||||
|
action: 'action_view',
|
||||||
|
data: uri.toString(),
|
||||||
|
package: packageName.trim(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
final launcher = launchExternal;
|
||||||
|
if (launcher != null) return launcher(uri);
|
||||||
|
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ class AppNetworkImage extends StatelessWidget {
|
|||||||
this.alignment = Alignment.center,
|
this.alignment = Alignment.center,
|
||||||
this.borderRadius,
|
this.borderRadius,
|
||||||
this.cacheKey,
|
this.cacheKey,
|
||||||
this.httpHeaders,
|
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
this.errorWidget,
|
this.errorWidget,
|
||||||
this.semanticLabel,
|
this.semanticLabel,
|
||||||
@@ -29,7 +28,6 @@ class AppNetworkImage extends StatelessWidget {
|
|||||||
final Alignment alignment;
|
final Alignment alignment;
|
||||||
final BorderRadius? borderRadius;
|
final BorderRadius? borderRadius;
|
||||||
final String? cacheKey;
|
final String? cacheKey;
|
||||||
final Map<String, String>? httpHeaders;
|
|
||||||
final Widget? placeholder;
|
final Widget? placeholder;
|
||||||
final Widget? errorWidget;
|
final Widget? errorWidget;
|
||||||
final String? semanticLabel;
|
final String? semanticLabel;
|
||||||
@@ -41,7 +39,6 @@ class AppNetworkImage extends StatelessWidget {
|
|||||||
: CachedNetworkImage(
|
: CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
cacheKey: cacheKey,
|
cacheKey: cacheKey,
|
||||||
httpHeaders: httpHeaders,
|
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../network/api_error_message.dart';
|
||||||
import 'app_state_card.dart';
|
import 'app_state_card.dart';
|
||||||
|
|
||||||
/// Shared loading, failure and empty-state contract for feature pages.
|
/// Shared loading, failure and empty-state contract for feature pages.
|
||||||
@@ -37,11 +38,11 @@ class AsyncStateView<T> extends StatelessWidget {
|
|||||||
return state.when(
|
return state.when(
|
||||||
loading: () =>
|
loading: () =>
|
||||||
loading ?? const Center(child: CircularProgressIndicator()),
|
loading ?? const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, _) => Center(
|
error: (error, _) => Center(
|
||||||
child: AppStateCard(
|
child: AppStateCard(
|
||||||
icon: Icons.error_outline,
|
icon: Icons.error_outline,
|
||||||
title: errorTitle,
|
title: errorTitle,
|
||||||
subtitle: errorSubtitle,
|
subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle),
|
||||||
buttonLabel: onRetry == null ? null : '重试',
|
buttonLabel: onRetry == null ? null : '重试',
|
||||||
onButtonPressed: onRetry,
|
onButtonPressed: onRetry,
|
||||||
buttonStyle: AppStateButtonStyle.filled,
|
buttonStyle: AppStateButtonStyle.filled,
|
||||||
@@ -60,3 +61,44 @@ class AsyncStateView<T> extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sliver counterpart for CustomScrollView pages without forcing one global
|
||||||
|
/// card layout on successful feature content.
|
||||||
|
class SliverAsyncState<T> extends StatelessWidget {
|
||||||
|
const SliverAsyncState({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
required this.data,
|
||||||
|
this.loading,
|
||||||
|
this.onRetry,
|
||||||
|
this.errorSubtitle = '请检查网络后重试',
|
||||||
|
});
|
||||||
|
|
||||||
|
final AsyncValue<T> state;
|
||||||
|
final Widget Function(T value) data;
|
||||||
|
final Widget? loading;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
final String errorSubtitle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => state.when(
|
||||||
|
data: data,
|
||||||
|
loading: () => SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: loading ?? const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
error: (error, _) => SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: AppStateCard(
|
||||||
|
icon: Icons.error_outline,
|
||||||
|
title: '加载失败',
|
||||||
|
subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle),
|
||||||
|
buttonLabel: onRetry == null ? null : '重试',
|
||||||
|
onButtonPressed: onRetry,
|
||||||
|
buttonStyle: AppStateButtonStyle.filled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
/// A short in-app explanation displayed immediately before the system prompt.
|
||||||
|
class NotificationPermissionTip extends StatelessWidget {
|
||||||
|
const NotificationPermissionTip({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = ShadTheme.of(context).colorScheme;
|
||||||
|
return IgnorePointer(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.foreground,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x33000000),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.notifications_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: colors.background,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'通知权限使用说明',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colors.background,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'用于向你传递重要服务提醒。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.background,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the explanation visible while Android's system permission dialog is
|
||||||
|
/// presented. Call [remove] after the permission request completes.
|
||||||
|
NotificationPermissionTipHandle showNotificationPermissionTip(
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
final overlay = Overlay.of(context, rootOverlay: true);
|
||||||
|
final entry = OverlayEntry(builder: (_) => const NotificationPermissionTip());
|
||||||
|
overlay.insert(entry);
|
||||||
|
return NotificationPermissionTipHandle._(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationPermissionTipHandle {
|
||||||
|
NotificationPermissionTipHandle._(this._entry);
|
||||||
|
|
||||||
|
final OverlayEntry _entry;
|
||||||
|
bool _isRemoved = false;
|
||||||
|
|
||||||
|
void remove() {
|
||||||
|
if (_isRemoved) return;
|
||||||
|
_isRemoved = true;
|
||||||
|
_entry.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import '../../mall/application/mall_tab_config.dart';
|
||||||
|
import '../data/ads_api_client.dart';
|
||||||
|
import 'splash_ad_controller.dart';
|
||||||
|
|
||||||
|
final adConfigForegroundRefresherProvider = Provider(
|
||||||
|
(ref) => AdConfigForegroundRefresher(
|
||||||
|
() => ref.invalidate(featuredAdsProvider(homeBannerAdPlacement)),
|
||||||
|
() => ref.read(splashAdControllerProvider).refresh(),
|
||||||
|
() => ref.read(mallTabConfigProvider.notifier).refresh(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class AdConfigForegroundRefresher {
|
||||||
|
AdConfigForegroundRefresher(this._banner, this._splash, this._mall);
|
||||||
|
final void Function() _banner;
|
||||||
|
final Future<void> Function() _splash, _mall;
|
||||||
|
Future<void>? _running;
|
||||||
|
Future<void> refresh({bool refreshMallTabConfig = true}) =>
|
||||||
|
_running ??= _refresh(
|
||||||
|
refreshMallTabConfig: refreshMallTabConfig,
|
||||||
|
).whenComplete(() => _running = null);
|
||||||
|
Future<void> _refresh({required bool refreshMallTabConfig}) async {
|
||||||
|
try {
|
||||||
|
_banner();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await _splash();
|
||||||
|
} catch (_) {}
|
||||||
|
if (refreshMallTabConfig) {
|
||||||
|
try {
|
||||||
|
await _mall();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io' show File;
|
||||||
|
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../common/storage/preferences_storage.dart';
|
||||||
|
import '../data/ads_api_client.dart';
|
||||||
|
|
||||||
|
class SplashAdEntry {
|
||||||
|
const SplashAdEntry(this.ad, this.dailyCap, this.assetPath, this.cachedAtMs);
|
||||||
|
final AdCreative ad;
|
||||||
|
final int dailyCap;
|
||||||
|
final String? assetPath;
|
||||||
|
final int cachedAtMs;
|
||||||
|
bool get valid =>
|
||||||
|
ad.isRenderable &&
|
||||||
|
DateTime.now().millisecondsSinceEpoch <
|
||||||
|
cachedAtMs + const Duration(hours: 24).inMilliseconds &&
|
||||||
|
(ad.endAtMs <= 0 || ad.endAtMs > DateTime.now().millisecondsSinceEpoch);
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'ad': ad.toJson(),
|
||||||
|
'dailyCap': dailyCap,
|
||||||
|
'assetPath': assetPath,
|
||||||
|
'cachedAtMs': cachedAtMs,
|
||||||
|
};
|
||||||
|
factory SplashAdEntry.fromJson(Map<String, dynamic> map) => SplashAdEntry(
|
||||||
|
AdCreative.fromJson(Map<String, dynamic>.from(map['ad'] as Map)),
|
||||||
|
(map['dailyCap'] as num?)?.toInt() ?? 0,
|
||||||
|
map['assetPath'] as String?,
|
||||||
|
(map['cachedAtMs'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SplashAdController {
|
||||||
|
SplashAdController(this._api, this._preferences);
|
||||||
|
final AdsApiClient _api;
|
||||||
|
final PreferencesStorage _preferences;
|
||||||
|
static const _entryKey = 'ads.splash.entry';
|
||||||
|
static const _dailyKey = 'ads.splash.daily';
|
||||||
|
Future<SplashAdEntry?> coldLaunchDecision() async {
|
||||||
|
final raw = _preferences.getString(_entryKey);
|
||||||
|
if (raw == null) return null;
|
||||||
|
try {
|
||||||
|
final entry = SplashAdEntry.fromJson(
|
||||||
|
Map<String, dynamic>.from(jsonDecode(raw) as Map),
|
||||||
|
);
|
||||||
|
if (!entry.valid || !_underCap(entry) || !await _ready(entry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
} catch (_) {
|
||||||
|
await clearCache();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _underCap(SplashAdEntry entry) {
|
||||||
|
final value = _preferences.getString(_dailyKey);
|
||||||
|
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||||
|
if (value == null) return true;
|
||||||
|
final map = Map<String, dynamic>.from(jsonDecode(value) as Map);
|
||||||
|
return map['date'] != today ||
|
||||||
|
entry.dailyCap <= 0 ||
|
||||||
|
((map['count'] as num?)?.toInt() ?? 0) < entry.dailyCap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _ready(SplashAdEntry entry) async =>
|
||||||
|
entry.assetPath != null && await File(entry.assetPath!).exists();
|
||||||
|
Future<void> markShown(SplashAdEntry entry) async {
|
||||||
|
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||||
|
final raw = _preferences.getString(_dailyKey);
|
||||||
|
var count = 0;
|
||||||
|
if (raw != null) {
|
||||||
|
final m = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||||
|
if (m['date'] == today) count = (m['count'] as num?)?.toInt() ?? 0;
|
||||||
|
}
|
||||||
|
await _preferences.setString(
|
||||||
|
_dailyKey,
|
||||||
|
jsonEncode({'date': today, 'count': count + 1}),
|
||||||
|
);
|
||||||
|
await _report(entry.ad, 'ad_impression');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
try {
|
||||||
|
final result = await _api.getAdResult(splashAdPlacement);
|
||||||
|
if (!result.enabled || result.ad == null) {
|
||||||
|
await _clearEntry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final file = await DefaultCacheManager().getSingleFile(
|
||||||
|
result.ad!.imageUrl,
|
||||||
|
);
|
||||||
|
if (!await file.exists()) return;
|
||||||
|
await _preferences.setString(
|
||||||
|
_entryKey,
|
||||||
|
jsonEncode(
|
||||||
|
SplashAdEntry(
|
||||||
|
result.ad!,
|
||||||
|
result.dailyCap,
|
||||||
|
file.path,
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
).toJson(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _report(AdCreative ad, String event, {String? clickId}) async {
|
||||||
|
try {
|
||||||
|
await _api.report(ad, event, clickId: clickId);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> skip(SplashAdEntry entry) => _report(entry.ad, 'ad_skip');
|
||||||
|
Future<void> clearCache() async {
|
||||||
|
await _clearEntry();
|
||||||
|
await _preferences.remove(_dailyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearEntry() async {
|
||||||
|
await _preferences.remove(_entryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final splashAdControllerProvider = Provider(
|
||||||
|
(ref) => SplashAdController(
|
||||||
|
ref.watch(adsApiClientProvider),
|
||||||
|
ref.watch(preferencesStorageProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../../../common/router/app_router.dart';
|
||||||
|
import '../data/ads_api_client.dart';
|
||||||
|
import '../presentation/splash_ad_dialog.dart';
|
||||||
|
import 'splash_ad_controller.dart';
|
||||||
|
|
||||||
|
final startupAdResolvedProvider = StateProvider<bool>((_) => false);
|
||||||
|
|
||||||
|
abstract final class StartupAdService {
|
||||||
|
static Future<void>? _running;
|
||||||
|
static Future<void> showIfAvailable(BuildContext context, WidgetRef ref) =>
|
||||||
|
_running ??= _show(context, ref);
|
||||||
|
static Future<void> _show(BuildContext context, WidgetRef ref) async {
|
||||||
|
final controller = ref.read(splashAdControllerProvider);
|
||||||
|
try {
|
||||||
|
final entry = await controller.coldLaunchDecision();
|
||||||
|
if (entry != null && context.mounted) {
|
||||||
|
final result = await showGeneralDialog<SplashAdResult>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
barrierDismissible: false,
|
||||||
|
barrierColor: Colors.black,
|
||||||
|
pageBuilder: (ctx, _, _) => SplashAdDialog(
|
||||||
|
entry: entry,
|
||||||
|
onShown: () => unawaited(controller.markShown(entry)),
|
||||||
|
onClick: () async {
|
||||||
|
final id = ref.read(adsApiClientProvider).newClickId();
|
||||||
|
final uri = entry.ad.clickUri(id);
|
||||||
|
if (uri != null) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(adsApiClientProvider)
|
||||||
|
.report(entry.ad, 'ad_click', clickId: id),
|
||||||
|
);
|
||||||
|
if (entry.ad.openMode == AdOpenMode.inApp) {
|
||||||
|
context.push(
|
||||||
|
'${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == SplashAdResult.skipped) unawaited(controller.skip(entry));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ref.read(startupAdResolvedProvider.notifier).state = true;
|
||||||
|
unawaited(controller.refresh());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:fixnum/fixnum.dart';
|
||||||
|
// ignore: unused_import — compute() is referenced by generated Retrofit code.
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../../common/network/dio_provider.dart';
|
||||||
|
import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||||
|
import '../../../models/pb/jinzhi/v1/ads.pb.dart'
|
||||||
|
show GetAdReply, ListAdsReply, ReportAdEventReply, ReportAdEventRequest;
|
||||||
|
|
||||||
|
part 'ads_api_client.g.dart';
|
||||||
|
|
||||||
|
const splashAdPlacement = 'splash';
|
||||||
|
const homeBannerAdPlacement = 'home_banner';
|
||||||
|
|
||||||
|
enum AdMediaType { image, gif, video, unsupported }
|
||||||
|
|
||||||
|
enum AdOpenMode { external, inApp }
|
||||||
|
|
||||||
|
class AdCreative {
|
||||||
|
const AdCreative({
|
||||||
|
required this.adId,
|
||||||
|
required this.placement,
|
||||||
|
required this.targetApp,
|
||||||
|
required this.title,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.mediaType,
|
||||||
|
required this.h5Url,
|
||||||
|
required this.trackingId,
|
||||||
|
required this.countdownSeconds,
|
||||||
|
required this.endAtMs,
|
||||||
|
required this.trackedUrl,
|
||||||
|
required this.openMode,
|
||||||
|
});
|
||||||
|
final String adId,
|
||||||
|
placement,
|
||||||
|
targetApp,
|
||||||
|
title,
|
||||||
|
imageUrl,
|
||||||
|
h5Url,
|
||||||
|
trackingId,
|
||||||
|
trackedUrl;
|
||||||
|
final AdMediaType mediaType;
|
||||||
|
final int countdownSeconds, endAtMs;
|
||||||
|
final AdOpenMode openMode;
|
||||||
|
Uri? get mediaUri => _http(imageUrl);
|
||||||
|
Uri? get targetUri => _http(trackedUrl) ?? _http(h5Url);
|
||||||
|
bool get isRenderable =>
|
||||||
|
adId.isNotEmpty &&
|
||||||
|
mediaUri != null &&
|
||||||
|
targetUri != null &&
|
||||||
|
mediaType != AdMediaType.unsupported &&
|
||||||
|
(endAtMs <= 0 || endAtMs > DateTime.now().millisecondsSinceEpoch);
|
||||||
|
Uri? clickUri(String clickId) {
|
||||||
|
final uri = targetUri;
|
||||||
|
if (uri == null) return null;
|
||||||
|
return uri.replace(
|
||||||
|
queryParameters: {
|
||||||
|
...uri.queryParameters,
|
||||||
|
'ad_id': adId,
|
||||||
|
'placement': placement,
|
||||||
|
'tracking_id': trackingId,
|
||||||
|
'click_id': clickId,
|
||||||
|
'target_app': targetApp,
|
||||||
|
'hook_type': placement,
|
||||||
|
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'adId': adId,
|
||||||
|
'placement': placement,
|
||||||
|
'targetApp': targetApp,
|
||||||
|
'title': title,
|
||||||
|
'imageUrl': imageUrl,
|
||||||
|
'mediaType': mediaType.index,
|
||||||
|
'h5Url': h5Url,
|
||||||
|
'trackingId': trackingId,
|
||||||
|
'countdownSeconds': countdownSeconds,
|
||||||
|
'endAtMs': endAtMs,
|
||||||
|
'trackedUrl': trackedUrl,
|
||||||
|
'openMode': openMode.index,
|
||||||
|
};
|
||||||
|
factory AdCreative.fromJson(Map<String, dynamic> value) => AdCreative(
|
||||||
|
adId: '${value['adId'] ?? ''}',
|
||||||
|
placement: '${value['placement'] ?? ''}',
|
||||||
|
targetApp: '${value['targetApp'] ?? ''}',
|
||||||
|
title: '${value['title'] ?? ''}',
|
||||||
|
imageUrl: '${value['imageUrl'] ?? ''}',
|
||||||
|
mediaType:
|
||||||
|
AdMediaType.values[(value['mediaType'] as num?)?.toInt().clamp(
|
||||||
|
0,
|
||||||
|
AdMediaType.values.length - 1,
|
||||||
|
) ??
|
||||||
|
AdMediaType.unsupported.index],
|
||||||
|
h5Url: '${value['h5Url'] ?? ''}',
|
||||||
|
trackingId: '${value['trackingId'] ?? ''}',
|
||||||
|
countdownSeconds: (value['countdownSeconds'] as num?)?.toInt() ?? 0,
|
||||||
|
endAtMs: (value['endAtMs'] as num?)?.toInt() ?? 0,
|
||||||
|
trackedUrl: '${value['trackedUrl'] ?? ''}',
|
||||||
|
openMode: (value['openMode'] as num?)?.toInt() == AdOpenMode.inApp.index
|
||||||
|
? AdOpenMode.inApp
|
||||||
|
: AdOpenMode.external,
|
||||||
|
);
|
||||||
|
factory AdCreative.fromProto(pb.AdCreative creative) => AdCreative(
|
||||||
|
adId: creative.adId,
|
||||||
|
placement: creative.placement,
|
||||||
|
targetApp: creative.targetApp,
|
||||||
|
title: creative.title,
|
||||||
|
imageUrl: creative.imageUrl,
|
||||||
|
mediaType: switch (creative.mediaType) {
|
||||||
|
pb.MediaType.MEDIA_TYPE_IMAGE => AdMediaType.image,
|
||||||
|
pb.MediaType.MEDIA_TYPE_GIF => AdMediaType.gif,
|
||||||
|
pb.MediaType.MEDIA_TYPE_VIDEO => AdMediaType.video,
|
||||||
|
_ => AdMediaType.unsupported,
|
||||||
|
},
|
||||||
|
h5Url: creative.h5Url,
|
||||||
|
trackingId: creative.trackingId,
|
||||||
|
countdownSeconds: creative.countdownSeconds,
|
||||||
|
endAtMs: creative.endAtMs.toInt(),
|
||||||
|
trackedUrl: creative.trackedUrl,
|
||||||
|
openMode: creative.openMode == pb.AdOpenMode.AD_OPEN_MODE_IN_APP
|
||||||
|
? AdOpenMode.inApp
|
||||||
|
: AdOpenMode.external,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class GetAdResult {
|
||||||
|
const GetAdResult(this.enabled, this.ad, this.dailyCap);
|
||||||
|
final bool enabled;
|
||||||
|
final AdCreative? ad;
|
||||||
|
final int dailyCap;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeaturedAdsState {
|
||||||
|
const FeaturedAdsState(this.enabled, this.ads, {this.trackingId});
|
||||||
|
final bool enabled;
|
||||||
|
final List<AdCreative> ads;
|
||||||
|
final String? trackingId;
|
||||||
|
bool get hasAds => enabled && ads.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RestApi()
|
||||||
|
abstract class AdsBackendApiClient {
|
||||||
|
factory AdsBackendApiClient(Dio dio, {String? baseUrl}) =
|
||||||
|
_AdsBackendApiClient;
|
||||||
|
|
||||||
|
@GET('/api/v1/ads')
|
||||||
|
Future<pb.GetAdReply> getAd({@Query('placement') required String placement});
|
||||||
|
|
||||||
|
@GET('/api/v1/ads/list')
|
||||||
|
Future<pb.ListAdsReply> listAds({
|
||||||
|
@Query('placement') required String placement,
|
||||||
|
});
|
||||||
|
|
||||||
|
@POST('/api/v1/ads/events')
|
||||||
|
Future<pb.ReportAdEventReply> reportAdEvent(
|
||||||
|
@Body() pb.ReportAdEventRequest body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdsApiClient {
|
||||||
|
AdsApiClient(this._api);
|
||||||
|
final AdsBackendApiClient _api;
|
||||||
|
|
||||||
|
Future<GetAdResult> getAdResult(String placement) async {
|
||||||
|
final reply = await _api.getAd(placement: placement);
|
||||||
|
final ad = reply.hasAd() ? AdCreative.fromProto(reply.ad) : null;
|
||||||
|
return GetAdResult(
|
||||||
|
reply.enabled,
|
||||||
|
ad?.isRenderable == true ? ad : null,
|
||||||
|
reply.dailyCap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FeaturedAdsState> listAds(String placement) async {
|
||||||
|
final reply = await _api.listAds(placement: placement);
|
||||||
|
final ads = reply.ads
|
||||||
|
.map(AdCreative.fromProto)
|
||||||
|
.where((ad) => ad.isRenderable)
|
||||||
|
.toList(growable: false);
|
||||||
|
return FeaturedAdsState(
|
||||||
|
reply.enabled,
|
||||||
|
ads,
|
||||||
|
trackingId: reply.trackingId.isEmpty ? null : reply.trackingId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> report(
|
||||||
|
AdCreative ad,
|
||||||
|
String event, {
|
||||||
|
String? clickId,
|
||||||
|
String? trackingId,
|
||||||
|
}) async {
|
||||||
|
await _api.reportAdEvent(
|
||||||
|
pb.ReportAdEventRequest(
|
||||||
|
eventName: event,
|
||||||
|
placement: ad.placement,
|
||||||
|
adId: ad.adId,
|
||||||
|
trackingId: trackingId ?? ad.trackingId,
|
||||||
|
clickId: clickId ?? '',
|
||||||
|
clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String newClickId() => const Uuid().v4();
|
||||||
|
}
|
||||||
|
|
||||||
|
final adsApiClientProvider = Provider(
|
||||||
|
(ref) => AdsApiClient(AdsBackendApiClient(ref.watch(backendDioProvider))),
|
||||||
|
);
|
||||||
|
final featuredAdsProvider = FutureProvider.autoDispose
|
||||||
|
.family<FeaturedAdsState, String>(
|
||||||
|
(ref, placement) => ref.watch(adsApiClientProvider).listAds(placement),
|
||||||
|
);
|
||||||
|
|
||||||
|
Uri? _http(String raw) {
|
||||||
|
final uri = Uri.tryParse(raw.trim());
|
||||||
|
return uri != null &&
|
||||||
|
uri.host.isNotEmpty &&
|
||||||
|
(uri.scheme == 'https' || uri.scheme == 'http')
|
||||||
|
? uri
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io' show File;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../../../common/widgets/app_network_image.dart';
|
||||||
|
import '../data/ads_api_client.dart';
|
||||||
|
|
||||||
|
/// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。
|
||||||
|
class AdMedia extends StatefulWidget {
|
||||||
|
const AdMedia({
|
||||||
|
super.key,
|
||||||
|
required this.ad,
|
||||||
|
this.assetPath,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
});
|
||||||
|
final AdCreative ad;
|
||||||
|
final String? assetPath;
|
||||||
|
final BoxFit fit;
|
||||||
|
@override
|
||||||
|
State<AdMedia> createState() => _AdMediaState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdMediaState extends State<AdMedia> {
|
||||||
|
VideoPlayerController? player;
|
||||||
|
bool ready = false, failed = false;
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant AdMedia old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.ad.adId != widget.ad.adId || old.assetPath != widget.assetPath) {
|
||||||
|
unawaited(player?.dispose() ?? Future.value());
|
||||||
|
player = null;
|
||||||
|
ready = false;
|
||||||
|
failed = false;
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _load() {
|
||||||
|
if (widget.ad.mediaType != AdMediaType.video) return;
|
||||||
|
final uri = widget.ad.mediaUri;
|
||||||
|
if (uri == null) return;
|
||||||
|
final path = widget.assetPath;
|
||||||
|
final controller = !kIsWeb && path != null && path.isNotEmpty
|
||||||
|
? VideoPlayerController.file(File(path))
|
||||||
|
: VideoPlayerController.networkUrl(uri);
|
||||||
|
player = controller;
|
||||||
|
unawaited(
|
||||||
|
controller
|
||||||
|
.initialize()
|
||||||
|
.then((_) async {
|
||||||
|
await controller.setLooping(true);
|
||||||
|
await controller.setVolume(0);
|
||||||
|
await controller.play();
|
||||||
|
if (mounted) setState(() => ready = true);
|
||||||
|
})
|
||||||
|
.catchError((_) {
|
||||||
|
if (mounted) setState(() => failed = true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
player?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.ad.mediaType != AdMediaType.video) {
|
||||||
|
final path = widget.assetPath;
|
||||||
|
if (!kIsWeb && path != null && path.isNotEmpty) {
|
||||||
|
return Image.file(
|
||||||
|
File(path),
|
||||||
|
fit: widget.fit,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
errorBuilder: (_, _, _) => _error(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return AppNetworkImage(
|
||||||
|
imageUrl: widget.ad.imageUrl,
|
||||||
|
fit: widget.fit,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
errorWidget: _error(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final c = player;
|
||||||
|
if (failed) return _error();
|
||||||
|
if (c == null || !ready) {
|
||||||
|
return const ColoredBox(
|
||||||
|
color: Color(0xFF161616),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FittedBox(
|
||||||
|
fit: widget.fit,
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: SizedBox(
|
||||||
|
width: c.value.size.width,
|
||||||
|
height: c.value.size.height,
|
||||||
|
child: VideoPlayer(c),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _error() => const ColoredBox(
|
||||||
|
color: Color(0xFF161616),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(Icons.broken_image_outlined, color: Colors.white54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../../common/router/app_router.dart';
|
||||||
|
import '../data/ads_api_client.dart';
|
||||||
|
import 'ad_media.dart';
|
||||||
|
|
||||||
|
class HomeAdBanner extends ConsumerStatefulWidget {
|
||||||
|
const HomeAdBanner({super.key});
|
||||||
|
@override
|
||||||
|
ConsumerState<HomeAdBanner> createState() => _HomeAdBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeAdBannerState extends ConsumerState<HomeAdBanner> {
|
||||||
|
final pages = PageController();
|
||||||
|
final impressed = <String>{};
|
||||||
|
Timer? timer;
|
||||||
|
int index = 0;
|
||||||
|
int _length = 0;
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
timer?.cancel();
|
||||||
|
pages.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sync(List<AdCreative> ads) {
|
||||||
|
if (_length == ads.length) return;
|
||||||
|
_length = ads.length;
|
||||||
|
timer?.cancel();
|
||||||
|
if (ads.length > 1) {
|
||||||
|
timer = Timer.periodic(const Duration(seconds: 4), (_) {
|
||||||
|
if (pages.hasClients) {
|
||||||
|
pages.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 320),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _impression(AdCreative ad, String? id) {
|
||||||
|
if (!impressed.add(ad.adId)) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(adsApiClientProvider)
|
||||||
|
.report(ad, 'ad_impression', trackingId: id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _open(AdCreative ad, String? tracking) async {
|
||||||
|
final client = ref.read(adsApiClientProvider);
|
||||||
|
final id = client.newClickId();
|
||||||
|
final uri = ad.clickUri(id);
|
||||||
|
if (uri == null) return;
|
||||||
|
unawaited(client.report(ad, 'ad_click', clickId: id, trackingId: tracking));
|
||||||
|
if (!mounted) return;
|
||||||
|
if (ad.openMode == AdOpenMode.inApp) {
|
||||||
|
context.push(
|
||||||
|
'${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final async = ref.watch(featuredAdsProvider(homeBannerAdPlacement));
|
||||||
|
return async.maybeWhen(
|
||||||
|
data: (value) {
|
||||||
|
if (!value.hasAds) return const SizedBox.shrink();
|
||||||
|
final ads = value.ads;
|
||||||
|
_sync(ads);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_impression(ads[index.clamp(0, ads.length - 1)], value.trackingId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 750 / 200,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: pages,
|
||||||
|
onPageChanged: (page) {
|
||||||
|
setState(() => index = page % ads.length);
|
||||||
|
_impression(ads[index], value.trackingId);
|
||||||
|
},
|
||||||
|
itemBuilder: (_, page) {
|
||||||
|
final ad = ads[page % ads.length];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _open(ad, value.trackingId),
|
||||||
|
child: AdMedia(ad: ad),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ads.length > 1)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: List.generate(
|
||||||
|
ads.length,
|
||||||
|
(i) => Container(
|
||||||
|
width: i == index ? 14 : 6,
|
||||||
|
height: 6,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: i == index
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(context).dividerColor,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
orElse: () => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../application/splash_ad_controller.dart';
|
||||||
|
import 'ad_media.dart';
|
||||||
|
|
||||||
|
enum SplashAdResult { clicked, skipped, completed }
|
||||||
|
|
||||||
|
class SplashAdDialog extends StatefulWidget {
|
||||||
|
const SplashAdDialog({
|
||||||
|
super.key,
|
||||||
|
required this.entry,
|
||||||
|
required this.onClick,
|
||||||
|
required this.onShown,
|
||||||
|
});
|
||||||
|
final SplashAdEntry entry;
|
||||||
|
final Future<void> Function() onClick;
|
||||||
|
final VoidCallback onShown;
|
||||||
|
@override
|
||||||
|
State<SplashAdDialog> createState() => _SplashAdDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SplashAdDialogState extends State<SplashAdDialog> {
|
||||||
|
Timer? timer;
|
||||||
|
late int remain;
|
||||||
|
bool opening = false;
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
remain = widget.entry.ad.countdownSeconds.clamp(1, 5);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
widget.onShown();
|
||||||
|
});
|
||||||
|
timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (remain <= 1) {
|
||||||
|
Navigator.of(context).pop(SplashAdResult.completed);
|
||||||
|
} else if (mounted) {
|
||||||
|
setState(() => remain--);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> open() async {
|
||||||
|
if (opening) return;
|
||||||
|
opening = true;
|
||||||
|
timer?.cancel();
|
||||||
|
await widget.onClick();
|
||||||
|
if (mounted) Navigator.of(context).pop(SplashAdResult.clicked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Material(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: open,
|
||||||
|
child: AdMedia(
|
||||||
|
ad: widget.entry.ad,
|
||||||
|
assetPath: widget.entry.assetPath,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: MediaQuery.paddingOf(context).top + 16,
|
||||||
|
right: 18,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: const ValueKey('splash-skip'),
|
||||||
|
onTap: () => Navigator.of(context).pop(SplashAdResult.skipped),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 7,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'跳过 ${remain > 0 ? remain : 0}s',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../../common/domain/metal_type.dart';
|
import '../../../common/domain/metal_type.dart';
|
||||||
|
import '../../auth/application/auth_controller.dart';
|
||||||
|
import '../../auth/data/auth_models.dart';
|
||||||
import '../../market/data/mock_market_repository.dart';
|
import '../../market/data/mock_market_repository.dart';
|
||||||
import '../data/asset_models.dart';
|
import '../data/asset_models.dart';
|
||||||
import '../data/mock_asset_repository.dart';
|
import '../data/mock_asset_repository.dart';
|
||||||
|
|
||||||
final assetPortfolioControllerProvider =
|
final assetPortfolioControllerProvider =
|
||||||
StateNotifierProvider<AssetPortfolioController, PortfolioSummary>((ref) {
|
StateNotifierProvider<AssetPortfolioController, PortfolioSummary>((ref) {
|
||||||
final holdings = ref.watch(mockAssetRepositoryProvider).loadHoldings();
|
final repository = ref.watch(mockAssetRepositoryProvider);
|
||||||
|
final authState = ref.watch(authProvider).valueOrNull;
|
||||||
|
final accountScope = switch (authState) {
|
||||||
|
LoggedInAuthState(:final user) => MockAssetRepository.accountScope(
|
||||||
|
userId: user.id,
|
||||||
|
phone: user.phone,
|
||||||
|
),
|
||||||
|
_ => MockAssetRepository.guestAccountScope,
|
||||||
|
};
|
||||||
|
final holdings = repository.loadHoldings(accountScope);
|
||||||
final market = ref.watch(mockMarketRepositoryProvider);
|
final market = ref.watch(mockMarketRepositoryProvider);
|
||||||
return AssetPortfolioController(
|
return AssetPortfolioController(
|
||||||
holdings: holdings,
|
holdings: holdings,
|
||||||
|
repository: repository,
|
||||||
|
accountScope: accountScope,
|
||||||
spotPriceFor: (metal) => market.quoteFor(metal).spotPrice,
|
spotPriceFor: (metal) => market.quoteFor(metal).spotPrice,
|
||||||
changeFor: (metal) => market.quoteFor(metal).changeAmount,
|
changeFor: (metal) => market.quoteFor(metal).changeAmount,
|
||||||
);
|
);
|
||||||
@@ -19,10 +32,14 @@ final assetPortfolioControllerProvider =
|
|||||||
class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
||||||
AssetPortfolioController({
|
AssetPortfolioController({
|
||||||
required List<GoldAssetHolding> holdings,
|
required List<GoldAssetHolding> holdings,
|
||||||
|
required MockAssetRepository repository,
|
||||||
|
required String accountScope,
|
||||||
required double Function(MetalType metal) spotPriceFor,
|
required double Function(MetalType metal) spotPriceFor,
|
||||||
required double Function(MetalType metal) changeFor,
|
required double Function(MetalType metal) changeFor,
|
||||||
this.recycleDiscount = 0.97,
|
this.recycleDiscount = 0.97,
|
||||||
}) : _holdings = holdings,
|
}) : _holdings = holdings,
|
||||||
|
_repository = repository,
|
||||||
|
_accountScope = accountScope,
|
||||||
_spotPriceFor = spotPriceFor,
|
_spotPriceFor = spotPriceFor,
|
||||||
_changeFor = changeFor,
|
_changeFor = changeFor,
|
||||||
super(
|
super(
|
||||||
@@ -35,12 +52,25 @@ class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final List<GoldAssetHolding> _holdings;
|
final List<GoldAssetHolding> _holdings;
|
||||||
|
final MockAssetRepository _repository;
|
||||||
|
final String _accountScope;
|
||||||
final double Function(MetalType metal) _spotPriceFor;
|
final double Function(MetalType metal) _spotPriceFor;
|
||||||
final double Function(MetalType metal) _changeFor;
|
final double Function(MetalType metal) _changeFor;
|
||||||
final double recycleDiscount;
|
final double recycleDiscount;
|
||||||
|
|
||||||
void addHolding(GoldAssetHolding holding) {
|
Future<void> addHolding(GoldAssetHolding holding) async {
|
||||||
_holdings.add(holding);
|
_holdings.add(holding);
|
||||||
|
await _repository.saveHoldings(_accountScope, _holdings);
|
||||||
|
state = _buildSummary(
|
||||||
|
holdings: _holdings,
|
||||||
|
spotPriceFor: _spotPriceFor,
|
||||||
|
changeFor: _changeFor,
|
||||||
|
recycleDiscount: recycleDiscount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearHoldings() {
|
||||||
|
_holdings.clear();
|
||||||
state = _buildSummary(
|
state = _buildSummary(
|
||||||
holdings: _holdings,
|
holdings: _holdings,
|
||||||
spotPriceFor: _spotPriceFor,
|
spotPriceFor: _spotPriceFor,
|
||||||
|
|||||||
@@ -51,6 +51,45 @@ class GoldAssetHolding {
|
|||||||
final HoldingStatus status;
|
final HoldingStatus status;
|
||||||
|
|
||||||
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
|
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'metal': metal.name,
|
||||||
|
'category': category.name,
|
||||||
|
'purity': purity,
|
||||||
|
'purityLabel': purityLabel,
|
||||||
|
'weightGram': weightGram,
|
||||||
|
'costAmount': costAmount,
|
||||||
|
'purchaseDate': purchaseDate?.toIso8601String(),
|
||||||
|
'channel': channel,
|
||||||
|
'note': note,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
'status': status.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory GoldAssetHolding.fromJson(Map<String, Object?> json) {
|
||||||
|
return GoldAssetHolding(
|
||||||
|
id: json['id']! as String,
|
||||||
|
name: json['name']! as String,
|
||||||
|
metal: MetalType.values.byName(json['metal']! as String),
|
||||||
|
category: AssetCategory.values.byName(json['category']! as String),
|
||||||
|
purity: (json['purity']! as num).toDouble(),
|
||||||
|
purityLabel: json['purityLabel']! as String,
|
||||||
|
weightGram: (json['weightGram']! as num).toDouble(),
|
||||||
|
costAmount: (json['costAmount'] as num?)?.toDouble(),
|
||||||
|
purchaseDate: switch (json['purchaseDate']) {
|
||||||
|
final String value when value.isNotEmpty => DateTime.parse(value),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
channel: json['channel'] as String?,
|
||||||
|
note: json['note'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt']! as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt']! as String),
|
||||||
|
status: HoldingStatus.values.byName(json['status']! as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HoldingValuation {
|
class HoldingValuation {
|
||||||
|
|||||||
@@ -1,14 +1,91 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../../common/domain/metal_type.dart';
|
import '../../../common/domain/metal_type.dart';
|
||||||
|
import '../../../common/storage/preferences_storage.dart';
|
||||||
|
import '../../../common/storage/storage_keys.dart';
|
||||||
import 'asset_models.dart';
|
import 'asset_models.dart';
|
||||||
|
|
||||||
final mockAssetRepositoryProvider = Provider<MockAssetRepository>(
|
final mockAssetRepositoryProvider = Provider<MockAssetRepository>(
|
||||||
(ref) => MockAssetRepository(),
|
(ref) => MockAssetRepository(ref.watch(preferencesStorageProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
class MockAssetRepository {
|
class MockAssetRepository {
|
||||||
List<GoldAssetHolding> loadHoldings() {
|
MockAssetRepository(this._preferences);
|
||||||
|
|
||||||
|
static const guestAccountScope = 'guest';
|
||||||
|
|
||||||
|
final PreferencesStorage _preferences;
|
||||||
|
Future<void> _operationTail = Future<void>.value();
|
||||||
|
|
||||||
|
static String accountScope({String? userId, String? phone}) {
|
||||||
|
final normalizedUserId = userId?.trim() ?? '';
|
||||||
|
if (normalizedUserId.isNotEmpty) return 'id:$normalizedUserId';
|
||||||
|
final normalizedPhone = phone?.trim() ?? '';
|
||||||
|
if (normalizedPhone.isNotEmpty) return 'phone:$normalizedPhone';
|
||||||
|
return guestAccountScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String holdingsStorageKey(String accountScope) =>
|
||||||
|
'$storageJinzhiHoldings:${Uri.encodeComponent(accountScope)}';
|
||||||
|
|
||||||
|
static String deletedStorageKey(String accountScope) =>
|
||||||
|
'$storageJinzhiHoldingsDeleted:${Uri.encodeComponent(accountScope)}';
|
||||||
|
|
||||||
|
List<GoldAssetHolding> loadHoldings(String accountScope) {
|
||||||
|
if (_preferences.getBool(deletedStorageKey(accountScope))) return [];
|
||||||
|
final encoded = _preferences.getString(holdingsStorageKey(accountScope));
|
||||||
|
if (encoded == null || encoded.isEmpty) return _seedHoldings();
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(encoded);
|
||||||
|
if (decoded is! List) return _seedHoldings();
|
||||||
|
return [
|
||||||
|
for (final item in decoded)
|
||||||
|
GoldAssetHolding.fromJson(Map<String, Object?>.from(item as Map)),
|
||||||
|
];
|
||||||
|
} catch (_) {
|
||||||
|
return _seedHoldings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveHoldings(
|
||||||
|
String accountScope,
|
||||||
|
List<GoldAssetHolding> holdings,
|
||||||
|
) async {
|
||||||
|
final encoded = jsonEncode([
|
||||||
|
for (final holding in holdings) holding.toJson(),
|
||||||
|
]);
|
||||||
|
await _serialize(() async {
|
||||||
|
await _preferences.setString(holdingsStorageKey(accountScope), encoded);
|
||||||
|
await _preferences.remove(deletedStorageKey(accountScope));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAccountHoldings(String accountScope) async {
|
||||||
|
await _serialize(() async {
|
||||||
|
final scopes = {accountScope, guestAccountScope};
|
||||||
|
await Future.wait([
|
||||||
|
for (final scope in scopes) ...[
|
||||||
|
_preferences.remove(holdingsStorageKey(scope)),
|
||||||
|
_preferences.setBool(deletedStorageKey(scope), true),
|
||||||
|
],
|
||||||
|
// Remove the pre-account-scoping key left by earlier builds.
|
||||||
|
_preferences.remove(storageJinzhiHoldings),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _serialize<T>(Future<T> Function() operation) {
|
||||||
|
final result = _operationTail.then((_) => operation());
|
||||||
|
_operationTail = result.then<void>(
|
||||||
|
(_) {},
|
||||||
|
onError: (Object _, StackTrace _) {},
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GoldAssetHolding> _seedHoldings() {
|
||||||
final now = DateTime(2026, 6, 18, 16, 11);
|
final now = DateTime(2026, 6, 18, 16, 11);
|
||||||
return [
|
return [
|
||||||
GoldAssetHolding(
|
GoldAssetHolding(
|
||||||
|
|||||||
@@ -1,16 +1,80 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../../common/domain/money.dart';
|
import '../../../common/domain/money.dart';
|
||||||
|
import '../../../common/push/notification_permission.dart';
|
||||||
|
import '../../../common/push/umeng_push_service.dart';
|
||||||
import '../../../common/widgets/adaptive.dart';
|
import '../../../common/widgets/adaptive.dart';
|
||||||
|
import '../../ads/application/startup_ad_service.dart';
|
||||||
|
import '../../ads/presentation/home_ad_banner.dart';
|
||||||
|
import '../../settings/application/update_controller.dart';
|
||||||
|
import '../../settings/application/update_service.dart';
|
||||||
import '../application/asset_portfolio_controller.dart';
|
import '../application/asset_portfolio_controller.dart';
|
||||||
|
|
||||||
class AssetsPage extends ConsumerWidget {
|
class AssetsPage extends ConsumerStatefulWidget {
|
||||||
const AssetsPage({super.key});
|
const AssetsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<AssetsPage> createState() => _AssetsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetsPageState extends ConsumerState<AssetsPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
unawaited(_completeColdLaunch());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _completeColdLaunch() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await StartupAdService.showIfAvailable(context, ref);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _checkColdLaunchUpgrade();
|
||||||
|
if (mounted) await _requestNotificationPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestNotificationPermission() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||||
|
// 研听的 U-Push register 会请求 APNs;这里再以系统 API 兜底,避免
|
||||||
|
// 旧安装已跳过欢迎页时永远不触发首次通知授权。iOS 不展示顶部说明。
|
||||||
|
await NotificationPermission.requestFromSettings();
|
||||||
|
} else {
|
||||||
|
await NotificationPermission.requestIfNeeded(context);
|
||||||
|
}
|
||||||
|
await UmengPushService.refreshDeviceToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkColdLaunchUpgrade() async {
|
||||||
|
if (!mounted ||
|
||||||
|
!UpdateService.isSupported ||
|
||||||
|
!ref.read(updateSessionGateProvider).takeColdLaunchSlot()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final upgrade = await ref
|
||||||
|
.read(updateControllerProvider.notifier)
|
||||||
|
.checkUpgrade();
|
||||||
|
if (!mounted) return;
|
||||||
|
await UpdateService.showIfNeeded(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
upgrade,
|
||||||
|
source: UpgradeCheckSource.automatic,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Automatic update checks must never block the assets home page.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final summary = ref.watch(assetPortfolioControllerProvider);
|
final summary = ref.watch(assetPortfolioControllerProvider);
|
||||||
final cs = ShadTheme.of(context).colorScheme;
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -71,6 +135,8 @@ class AssetsPage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
const HomeAdBanner(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_Card(
|
_Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -1,68 +1,151 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../../common/network/auth_interceptor.dart';
|
import '../../../common/network/api_exception.dart';
|
||||||
|
import '../../../common/network/dio_provider.dart';
|
||||||
|
import '../../../common/storage/preferences_storage.dart';
|
||||||
import '../../../common/storage/storage_keys.dart';
|
import '../../../common/storage/storage_keys.dart';
|
||||||
import '../../../common/storage/storage_service.dart';
|
import '../data/auth_api.dart';
|
||||||
import '../data/auth_models.dart';
|
import '../data/auth_models.dart';
|
||||||
|
import 'auth_session_coordinator.dart';
|
||||||
|
|
||||||
|
const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in';
|
||||||
|
|
||||||
final authProvider =
|
final authProvider =
|
||||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
StateNotifierProvider<AuthController, AsyncValue<AuthState>>((ref) {
|
||||||
(ref) => AuthController(),
|
return AuthController(
|
||||||
|
ref.read(dioProvider),
|
||||||
|
preferences: ref.read(preferencesStorageProvider),
|
||||||
|
coordinator: ref.read(authSessionCoordinatorProvider),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||||
AuthController() : super(const AsyncLoading()) {
|
AuthController(
|
||||||
_bootstrap();
|
this._dio, {
|
||||||
|
required PreferencesStorage preferences,
|
||||||
|
required AuthSessionCoordinator coordinator,
|
||||||
|
bool autoBootstrap = true,
|
||||||
|
}) : _preferences = preferences,
|
||||||
|
_coordinator = coordinator,
|
||||||
|
super(const AsyncLoading()) {
|
||||||
|
_coordinator.addListener(_onSessionEnded);
|
||||||
|
if (autoBootstrap) _bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
final PreferencesStorage _preferences;
|
||||||
|
final AuthSessionCoordinator _coordinator;
|
||||||
|
|
||||||
|
AuthApi get _api => AuthApi(_dio);
|
||||||
|
|
||||||
|
Future<void> retryBootstrap() => _bootstrap();
|
||||||
|
|
||||||
Future<void> _bootstrap() async {
|
Future<void> _bootstrap() async {
|
||||||
final token = await loadJwt();
|
state = const AsyncLoading();
|
||||||
|
final token = await _coordinator.restore();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
state = const AsyncData(GuestAuthState());
|
state = const AsyncData(GuestAuthState());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final phone = StorageService.to.getString(storageJinzhiMockLoggedIn) ?? '';
|
final cached = _readCachedProfile();
|
||||||
|
if (cached != null) {
|
||||||
state = AsyncData(
|
state = AsyncData(
|
||||||
LoggedInAuthState(
|
LoggedInAuthState(user: cached, token: token, isVerified: false),
|
||||||
user: JinzhiUser(
|
|
||||||
phone: phone.isEmpty ? '13800000000' : phone,
|
|
||||||
nickname: '金值用户',
|
|
||||||
createdAt: DateTime(2026, 6, 18),
|
|
||||||
),
|
|
||||||
token: token,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
await _persistProfile(cached);
|
||||||
|
}
|
||||||
|
await _verifyProfile(token, _coordinator.generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendCode(String phone) async {
|
Future<void> _verifyProfile(String token, int generation) async {
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
try {
|
||||||
|
final user = await _api.getProfile();
|
||||||
|
if (generation != _coordinator.generation) return;
|
||||||
|
await _persistProfile(user);
|
||||||
|
state = AsyncData(
|
||||||
|
LoggedInAuthState(user: user, token: token, isVerified: true),
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
if (generation != _coordinator.generation) return;
|
||||||
|
final exception = ApiException.from(error, stackTrace: stackTrace);
|
||||||
|
if (await _coordinator.expireIfNeeded(exception)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Any non-session error (transport, 403, 429, 5xx, decode) preserves a
|
||||||
|
// stale cached session. With no profile there is nothing safe to render,
|
||||||
|
// but the credential still remains available for retry.
|
||||||
|
if (state.valueOrNull is LoggedInAuthState) return;
|
||||||
|
state = AsyncError(exception, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendCode(String phone) => _api.sendSmsCode(phone);
|
||||||
|
|
||||||
Future<void> verifyCode(String phone, String code) async {
|
Future<void> verifyCode(String phone, String code) async {
|
||||||
final token = 'mock-jinzhi-token-$phone-$code';
|
final reply = await _api.verifySmsCode(phone, code);
|
||||||
await saveJwt(token);
|
await _persistLogin(reply);
|
||||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, phone);
|
}
|
||||||
|
|
||||||
|
Future<void> verifyOneKeyLogin(String loginToken) async {
|
||||||
|
final reply = await _api.verifyOneKeyLogin(loginToken);
|
||||||
|
await _persistLogin(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistLogin(AuthLoginResult reply) async {
|
||||||
|
await _coordinator.startSession(reply.accessToken);
|
||||||
|
await _persistProfile(reply.user);
|
||||||
state = AsyncData(
|
state = AsyncData(
|
||||||
LoggedInAuthState(
|
LoggedInAuthState(user: reply.user, token: reply.accessToken),
|
||||||
user: JinzhiUser(
|
|
||||||
phone: phone,
|
|
||||||
nickname: '金值用户',
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
token: token,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
await clearJwt();
|
await _coordinator.logout();
|
||||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
|
||||||
state = const AsyncData(GuestAuthState());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteAccount([String? code]) async {
|
Future<void> deleteAccount(String code) async {
|
||||||
await logout();
|
final normalized = code.trim();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
throw ArgumentError.value(code, 'code', 'must not be empty');
|
||||||
|
}
|
||||||
|
await _api.deleteAccount(normalized);
|
||||||
|
await _coordinator.accountDeleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistProfile(JinzhiUser user) async {
|
||||||
|
await _preferences.setString(storageUserId, user.id);
|
||||||
|
await _preferences.setString(storageUserPhone, user.phone);
|
||||||
|
await _preferences.setString(storageUserCreatedAt, user.createdAt);
|
||||||
|
await _preferences.remove(_legacyMockLoggedInKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
JinzhiUser? _readCachedProfile() {
|
||||||
|
final id = _preferences.getString(storageUserId)?.trim() ?? '';
|
||||||
|
final phone =
|
||||||
|
(_preferences.getString(storageUserPhone) ??
|
||||||
|
_preferences.getString(_legacyMockLoggedInKey) ??
|
||||||
|
'')
|
||||||
|
.trim();
|
||||||
|
final createdAt =
|
||||||
|
_preferences.getString(storageUserCreatedAt)?.trim() ?? '';
|
||||||
|
if (id.isEmpty && phone.isEmpty) return null;
|
||||||
|
return JinzhiUser(id: id, phone: phone, createdAt: createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSessionEnded(SessionEndReason reason) {
|
||||||
|
state = AsyncData(
|
||||||
|
reason == SessionEndReason.expired
|
||||||
|
? const SessionExpiredAuthState()
|
||||||
|
: const GuestAuthState(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
|
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_coordinator.removeListener(_onSessionEnded);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../common/network/api_exception.dart';
|
||||||
|
import '../../../common/storage/preferences_storage.dart';
|
||||||
|
import '../../../common/storage/secure_identity_storage.dart';
|
||||||
|
import '../../../common/storage/storage_keys.dart';
|
||||||
|
import '../../assets/data/mock_asset_repository.dart';
|
||||||
|
|
||||||
|
enum SessionEndReason { logout, expired, accountDeleted }
|
||||||
|
|
||||||
|
typedef SessionEndListener = void Function(SessionEndReason reason);
|
||||||
|
|
||||||
|
/// Owns session invalidation without depending on Navigator, Router or UI.
|
||||||
|
class AuthSessionCoordinator {
|
||||||
|
AuthSessionCoordinator(
|
||||||
|
this._sessionStorage,
|
||||||
|
this._preferences,
|
||||||
|
this._assetRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
final AuthSessionStorage _sessionStorage;
|
||||||
|
final PreferencesStorage _preferences;
|
||||||
|
final MockAssetRepository _assetRepository;
|
||||||
|
final Set<SessionEndListener> _listeners = {};
|
||||||
|
Future<void>? _ending;
|
||||||
|
SessionEndReason? _endingReason;
|
||||||
|
|
||||||
|
int get generation => _sessionStorage.generation;
|
||||||
|
|
||||||
|
void addListener(SessionEndListener listener) => _listeners.add(listener);
|
||||||
|
void removeListener(SessionEndListener listener) =>
|
||||||
|
_listeners.remove(listener);
|
||||||
|
|
||||||
|
Future<String?> restore() => _sessionStorage.restore();
|
||||||
|
|
||||||
|
Future<void> startSession(String token) async {
|
||||||
|
final ending = _ending;
|
||||||
|
if (ending != null) await ending;
|
||||||
|
await _sessionStorage.save(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> expireIfNeeded(ApiException exception) async {
|
||||||
|
if (!exception.isUnauthorized) return false;
|
||||||
|
final requestGeneration = exception.sessionGeneration;
|
||||||
|
if (requestGeneration != null && requestGeneration != generation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await end(SessionEndReason.expired);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() => end(SessionEndReason.logout);
|
||||||
|
|
||||||
|
Future<void> accountDeleted() => end(SessionEndReason.accountDeleted);
|
||||||
|
|
||||||
|
Future<void> end(SessionEndReason reason) {
|
||||||
|
_endingReason = _strongerReason(_endingReason, reason);
|
||||||
|
final active = _ending;
|
||||||
|
if (active != null) return active;
|
||||||
|
final operation = _drainEnd();
|
||||||
|
late final Future<void> tracked;
|
||||||
|
tracked = operation.whenComplete(() {
|
||||||
|
if (identical(_ending, tracked)) {
|
||||||
|
_ending = null;
|
||||||
|
_endingReason = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_ending = tracked;
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _drainEnd() async {
|
||||||
|
final assetAccountScope = MockAssetRepository.accountScope(
|
||||||
|
userId: _preferences.getString(storageUserId),
|
||||||
|
phone: _preferences.getString(storageUserPhone),
|
||||||
|
);
|
||||||
|
SessionEndReason? completedReason;
|
||||||
|
while (completedReason != _endingReason) {
|
||||||
|
final reason = _endingReason!;
|
||||||
|
await _sessionStorage.clear(
|
||||||
|
accountDeleted: reason == SessionEndReason.accountDeleted,
|
||||||
|
);
|
||||||
|
await Future.wait([
|
||||||
|
_preferences.remove(storageUserId),
|
||||||
|
_preferences.remove(storageUserPhone),
|
||||||
|
_preferences.remove(storageUserCreatedAt),
|
||||||
|
_preferences.remove('jinzhi_mock_logged_in'),
|
||||||
|
if (reason == SessionEndReason.accountDeleted)
|
||||||
|
_assetRepository.deleteAccountHoldings(assetAccountScope),
|
||||||
|
]);
|
||||||
|
completedReason = reason;
|
||||||
|
}
|
||||||
|
final finalReason = _endingReason!;
|
||||||
|
for (final listener in List<SessionEndListener>.of(_listeners)) {
|
||||||
|
listener(finalReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionEndReason _strongerReason(
|
||||||
|
SessionEndReason? current,
|
||||||
|
SessionEndReason next,
|
||||||
|
) {
|
||||||
|
if (current == null) return next;
|
||||||
|
return _reasonPriority(next) > _reasonPriority(current) ? next : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _reasonPriority(SessionEndReason reason) => switch (reason) {
|
||||||
|
SessionEndReason.expired => 0,
|
||||||
|
SessionEndReason.logout => 1,
|
||||||
|
SessionEndReason.accountDeleted => 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
final authSessionCoordinatorProvider = Provider<AuthSessionCoordinator>(
|
||||||
|
(ref) => AuthSessionCoordinator(
|
||||||
|
ref.watch(authSessionStorageProvider),
|
||||||
|
ref.watch(preferencesStorageProvider),
|
||||||
|
ref.watch(mockAssetRepositoryProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../common/router/app_router.dart';
|
||||||
|
import 'auth_controller.dart';
|
||||||
|
import 'one_key_login_service.dart';
|
||||||
|
|
||||||
|
abstract final class LoginLauncher {
|
||||||
|
static final OneKeyLoginService _service = OneKeyLoginService();
|
||||||
|
static bool _starting = false;
|
||||||
|
|
||||||
|
static Future<void> start(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
String? next,
|
||||||
|
}) async {
|
||||||
|
if (_starting) return;
|
||||||
|
_starting = true;
|
||||||
|
|
||||||
|
var loadingVisible = false;
|
||||||
|
|
||||||
|
void showLoading() {
|
||||||
|
if (loadingVisible || !context.mounted) return;
|
||||||
|
loadingVisible = true;
|
||||||
|
unawaited(
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dismissLoading() {
|
||||||
|
if (!loadingVisible || !context.mounted) return;
|
||||||
|
loadingVisible = false;
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final observer = _OneKeyLoginLifecycleObserver(_service);
|
||||||
|
var observerAdded = false;
|
||||||
|
late final OneKeyLoginOutcome outcome;
|
||||||
|
try {
|
||||||
|
WidgetsBinding.instance.addObserver(observer);
|
||||||
|
observerAdded = true;
|
||||||
|
// Carrier-auth initialization only begins after this explicit login
|
||||||
|
// action. Keep the loading indicator visible while the native SDK
|
||||||
|
// determines whether one-key login can be launched.
|
||||||
|
showLoading();
|
||||||
|
outcome = await _service.start(
|
||||||
|
context: context,
|
||||||
|
verifyToken: ref.read(authProvider.notifier).verifyOneKeyLogin,
|
||||||
|
// The native carrier page follows the device, while linked H5 pages
|
||||||
|
// retain the appearance selected inside the app.
|
||||||
|
appIsDark: Theme.of(context).brightness == Brightness.dark,
|
||||||
|
nativeIsDark:
|
||||||
|
MediaQuery.platformBrightnessOf(context) == Brightness.dark,
|
||||||
|
onNativeRequestDispatched: dismissLoading,
|
||||||
|
onAuthPageOpened: dismissLoading,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
dismissLoading();
|
||||||
|
if (observerAdded) WidgetsBinding.instance.removeObserver(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
switch (outcome) {
|
||||||
|
case OneKeyLoginOutcome.success:
|
||||||
|
if (next != null && next.isNotEmpty) GoRouter.of(context).go(next);
|
||||||
|
return;
|
||||||
|
case OneKeyLoginOutcome.cancelled:
|
||||||
|
case OneKeyLoginOutcome.busy:
|
||||||
|
return;
|
||||||
|
case OneKeyLoginOutcome.fallback:
|
||||||
|
case OneKeyLoginOutcome.unsupported:
|
||||||
|
await openSmsLogin(context, next: next);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openSmsLogin(
|
||||||
|
BuildContext context, {
|
||||||
|
String? next,
|
||||||
|
Future<void> Function()? onReturn,
|
||||||
|
}) async {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (GoRouterState.of(context).matchedLocation == AppRoutes.login) return;
|
||||||
|
final location = next == null || next.isEmpty
|
||||||
|
? AppRoutes.login
|
||||||
|
: '${AppRoutes.login}?next=${Uri.encodeComponent(next)}';
|
||||||
|
await GoRouter.of(context).push(location);
|
||||||
|
if (context.mounted) await onReturn?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OneKeyLoginLifecycleObserver with WidgetsBindingObserver {
|
||||||
|
_OneKeyLoginLifecycleObserver(this.service);
|
||||||
|
final OneKeyLoginService service;
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) service.onAppResumed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:umeng_verify_sdk/umeng_verify_sdk.dart';
|
||||||
|
|
||||||
|
import '../../../common/config/app_h5_urls.dart';
|
||||||
|
import '../../../common/tracking/umeng_service.dart';
|
||||||
|
import '../utils/one_key_login_result.dart';
|
||||||
|
|
||||||
|
enum OneKeyLoginOutcome { success, fallback, cancelled, unsupported, busy }
|
||||||
|
|
||||||
|
enum OneKeyLoginPlatform { android, ios, unsupported }
|
||||||
|
|
||||||
|
typedef OneKeyLoginCallback = void Function(Object? result);
|
||||||
|
|
||||||
|
abstract interface class OneKeyLoginGateway {
|
||||||
|
OneKeyLoginPlatform get platform;
|
||||||
|
|
||||||
|
void setTokenCallback(OneKeyLoginCallback? callback);
|
||||||
|
|
||||||
|
void setUiCallback(OneKeyLoginCallback? callback);
|
||||||
|
|
||||||
|
void setWidgetCallback(OneKeyLoginCallback? callback);
|
||||||
|
|
||||||
|
/// 仅在用户点按一键登录后调用,预热运营商授权页。
|
||||||
|
Future<Object?> accelerate();
|
||||||
|
|
||||||
|
Future<void> requestLogin(UMCustomModel uiConfig);
|
||||||
|
|
||||||
|
void closeAuthPage();
|
||||||
|
|
||||||
|
void clearCallbacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
class UmengOneKeyLoginGateway implements OneKeyLoginGateway {
|
||||||
|
const UmengOneKeyLoginGateway();
|
||||||
|
|
||||||
|
@override
|
||||||
|
OneKeyLoginPlatform get platform {
|
||||||
|
if (kIsWeb) return OneKeyLoginPlatform.unsupported;
|
||||||
|
if (Platform.isAndroid) return OneKeyLoginPlatform.android;
|
||||||
|
if (Platform.isIOS) return OneKeyLoginPlatform.ios;
|
||||||
|
return OneKeyLoginPlatform.unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setTokenCallback(OneKeyLoginCallback? callback) {
|
||||||
|
if (platform == OneKeyLoginPlatform.ios) {
|
||||||
|
UmengVerifySdk.getLoginTokenCallback(callback);
|
||||||
|
} else if (platform == OneKeyLoginPlatform.android) {
|
||||||
|
UmengVerifySdk.setTokenResultCallback_android(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setUiCallback(OneKeyLoginCallback? callback) {
|
||||||
|
if (platform == OneKeyLoginPlatform.android) {
|
||||||
|
UmengVerifySdk.setUIClickCallback_android(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setWidgetCallback(OneKeyLoginCallback? callback) {
|
||||||
|
if (platform == OneKeyLoginPlatform.ios) {
|
||||||
|
UmengVerifySdk.getWidgetEventCallback(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Object?> accelerate() {
|
||||||
|
return UmengVerifySdk.accelerateLoginPageWithTimeout(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestLogin(UMCustomModel uiConfig) async {
|
||||||
|
await UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void closeAuthPage() {
|
||||||
|
if (platform == OneKeyLoginPlatform.ios) {
|
||||||
|
unawaited(
|
||||||
|
UmengVerifySdk.cancelLoginVCAnimated(
|
||||||
|
true,
|
||||||
|
).catchError((Object _) => null),
|
||||||
|
);
|
||||||
|
} else if (platform == OneKeyLoginPlatform.android) {
|
||||||
|
UmengVerifySdk.quitLoginPage_android();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void clearCallbacks() {
|
||||||
|
UmengVerifySdk.getLoginTokenCallback(null);
|
||||||
|
UmengVerifySdk.getWidgetEventCallback(null);
|
||||||
|
UmengVerifySdk.setTokenResultCallback_android(null);
|
||||||
|
UmengVerifySdk.setUIClickCallback_android(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OneKeyLoginSdkResult {
|
||||||
|
const OneKeyLoginSdkResult({
|
||||||
|
required this.outcome,
|
||||||
|
this.token,
|
||||||
|
this.code,
|
||||||
|
this.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
final OneKeyLoginOutcome outcome;
|
||||||
|
final String? token;
|
||||||
|
final String? code;
|
||||||
|
final String? reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OneKeyLoginSdkSession {
|
||||||
|
OneKeyLoginSdkSession(
|
||||||
|
this._gateway, {
|
||||||
|
this.onNativeRequestDispatched,
|
||||||
|
this.onAuthPageOpened,
|
||||||
|
});
|
||||||
|
|
||||||
|
final OneKeyLoginGateway _gateway;
|
||||||
|
final VoidCallback? onNativeRequestDispatched;
|
||||||
|
final VoidCallback? onAuthPageOpened;
|
||||||
|
final _result = Completer<OneKeyLoginSdkResult>();
|
||||||
|
var _started = false;
|
||||||
|
var _terminal = false;
|
||||||
|
var _closed = false;
|
||||||
|
var _authPageOpened = false;
|
||||||
|
Timer? _resumeCancellation;
|
||||||
|
|
||||||
|
Future<OneKeyLoginSdkResult> start(UMCustomModel uiConfig) {
|
||||||
|
if (_started) return _result.future;
|
||||||
|
_started = true;
|
||||||
|
|
||||||
|
if (_gateway.platform == OneKeyLoginPlatform.unsupported) {
|
||||||
|
_complete(
|
||||||
|
const OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.unsupported,
|
||||||
|
reason: 'unsupported_platform',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return _result.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预热必须由用户显式点击触发;不在启动或隐私同意阶段调用它。
|
||||||
|
unawaited(_startNativeLogin(uiConfig));
|
||||||
|
return _result.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startNativeLogin(UMCustomModel uiConfig) async {
|
||||||
|
try {
|
||||||
|
// 与 jinzhi-flutter-app 保持一致。未预热时 SDK 会在此处隐式预热,
|
||||||
|
// 在部分 Android 机型上更容易超时或直接回调失败。
|
||||||
|
final preheat = await _gateway.accelerate();
|
||||||
|
if (kDebugMode && preheat is Map && preheat['ret'] != null) {
|
||||||
|
// `ret` 是运营商预热失败码;插件原生层未将它输出到 logcat。
|
||||||
|
// 预热失败不阻断接下来的 getLoginToken,SDK 会自行再试一次。
|
||||||
|
debugPrint(
|
||||||
|
'OneKeyLogin preheat failed: vendor=${preheat['vendor']}, '
|
||||||
|
'ret=${preheat['ret']}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_terminal) return;
|
||||||
|
_gateway.setTokenCallback(_handleTokenResult);
|
||||||
|
_gateway.setWidgetCallback((Object? _) {});
|
||||||
|
_gateway.setUiCallback(_handleUiResult);
|
||||||
|
final nativeRequest = _gateway.requestLogin(uiConfig);
|
||||||
|
onNativeRequestDispatched?.call();
|
||||||
|
await nativeRequest;
|
||||||
|
} catch (_) {
|
||||||
|
final completed = _complete(
|
||||||
|
const OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.unsupported,
|
||||||
|
reason: 'launch_error',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (completed) close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleUiResult(Object? result) {
|
||||||
|
final code = extractUmengLoginCode(result);
|
||||||
|
if (isUmengCancel(result)) {
|
||||||
|
_complete(
|
||||||
|
OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.cancelled,
|
||||||
|
code: code,
|
||||||
|
reason: 'cancel',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isUmengManualSwitch(result)) {
|
||||||
|
_complete(
|
||||||
|
OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.fallback,
|
||||||
|
code: code,
|
||||||
|
reason: 'manual_switch',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTokenResult(Object? result) {
|
||||||
|
final code = extractUmengLoginCode(result);
|
||||||
|
if (kDebugMode && !isUmengTokenSuccess(result)) {
|
||||||
|
final map = result is Map ? result : const <Object?, Object?>{};
|
||||||
|
// 本地插件原先丢弃了 onTokenFailed 的 ret;只记录诊断字段,绝不记录 token。
|
||||||
|
debugPrint(
|
||||||
|
'OneKeyLogin Android/iOS SDK result: code=$code, '
|
||||||
|
'msg=${map['msg']}, vendor=${map['vendorName']}, '
|
||||||
|
'carrierError=${map['carrierFailedResultData']}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// On iOS, 700002 only means the user tapped the authorization page's
|
||||||
|
// login button. It is followed by a separate 600000 (or 600xxx) token
|
||||||
|
// callback, so completing the session here discards that callback.
|
||||||
|
if (_gateway.platform == OneKeyLoginPlatform.ios && code == '700002') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Android `checkEnvAvailable_android` reports 600024 through the same
|
||||||
|
// token callback. It means the terminal supports authentication, not that
|
||||||
|
// this login attempt failed; wait for the actual login callback instead.
|
||||||
|
if (isUmengEnvironmentAvailable(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isUmengAuthPage(result)) {
|
||||||
|
if (_authPageOpened) return;
|
||||||
|
_authPageOpened = true;
|
||||||
|
onAuthPageOpened?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isUmengCancel(result)) {
|
||||||
|
_complete(
|
||||||
|
OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.cancelled,
|
||||||
|
code: code,
|
||||||
|
reason: 'cancel',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isUmengManualSwitch(result)) {
|
||||||
|
_complete(
|
||||||
|
OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.fallback,
|
||||||
|
code: code,
|
||||||
|
reason: 'manual_switch',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isUmengFallbackLoginCode(result)) {
|
||||||
|
_complete(
|
||||||
|
OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.fallback,
|
||||||
|
code: code,
|
||||||
|
reason: 'sdk_fallback',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isUmengTokenSuccess(result) || result is! Map) {
|
||||||
|
_complete(
|
||||||
|
OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.fallback,
|
||||||
|
code: code,
|
||||||
|
reason: 'token_failed',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = result['token']?.toString();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
_complete(
|
||||||
|
OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.fallback,
|
||||||
|
code: code,
|
||||||
|
reason: 'token_empty',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_complete(
|
||||||
|
OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.success,
|
||||||
|
token: token,
|
||||||
|
code: code,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _complete(OneKeyLoginSdkResult result) {
|
||||||
|
if (_terminal) return false;
|
||||||
|
_terminal = true;
|
||||||
|
_resumeCancellation?.cancel();
|
||||||
|
_resumeCancellation = null;
|
||||||
|
_gateway.clearCallbacks();
|
||||||
|
_result.complete(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
_complete(
|
||||||
|
const OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.cancelled,
|
||||||
|
reason: 'disposed',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onAppResumed() {
|
||||||
|
if (_gateway.platform != OneKeyLoginPlatform.android ||
|
||||||
|
!_authPageOpened ||
|
||||||
|
_terminal ||
|
||||||
|
_resumeCancellation != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_resumeCancellation = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
_resumeCancellation = null;
|
||||||
|
final completed = _complete(
|
||||||
|
const OneKeyLoginSdkResult(
|
||||||
|
outcome: OneKeyLoginOutcome.cancelled,
|
||||||
|
reason: 'auth_page_returned',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (completed) close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
if (_closed) return;
|
||||||
|
_closed = true;
|
||||||
|
_gateway.closeAuthPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OneKeyLoginService {
|
||||||
|
OneKeyLoginService({
|
||||||
|
OneKeyLoginGateway gateway = const UmengOneKeyLoginGateway(),
|
||||||
|
Future<void> Function() initializeSdk =
|
||||||
|
UmengService.initOneKeyLoginIfAllowed,
|
||||||
|
}) : _gateway = gateway,
|
||||||
|
_initializeSdk = initializeSdk;
|
||||||
|
|
||||||
|
final OneKeyLoginGateway _gateway;
|
||||||
|
final Future<void> Function() _initializeSdk;
|
||||||
|
OneKeyLoginSdkSession? _activeSession;
|
||||||
|
Future<OneKeyLoginOutcome>? _activeOperation;
|
||||||
|
_OneKeyLoginPhase _phase = _OneKeyLoginPhase.idle;
|
||||||
|
int _generation = 0;
|
||||||
|
|
||||||
|
Future<OneKeyLoginOutcome> start({
|
||||||
|
required BuildContext context,
|
||||||
|
required Future<void> Function(String loginToken) verifyToken,
|
||||||
|
required bool appIsDark,
|
||||||
|
required bool nativeIsDark,
|
||||||
|
VoidCallback? onNativeRequestDispatched,
|
||||||
|
VoidCallback? onAuthPageOpened,
|
||||||
|
}) {
|
||||||
|
if (_phase == _OneKeyLoginPhase.authPageVisible ||
|
||||||
|
_phase == _OneKeyLoginPhase.verifying) {
|
||||||
|
return Future<OneKeyLoginOutcome>.value(OneKeyLoginOutcome.busy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_phase == _OneKeyLoginPhase.launching) {
|
||||||
|
_activeSession?.cancel();
|
||||||
|
_activeSession = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final generation = ++_generation;
|
||||||
|
_phase = _OneKeyLoginPhase.launching;
|
||||||
|
final operation = _start(
|
||||||
|
generation: generation,
|
||||||
|
context: context,
|
||||||
|
verifyToken: verifyToken,
|
||||||
|
appIsDark: appIsDark,
|
||||||
|
nativeIsDark: nativeIsDark,
|
||||||
|
onNativeRequestDispatched: () {
|
||||||
|
if (generation != _generation) return;
|
||||||
|
onNativeRequestDispatched?.call();
|
||||||
|
},
|
||||||
|
onAuthPageOpened: () {
|
||||||
|
if (generation != _generation) return;
|
||||||
|
_phase = _OneKeyLoginPhase.authPageVisible;
|
||||||
|
onAuthPageOpened?.call();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_activeOperation = operation;
|
||||||
|
return operation.whenComplete(() {
|
||||||
|
if (generation == _generation && identical(_activeOperation, operation)) {
|
||||||
|
_activeOperation = null;
|
||||||
|
_phase = _OneKeyLoginPhase.idle;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<OneKeyLoginOutcome> _start({
|
||||||
|
required int generation,
|
||||||
|
required BuildContext context,
|
||||||
|
required Future<void> Function(String loginToken) verifyToken,
|
||||||
|
required bool appIsDark,
|
||||||
|
required bool nativeIsDark,
|
||||||
|
VoidCallback? onNativeRequestDispatched,
|
||||||
|
VoidCallback? onAuthPageOpened,
|
||||||
|
}) async {
|
||||||
|
if (_gateway.platform == OneKeyLoginPlatform.unsupported) {
|
||||||
|
return OneKeyLoginOutcome.unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _initializeSdk();
|
||||||
|
if (generation != _generation) return OneKeyLoginOutcome.cancelled;
|
||||||
|
if (!context.mounted) return OneKeyLoginOutcome.cancelled;
|
||||||
|
final uiConfig = _buildUiConfig(context, appIsDark, nativeIsDark);
|
||||||
|
final session = OneKeyLoginSdkSession(
|
||||||
|
_gateway,
|
||||||
|
onNativeRequestDispatched: onNativeRequestDispatched,
|
||||||
|
onAuthPageOpened: onAuthPageOpened,
|
||||||
|
);
|
||||||
|
_activeSession = session;
|
||||||
|
try {
|
||||||
|
final sdkResult = await session.start(uiConfig);
|
||||||
|
if (!context.mounted) {
|
||||||
|
session.close();
|
||||||
|
return OneKeyLoginOutcome.cancelled;
|
||||||
|
}
|
||||||
|
if (sdkResult.outcome != OneKeyLoginOutcome.success) {
|
||||||
|
session.close();
|
||||||
|
return sdkResult.outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = sdkResult.token;
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
session.close();
|
||||||
|
return OneKeyLoginOutcome.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation == _generation) {
|
||||||
|
_phase = _OneKeyLoginPhase.verifying;
|
||||||
|
}
|
||||||
|
await verifyToken(token);
|
||||||
|
session.close();
|
||||||
|
return OneKeyLoginOutcome.success;
|
||||||
|
} catch (_) {
|
||||||
|
session.close();
|
||||||
|
return OneKeyLoginOutcome.fallback;
|
||||||
|
} finally {
|
||||||
|
if (identical(_activeSession, session)) {
|
||||||
|
_activeSession = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
_generation++;
|
||||||
|
_activeSession?.cancel();
|
||||||
|
_activeSession = null;
|
||||||
|
_activeOperation = null;
|
||||||
|
_phase = _OneKeyLoginPhase.idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onAppResumed() {
|
||||||
|
_activeSession?.onAppResumed();
|
||||||
|
}
|
||||||
|
|
||||||
|
UMCustomModel _buildUiConfig(
|
||||||
|
BuildContext context,
|
||||||
|
bool appIsDark,
|
||||||
|
bool nativeIsDark,
|
||||||
|
) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
// UVerify follows Android's system theme, which can differ from Flutter's
|
||||||
|
// active theme. iOS colors are fixed at launch, so it receives a palette
|
||||||
|
// resolved from the device appearance.
|
||||||
|
final useDarkPalette =
|
||||||
|
_gateway.platform == OneKeyLoginPlatform.ios && nativeIsDark;
|
||||||
|
final pageBg = useDarkPalette ? const Color(0xFF0F0F0F) : Colors.white;
|
||||||
|
final titleInk = useDarkPalette
|
||||||
|
? const Color(0xFFF0F0F0)
|
||||||
|
: const Color(0xFF252529);
|
||||||
|
final hintInk = useDarkPalette
|
||||||
|
? const Color(0xFFA1A1AA)
|
||||||
|
: const Color(0xFF8E8E93);
|
||||||
|
const buttonInk = Color(0xFF0F1A00);
|
||||||
|
final transparent = Colors.transparent.toARGB32();
|
||||||
|
final titleColor = titleInk.toARGB32();
|
||||||
|
final hintColor = hintInk.toARGB32();
|
||||||
|
final buttonTextColor = buttonInk.toARGB32();
|
||||||
|
final bgColor = pageBg.toARGB32();
|
||||||
|
final protocolUrl = AppH5UrlsHelper.buildUrlWithNight(
|
||||||
|
AppH5Urls.userProtocolUrl,
|
||||||
|
appIsDark,
|
||||||
|
);
|
||||||
|
final privacyUrl = AppH5UrlsHelper.buildUrlWithNight(
|
||||||
|
AppH5Urls.privacyUrl,
|
||||||
|
appIsDark,
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = UMCustomModel();
|
||||||
|
final titleWidget = UMCustomWidget('title', UMCustomWidgetType.textView)
|
||||||
|
..title = '金值'
|
||||||
|
..titleFont = 28
|
||||||
|
..textAlignment = UMCustomWidgetTextAlignmentType.center
|
||||||
|
..top = 26
|
||||||
|
..width = width.toInt()
|
||||||
|
..height = 54
|
||||||
|
..titleColor = titleColor
|
||||||
|
..isClickEnable = false
|
||||||
|
..rootViewId = UMRootViewId.body;
|
||||||
|
|
||||||
|
model.logoIsHidden = true;
|
||||||
|
model.numberColor = titleColor;
|
||||||
|
model.numberFont = 32;
|
||||||
|
model.sloganText = ['', bgColor, 0];
|
||||||
|
model.navTitle = ['', transparent, 0];
|
||||||
|
model.privacyPreText = '我已阅读并同意';
|
||||||
|
model.privacyOne = ['《隐私政策》', privacyUrl];
|
||||||
|
model.privacyTwo = ['《用户协议》', protocolUrl];
|
||||||
|
model.privacyColors = [hintColor, titleColor];
|
||||||
|
model.changeBtnTitle = ['其他手机号登录', titleColor, 16];
|
||||||
|
model.privacyOperatorPreText = '《';
|
||||||
|
model.privacyOperatorSufText = '》';
|
||||||
|
model.autoHideLoginLoading = false;
|
||||||
|
model.customWidget = [titleWidget.toJsonMap()];
|
||||||
|
|
||||||
|
if (_gateway.platform == OneKeyLoginPlatform.ios) {
|
||||||
|
model.loginBtnText = ['本机号码一键登录', buttonTextColor, 20];
|
||||||
|
model.loginBtnBgImgs_ios = [
|
||||||
|
'jinzhi_onetap_button',
|
||||||
|
'jinzhi_onetap_button',
|
||||||
|
'jinzhi_onetap_button_pressed',
|
||||||
|
];
|
||||||
|
model.backgroundColor_ios = bgColor;
|
||||||
|
model.navColor = bgColor;
|
||||||
|
// Always use our paired assets so both appearances keep the same iOS
|
||||||
|
// chevron geometry and differ only in color.
|
||||||
|
model.navBackImage = useDarkPalette
|
||||||
|
? 'jinzhi_onetap_back_night'
|
||||||
|
: 'jinzhi_onetap_back_day';
|
||||||
|
model.privacyConectTexts = ['和', ''];
|
||||||
|
model.privacyFont = 13;
|
||||||
|
model.checkBoxImages = [
|
||||||
|
'jinzhi_onetap_unchecked',
|
||||||
|
'jinzhi_onetap_checked',
|
||||||
|
];
|
||||||
|
// 原生 SDK 会按该尺寸直接放大图片;36pt 会让协议勾选显得过重。
|
||||||
|
model.checkBoxWH = 22;
|
||||||
|
model.checkBoxIsChecked = true;
|
||||||
|
} else {
|
||||||
|
model.numberFrame = [-1, 156, -1, -1];
|
||||||
|
// Keep the same Android SDK frame as caizhi. The capsule's visible
|
||||||
|
// geometry is determined by the native drawable's intrinsic size.
|
||||||
|
model.loginBtnFrame = [-1, 283, -1, -1, 56];
|
||||||
|
model.changeBtnFrame = [368, -1, -1, -1];
|
||||||
|
model.privacyFrame = [-1, 427, -1, -1];
|
||||||
|
// Android only accepts width/height here. The back drawable itself uses
|
||||||
|
// caizhi's 48×120 transparent canvas to position the arrow correctly.
|
||||||
|
model.navBackButtonFrame = [-1, -1, 72, 120];
|
||||||
|
model.loginBtnText = ['本机号码一键登录', buttonTextColor, 16];
|
||||||
|
model.loginBtnBgImg_android = 'jinzhi_onetap_button';
|
||||||
|
model.navBackImage = 'jinzhi_onetap_back_daynight';
|
||||||
|
model.navColor = transparent;
|
||||||
|
model.privacyFont = 12;
|
||||||
|
model.privacyConectTexts = ['和', '、'];
|
||||||
|
model.checkBoxWH = 14;
|
||||||
|
model.checkBoxIsChecked = false;
|
||||||
|
model.checkBoxImages = [
|
||||||
|
'jinzhi_onetap_unchecked',
|
||||||
|
'jinzhi_onetap_checked',
|
||||||
|
];
|
||||||
|
model.privacyNavColor = bgColor;
|
||||||
|
model.privacyNavTitleColor = titleColor;
|
||||||
|
model.privacyNavTitleFont = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _OneKeyLoginPhase { idle, launching, authPageVisible, verifying }
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../common/network/dio_provider.dart';
|
||||||
|
import '../../../models/pb/jinzhi/v1/account.pb.dart' as pb;
|
||||||
|
import 'auth_models.dart';
|
||||||
|
|
||||||
|
/// 金值账户的 protobuf-over-HTTP 客户端。
|
||||||
|
class AuthApi {
|
||||||
|
const AuthApi(this._dio);
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<int> sendSmsCode(String phone) async {
|
||||||
|
final response = await _dio.post<List<int>>(
|
||||||
|
'/api/v1/auth/sms/send',
|
||||||
|
data: pb.SendSmsCodeRequest(phone: phone).writeToBuffer(),
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
return pb.SendSmsCodeReply.fromBuffer(
|
||||||
|
response.data ?? const <int>[],
|
||||||
|
).expiresIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthLoginResult> verifySmsCode(String phone, String code) async {
|
||||||
|
final response = await _dio.post<List<int>>(
|
||||||
|
'/api/v1/auth/sms/verify',
|
||||||
|
data: pb.VerifySmsCodeRequest(phone: phone, code: code).writeToBuffer(),
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
return AuthLoginResult.fromProto(
|
||||||
|
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthLoginResult> verifyOneKeyLogin(String loginToken) async {
|
||||||
|
final response = await _dio.post<List<int>>(
|
||||||
|
'/api/v1/auth/onekey/verify',
|
||||||
|
data: pb.VerifyOneKeyLoginRequest(loginToken: loginToken).writeToBuffer(),
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
return AuthLoginResult.fromProto(
|
||||||
|
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<JinzhiUser> getProfile() async {
|
||||||
|
final response = await _dio.get<List<int>>(
|
||||||
|
'/api/v1/me',
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
final reply = pb.ProfileReply.fromBuffer(response.data ?? const <int>[]);
|
||||||
|
if (!reply.hasUser()) {
|
||||||
|
throw const FormatException('账号资料响应缺少 user');
|
||||||
|
}
|
||||||
|
return _userFromProto(reply.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAccount(String smsCode) async {
|
||||||
|
await _dio.delete<List<int>>(
|
||||||
|
'/api/v1/me',
|
||||||
|
data: pb.DeleteAccountRequest(smsCode: smsCode).writeToBuffer(),
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthLoginResult {
|
||||||
|
const AuthLoginResult({
|
||||||
|
required this.accessToken,
|
||||||
|
required this.expiresIn,
|
||||||
|
required this.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String accessToken;
|
||||||
|
final int expiresIn;
|
||||||
|
final JinzhiUser user;
|
||||||
|
|
||||||
|
factory AuthLoginResult.fromProto(pb.VerifySmsCodeReply reply) {
|
||||||
|
if (reply.accessToken.trim().isEmpty || !reply.hasUser()) {
|
||||||
|
throw const FormatException('登录响应缺少 access_token 或 user');
|
||||||
|
}
|
||||||
|
return AuthLoginResult(
|
||||||
|
accessToken: reply.accessToken.trim(),
|
||||||
|
expiresIn: reply.expiresIn,
|
||||||
|
user: _userFromProto(reply.user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JinzhiUser _userFromProto(pb.UserInfo user) => JinzhiUser(
|
||||||
|
id: user.id.trim(),
|
||||||
|
phone: user.phone.trim(),
|
||||||
|
createdAt: user.createdAt.trim(),
|
||||||
|
);
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
class JinzhiUser {
|
class JinzhiUser {
|
||||||
const JinzhiUser({
|
const JinzhiUser({
|
||||||
|
required this.id,
|
||||||
required this.phone,
|
required this.phone,
|
||||||
required this.nickname,
|
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
final String phone;
|
final String phone;
|
||||||
final String nickname;
|
final String createdAt;
|
||||||
final DateTime createdAt;
|
|
||||||
|
String get nickname => '金值用户';
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class AuthState {
|
sealed class AuthState {
|
||||||
@@ -18,9 +20,18 @@ class GuestAuthState extends AuthState {
|
|||||||
const GuestAuthState();
|
const GuestAuthState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SessionExpiredAuthState extends AuthState {
|
||||||
|
const SessionExpiredAuthState();
|
||||||
|
}
|
||||||
|
|
||||||
class LoggedInAuthState extends AuthState {
|
class LoggedInAuthState extends AuthState {
|
||||||
const LoggedInAuthState({required this.user, required this.token});
|
const LoggedInAuthState({
|
||||||
|
required this.user,
|
||||||
|
required this.token,
|
||||||
|
this.isVerified = true,
|
||||||
|
});
|
||||||
|
|
||||||
final JinzhiUser user;
|
final JinzhiUser user;
|
||||||
final String token;
|
final String token;
|
||||||
|
final bool isVerified;
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,20 @@ class DeleteAccountPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
error: (e, _) => Scaffold(
|
error: (e, _) => Scaffold(
|
||||||
backgroundColor: cs.background,
|
backgroundColor: cs.background,
|
||||||
body: Center(child: Text('加载失败:$e')),
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('账号状态加载失败'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(authProvider.notifier).retryBootstrap(),
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
data: (authState) {
|
data: (authState) {
|
||||||
if (authState is! LoggedInAuthState) {
|
if (authState is! LoggedInAuthState) {
|
||||||
@@ -669,7 +682,9 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
|||||||
final cs = ShadTheme.of(context).colorScheme;
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
return Padding(
|
return Padding(
|
||||||
// 键盘弹出时 sheet 跟随上移
|
// 键盘弹出时 sheet 跟随上移
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
@@ -715,11 +730,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
|||||||
return [
|
return [
|
||||||
Text(
|
Text(
|
||||||
'正在清除账户数据,请稍候',
|
'正在清除账户数据,请稍候',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||||
fontSize: 13,
|
|
||||||
height: 1.55,
|
|
||||||
color: cs.mutedForeground,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Center(
|
Center(
|
||||||
@@ -737,11 +748,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
|||||||
return [
|
return [
|
||||||
Text(
|
Text(
|
||||||
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
|
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||||
fontSize: 13,
|
|
||||||
height: 1.55,
|
|
||||||
color: cs.mutedForeground,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (_error != null) ...[
|
if (_error != null) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
@@ -768,11 +775,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
|||||||
return [
|
return [
|
||||||
Text(
|
Text(
|
||||||
'验证码已发送至 ${_maskPhone(widget.phone)}',
|
'验证码已发送至 ${_maskPhone(widget.phone)}',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||||
fontSize: 13,
|
|
||||||
height: 1.55,
|
|
||||||
color: cs.mutedForeground,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_PinInput(
|
_PinInput(
|
||||||
|
|||||||
@@ -47,11 +47,19 @@ class LoginPage extends HookConsumerWidget {
|
|||||||
Future<void> returnAfterLogin() async {
|
Future<void> returnAfterLogin() async {
|
||||||
final target = next?.trim();
|
final target = next?.trim();
|
||||||
final router = GoRouter.of(context);
|
final router = GoRouter.of(context);
|
||||||
if (target == null || target.isEmpty) {
|
if (target != null && target.isNotEmpty) {
|
||||||
router.go(AppRoutes.profile);
|
router.go(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.go(target);
|
// The mall opens this page with no `next`. Pop back to its resident
|
||||||
|
// WebView so the auth-state listener can deliver `enterWebMall` after
|
||||||
|
// the app-mall token has been issued. Replacing the route here disposes
|
||||||
|
// that WebView before the asynchronous hand-off can finish.
|
||||||
|
if (context.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
} else {
|
||||||
|
router.go(AppRoutes.profile);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void startCountdown() {
|
void startCountdown() {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const _umengFallbackCodes = {
|
||||||
|
'600002',
|
||||||
|
'600004',
|
||||||
|
'600005',
|
||||||
|
'600007',
|
||||||
|
'600008',
|
||||||
|
'600009',
|
||||||
|
'600010',
|
||||||
|
'600011',
|
||||||
|
'600012',
|
||||||
|
'600013',
|
||||||
|
'600014',
|
||||||
|
'600015',
|
||||||
|
'600017',
|
||||||
|
'600021',
|
||||||
|
'600023',
|
||||||
|
'600025',
|
||||||
|
'600026',
|
||||||
|
};
|
||||||
|
|
||||||
|
String? extractUmengLoginCode(Object? result) {
|
||||||
|
if (result is! Map) return null;
|
||||||
|
final code = result['code'] ?? result['resultCode'];
|
||||||
|
final value = code?.toString();
|
||||||
|
return value == null || value.isEmpty ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isUmengTokenSuccess(Object? result) =>
|
||||||
|
extractUmengLoginCode(result) == '600000';
|
||||||
|
bool isUmengAuthPage(Object? result) =>
|
||||||
|
extractUmengLoginCode(result) == '600001';
|
||||||
|
bool isUmengEnvironmentAvailable(Object? result) =>
|
||||||
|
extractUmengLoginCode(result) == '600024';
|
||||||
|
bool isUmengCancel(Object? result) => extractUmengLoginCode(result) == '700000';
|
||||||
|
bool isUmengManualSwitch(Object? result) =>
|
||||||
|
extractUmengLoginCode(result) == '700001';
|
||||||
|
bool isUmengFallbackLoginCode(Object? result) {
|
||||||
|
final code = extractUmengLoginCode(result);
|
||||||
|
return code != null && _umengFallbackCodes.contains(code);
|
||||||
|
}
|
||||||