Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4343ea528 | ||
|
|
05e89b80bb | ||
|
|
2a88320228 | ||
|
|
b503cb7e6e | ||
|
|
afee4b5aec | ||
|
|
33235f450b | ||
|
|
e834ec7586 | ||
|
|
c5653a3699 | ||
|
|
bd6bb0ffce |
No files matched your search
@@ -45,3 +45,10 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
/android/.kotlin/
|
||||||
|
|
||||||
|
# Release signing material and packaged runtime secrets are restored locally
|
||||||
|
# from the team's secure storage; never commit them.
|
||||||
|
/android/key.properties
|
||||||
|
/android/app/files/*.jks
|
||||||
|
/assets/env/.env
|
||||||
@@ -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()) {
|
||||||
@@ -28,7 +38,7 @@ android {
|
|||||||
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"))
|
||||||
@@ -47,7 +57,9 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.jinzhi.app"
|
// Temporary U-Verify test identity: the Yanting AppKey/login secret
|
||||||
|
// loaded from assets/env/.env is registered against this package.
|
||||||
|
applicationId = "com.yanting.read"
|
||||||
// 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 +67,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,7 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
the Flutter tool needs it to communicate with the running application
|
<!-- Debug-only local API support. Release inherits false from main. -->
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
<application
|
||||||
-->
|
android:usesCleartextTraffic="true"
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
tools:replace="android:usesCleartextTraffic" />
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,15 +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.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="true">
|
android:usesCleartextTraffic="false">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -31,6 +32,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 +48,12 @@
|
|||||||
<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}" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.jinzhi.app
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
package com.jinzhi.app
|
package com.jinzhi.app
|
||||||
|
|
||||||
|
import com.umeng.commonsdk.UMConfigure
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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>
|
||||||
@@ -2,6 +2,10 @@ allprojects {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven("https://maven.aliyun.com/repository/public")
|
||||||
|
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
||||||
|
maven("https://maven.aliyun.com/repository/google")
|
||||||
|
maven("https://maven.aliyun.com/repository/jcenter")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,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>
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
storePassword=jinzhi123
|
|
||||||
keyPassword=jinzhi123
|
|
||||||
keyAlias=jinzhi
|
|
||||||
storeFile=files/jinzhi.jks
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# .env file
|
|
||||||
# UMENG keys for Muzhi environment (replace with actual keys later)
|
|
||||||
UMENG_ANDROID_APP_KEY=6a27b1f46f259537c7b616be
|
|
||||||
UMENG_IOS_APP_KEY=6a27b24e6f259537c7b617a8
|
|
||||||
UMENG_ANDROID_PUSH_SECRET=xxx
|
|
||||||
# UMENG_CHANNEL is usually set via build flavors or defaults, but can be here if needed.
|
|
||||||
@@ -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 = (
|
||||||
);
|
);
|
||||||
@@ -299,14 +300,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
@@ -350,6 +347,23 @@
|
|||||||
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",
|
||||||
|
);
|
||||||
|
name = "[CP] Copy Pods Resources";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
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,6 +487,7 @@
|
|||||||
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)";
|
||||||
@@ -483,7 +498,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||||
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,6 +674,7 @@
|
|||||||
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)";
|
||||||
@@ -669,7 +685,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||||
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,6 +701,7 @@
|
|||||||
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)";
|
||||||
@@ -695,7 +712,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||||
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 |
@@ -1,60 +1,75 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>金值</string>
|
<string>金值</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>金值</string>
|
<string>金值</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<array>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<true/>
|
<array>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<string>jinzhi</string>
|
||||||
<true/>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
</array>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
<string>LaunchScreen</string>
|
|
||||||
<key>UIMainStoryboardFile</key>
|
|
||||||
<string>Main</string>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>UIStatusBarHidden</key>
|
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSUserTrackingUsageDescription</key>
|
||||||
|
<string>用于衡量广告投放效果并提供更相关的内容。</string>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UIStatusBarHidden</key>
|
||||||
|
<true/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>IDEDidComputeMac32BitWarning</key>
|
<key>aps-environment</key>
|
||||||
<true/>
|
<string>$(APS_ENVIRONMENT)</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,18 +1,72 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
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: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/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 '../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;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_pushRegistrar = DevicePushRegistrar(ref.read)..start();
|
||||||
|
UmengPushService.configureNotificationOpen(DeepLinkService.handle);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// 首次 iOS 启动在欢迎页点击进入后才注册 APNs,确保系统弹窗不被启动页
|
||||||
|
// 或其他 Overlay 吞掉;后续启动和 Android 保持原有自动初始化。
|
||||||
|
if (!DeviceHeaderService.to.iosStartupRequired) {
|
||||||
|
unawaited(UmengPushService.startIfAllowed());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
unawaited(_pushRegistrar.stop());
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
unawaited(_pushRegistrar.refresh());
|
||||||
|
unawaited(ref.read(adConfigForegroundRefresherProvider).refresh());
|
||||||
|
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
|
||||||
|
// by registering only while their screen is visible.
|
||||||
|
ref.watch(refreshCoordinatorProvider);
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
final mode = ref.watch(themeModeControllerProvider);
|
final mode = ref.watch(themeModeControllerProvider);
|
||||||
// DM Sans 作主字体(拉丁/数字),中文走系统级联(iOS 苹方 / Android 厂商
|
// DM Sans 作主字体(拉丁/数字),中文走系统级联(iOS 苹方 / Android 厂商
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
|
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/config/shorebird_service.dart';
|
||||||
|
import '../common/config/umeng_config.dart';
|
||||||
|
import '../common/storage/secure_identity_storage.dart';
|
||||||
import '../common/storage/storage_service.dart';
|
import '../common/storage/storage_service.dart';
|
||||||
|
import '../common/widgets/easy_refresh_localization.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
|
||||||
Future<Widget> bootstrap() async {
|
Future<Widget> bootstrap() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
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
|
||||||
@@ -19,9 +29,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,7 @@ 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: 'https://yanting.api.hnsiderui.cn',
|
||||||
);
|
);
|
||||||
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://yanting.h5.hnsiderui.cn',
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
|
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class UmengConfig {
|
|||||||
'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 = 'xxx'; // Default placeholder
|
||||||
|
static String _androidLoginSecret = '';
|
||||||
|
static String _iosLoginSecret = '';
|
||||||
|
|
||||||
static Future<void> load() async {
|
static Future<void> load() async {
|
||||||
await dotenv.load(
|
await dotenv.load(
|
||||||
@@ -17,9 +19,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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
typedef RefreshCallback = FutureOr<void> Function();
|
||||||
|
|
||||||
|
/// Coordinates safe refresh points without owning any feature data.
|
||||||
|
///
|
||||||
|
/// A screen registers its callback while it is visible. Registered callbacks
|
||||||
|
/// run when the app returns to the foreground; callers can also request a
|
||||||
|
/// manual refresh through the returned handle.
|
||||||
|
class RefreshCoordinator with WidgetsBindingObserver {
|
||||||
|
RefreshCoordinator() {
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Set<_RefreshRegistration> _registrations = {};
|
||||||
|
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||||
|
|
||||||
|
bool get isForeground => _lifecycleState == AppLifecycleState.resumed;
|
||||||
|
|
||||||
|
RefreshRegistration register({
|
||||||
|
required RefreshCallback onRefresh,
|
||||||
|
bool active = true,
|
||||||
|
}) {
|
||||||
|
final registration = _RefreshRegistration(onRefresh, active);
|
||||||
|
_registrations.add(registration);
|
||||||
|
return RefreshRegistration._(this, registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
_lifecycleState = state;
|
||||||
|
if (state == AppLifecycleState.resumed) refreshVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshVisible() {
|
||||||
|
for (final registration in _registrations) {
|
||||||
|
if (registration.active) unawaited(_run(registration));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run(_RefreshRegistration registration) async {
|
||||||
|
try {
|
||||||
|
await registration.onRefresh();
|
||||||
|
} catch (_) {
|
||||||
|
// Refresh failures belong to the owning feature's state/UI.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_registrations.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefreshRegistration {
|
||||||
|
RefreshRegistration._(this._coordinator, this._registration);
|
||||||
|
|
||||||
|
final RefreshCoordinator _coordinator;
|
||||||
|
final _RefreshRegistration _registration;
|
||||||
|
|
||||||
|
bool get active => _registration.active;
|
||||||
|
|
||||||
|
set active(bool value) => _registration.active = value;
|
||||||
|
|
||||||
|
void refreshNow() => _coordinator._run(_registration);
|
||||||
|
|
||||||
|
void dispose() => _coordinator._registrations.remove(_registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RefreshRegistration {
|
||||||
|
_RefreshRegistration(this.onRefresh, this.active);
|
||||||
|
|
||||||
|
final RefreshCallback onRefresh;
|
||||||
|
bool active;
|
||||||
|
}
|
||||||
|
|
||||||
|
final refreshCoordinatorProvider = Provider<RefreshCoordinator>((ref) {
|
||||||
|
final coordinator = RefreshCoordinator();
|
||||||
|
ref.onDispose(coordinator.dispose);
|
||||||
|
return coordinator;
|
||||||
|
});
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import '../services/device_header_service.dart';
|
import '../services/device_header_service.dart';
|
||||||
|
import '../storage/secure_identity_storage.dart';
|
||||||
|
import '../storage/storage_service.dart';
|
||||||
|
|
||||||
const _kJwtKey = 'jwt_token';
|
final _defaultIdentityStorage = SecureIdentityStorage(
|
||||||
const _storage = FlutterSecureStorage();
|
StorageService.to.preferences,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> saveJwt(String token) =>
|
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
|
||||||
_storage.write(key: _kJwtKey, value: token);
|
Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
|
||||||
Future<void> clearJwt() => _storage.delete(key: _kJwtKey);
|
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
|
||||||
Future<String?> loadJwt() => _storage.read(key: _kJwtKey);
|
|
||||||
|
|
||||||
class AuthInterceptor extends Interceptor {
|
class AuthInterceptor extends Interceptor {
|
||||||
|
AuthInterceptor(this._identityStorage);
|
||||||
|
|
||||||
|
final SecureIdentityStorage _identityStorage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onRequest(
|
Future<void> onRequest(
|
||||||
RequestOptions options,
|
RequestOptions options,
|
||||||
@@ -19,7 +25,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
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 loadJwt();
|
final jwt = await _identityStorage.readJwt();
|
||||||
if (jwt != null && jwt.isNotEmpty) {
|
if (jwt != null && jwt.isNotEmpty) {
|
||||||
options.headers['Authorization'] = 'Bearer $jwt';
|
options.headers['Authorization'] = 'Bearer $jwt';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
|
import '../storage/secure_identity_storage.dart';
|
||||||
import 'auth_interceptor.dart';
|
import 'auth_interceptor.dart';
|
||||||
import 'kratos_error_interceptor.dart';
|
import 'kratos_error_interceptor.dart';
|
||||||
|
|
||||||
@@ -12,12 +13,20 @@ part 'dio_provider.g.dart';
|
|||||||
|
|
||||||
const _protobufContentType = 'application/x-protobuf';
|
const _protobufContentType = 'application/x-protobuf';
|
||||||
|
|
||||||
|
/// Per-request override for native protobuf-over-HTTP endpoints.
|
||||||
|
Options protobufOptions() => Options(
|
||||||
|
responseType: ResponseType.bytes,
|
||||||
|
contentType: _protobufContentType,
|
||||||
|
headers: const {'Accept': _protobufContentType},
|
||||||
|
);
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
|
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 d = Dio(
|
final d = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: config.apiBaseUrl,
|
baseUrl: config.apiBaseUrl,
|
||||||
@@ -31,7 +40,7 @@ Dio dio(Ref ref) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
d.interceptors.add(AuthInterceptor());
|
d.interceptors.add(AuthInterceptor(identityStorage));
|
||||||
d.interceptors.add(KratosErrorInterceptor());
|
d.interceptors.add(KratosErrorInterceptor());
|
||||||
if (!kReleaseMode) {
|
if (!kReleaseMode) {
|
||||||
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Android requires explicit privacy consent before collecting device
|
||||||
|
/// identifiers. iOS follows its existing startup flow instead.
|
||||||
|
bool requiresAndroidPrivacyConsent({
|
||||||
|
required bool isWeb,
|
||||||
|
required TargetPlatform platform,
|
||||||
|
required bool privacyAgreed,
|
||||||
|
}) => !isWeb && platform == TargetPlatform.android && !privacyAgreed;
|
||||||
@@ -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,95 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../network/dio_provider.dart';
|
||||||
|
import 'notification_permission.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;
|
||||||
|
int _attempt = 0;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
_subscription ??= UmengPushService.onDeviceToken.listen(_register);
|
||||||
|
UmengPushService.emitCachedToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async => _subscription?.cancel();
|
||||||
|
Future<void> refresh() => UmengPushService.refreshDeviceToken();
|
||||||
|
void forceRegisterCurrentToken() {
|
||||||
|
_lastToken = null;
|
||||||
|
_lastPermission = null;
|
||||||
|
UmengPushService.emitCachedToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _register(String token) async {
|
||||||
|
final permission = await NotificationPermission.isGranted();
|
||||||
|
if (token == _lastToken && permission == _lastPermission) return;
|
||||||
|
try {
|
||||||
|
final package = await PackageInfo.fromPlatform();
|
||||||
|
await _read(dioProvider).post<List<int>>(
|
||||||
|
'/api/v1/push/devices',
|
||||||
|
data: _PushRegisterRequest(
|
||||||
|
token: token,
|
||||||
|
platform: defaultTargetPlatform == TargetPlatform.iOS ? 1 : 2,
|
||||||
|
version: package.version,
|
||||||
|
permission: permission,
|
||||||
|
).toBytes(),
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
_lastToken = token;
|
||||||
|
_lastPermission = permission;
|
||||||
|
_attempt = 0;
|
||||||
|
} catch (_) {
|
||||||
|
if (_attempt++ >= 3) return;
|
||||||
|
await Future<void>.delayed(Duration(seconds: 1 << (_attempt - 1)));
|
||||||
|
await _register(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PushRegisterRequest {
|
||||||
|
const _PushRegisterRequest({
|
||||||
|
required this.token,
|
||||||
|
required this.platform,
|
||||||
|
required this.version,
|
||||||
|
required this.permission,
|
||||||
|
});
|
||||||
|
final String token;
|
||||||
|
final int platform;
|
||||||
|
final String version;
|
||||||
|
final bool permission;
|
||||||
|
Uint8List toBytes() {
|
||||||
|
final bytes = BytesBuilder(copy: false);
|
||||||
|
void putInt(int value) {
|
||||||
|
while (value > 0x7f) {
|
||||||
|
bytes.addByte((value & 0x7f) | 0x80);
|
||||||
|
value >>= 7;
|
||||||
|
}
|
||||||
|
bytes.addByte(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void putString(int tag, String value) {
|
||||||
|
final raw = value.codeUnits;
|
||||||
|
putInt((tag << 3) | 2);
|
||||||
|
putInt(raw.length);
|
||||||
|
bytes.add(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
putString(1, token);
|
||||||
|
putInt(2 << 3);
|
||||||
|
putInt(platform);
|
||||||
|
putString(3, version);
|
||||||
|
putInt(4 << 3);
|
||||||
|
putInt(permission ? 1 : 0);
|
||||||
|
return bytes.toBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,86 @@
|
|||||||
|
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 final _tokens = StreamController<String>.broadcast();
|
||||||
|
static Future<void>? _starting;
|
||||||
|
static bool _started = false;
|
||||||
|
static void Function(String payload)? _onNotificationOpen;
|
||||||
|
static String? _pendingNotificationOpen;
|
||||||
|
|
||||||
|
static Stream<String> get onDeviceToken => _tokens.stream;
|
||||||
|
|
||||||
|
static void configureNotificationOpen(
|
||||||
|
void Function(String payload) callback,
|
||||||
|
) {
|
||||||
|
_onNotificationOpen = callback;
|
||||||
|
final pending = _pendingNotificationOpen;
|
||||||
|
if (pending != null) {
|
||||||
|
_pendingNotificationOpen = null;
|
||||||
|
callback(pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
_pendingNotificationOpen = 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;
|
||||||
@@ -54,22 +61,43 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
ShellRoute(
|
StatefulShellRoute.indexedStack(
|
||||||
builder: (context, state, child) =>
|
builder: (context, state, navigationShell) => TabShell(
|
||||||
TabShell(location: state.matchedLocation, child: child),
|
location: state.matchedLocation,
|
||||||
routes: [
|
navigationShell: navigationShell,
|
||||||
GoRoute(
|
),
|
||||||
path: AppRoutes.assets,
|
branches: [
|
||||||
builder: (ctx, s) => const AssetsPage(),
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.assets,
|
||||||
|
builder: (ctx, s) => const AssetsPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
StatefulShellBranch(
|
||||||
path: AppRoutes.market,
|
routes: [
|
||||||
builder: (ctx, s) => const MarketPage(),
|
GoRoute(
|
||||||
|
path: AppRoutes.market,
|
||||||
|
builder: (ctx, s) => const MarketPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(path: AppRoutes.news, builder: (ctx, s) => const NewsPage()),
|
StatefulShellBranch(
|
||||||
GoRoute(
|
routes: [
|
||||||
path: AppRoutes.profile,
|
GoRoute(
|
||||||
builder: (ctx, s) => const ProfilePage(),
|
path: AppRoutes.news,
|
||||||
|
builder: (ctx, s) => const NewsPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profile,
|
||||||
|
builder: (ctx, s) => const ProfilePage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -110,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,43 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'app_router.dart';
|
||||||
|
|
||||||
|
/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。
|
||||||
|
abstract final class DeepLinkService {
|
||||||
|
static void handle(String raw) {
|
||||||
|
final target = _route(raw);
|
||||||
|
if (target != null) rootNavigatorKey.currentContext?.go(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _route(String raw) {
|
||||||
|
var source = raw.trim();
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(source);
|
||||||
|
if (json is Map) {
|
||||||
|
source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
final uri = Uri.tryParse(source);
|
||||||
|
if (uri == null) return null;
|
||||||
|
if (uri.scheme == 'jinzhi') {
|
||||||
|
final path = uri.path.isEmpty ? '/assets' : uri.path;
|
||||||
|
return _allowed(path)
|
||||||
|
? '$path${uri.hasQuery ? '?${uri.query}' : ''}'
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if ((uri.scheme == 'https' || uri.scheme == 'http') && _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);
|
||||||
|
}
|
||||||
@@ -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,31 +1,50 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../features/mall/application/mall_tab_config.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/adaptive.dart';
|
||||||
import 'app_router.dart';
|
import 'app_router.dart';
|
||||||
|
|
||||||
class TabShell extends StatefulWidget {
|
class TabShell extends ConsumerStatefulWidget {
|
||||||
const TabShell({super.key, required this.child, required this.location});
|
const TabShell({
|
||||||
|
super.key,
|
||||||
|
required this.navigationShell,
|
||||||
|
required this.location,
|
||||||
|
});
|
||||||
|
|
||||||
final Widget child;
|
final StatefulNavigationShell navigationShell;
|
||||||
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> {
|
||||||
static const double _compactHeaderThreshold = 34;
|
static const double _compactHeaderThreshold = 34;
|
||||||
|
|
||||||
bool _showCompactHeader = false;
|
bool _showCompactHeader = false;
|
||||||
|
|
||||||
static const _tabs = ['/assets', '/market', '/news', '/profile'];
|
int get _index => widget.navigationShell.currentIndex;
|
||||||
|
|
||||||
int get _index =>
|
@override
|
||||||
_tabs.indexWhere((t) => widget.location.startsWith(t)).clamp(0, 3);
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
ref.listenManual<MallTabConfig>(mallTabConfigProvider, (_, next) {
|
||||||
|
if (!next.isVisible) return;
|
||||||
|
final uri = next.attributedUri(
|
||||||
|
clickId: '',
|
||||||
|
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||||
|
);
|
||||||
|
if (uri != null) MallWebHostController.instance.preload(uri.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant TabShell oldWidget) {
|
void didUpdateWidget(covariant TabShell oldWidget) {
|
||||||
@@ -35,9 +54,28 @@ class _TabShellState extends State<TabShell> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openMall(MallTabConfig config) async {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final actual = await ref
|
||||||
|
.read(mallTabConfigProvider.notifier)
|
||||||
|
.ensureLoaded();
|
||||||
|
if (!actual.isVisible || !mounted) return;
|
||||||
|
final clickId = await ref
|
||||||
|
.read(mallTabConfigProvider.notifier)
|
||||||
|
.recordClick();
|
||||||
|
final uri = actual.attributedUri(clickId: clickId ?? '', isDark: isDark);
|
||||||
|
if (uri == null) return;
|
||||||
|
if (actual.opensInApp) {
|
||||||
|
MallWebHostController.instance.open(uri.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
|
||||||
@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 header = _headerForPath(widget.location);
|
final header = _headerForPath(widget.location);
|
||||||
final canPop = GoRouter.of(context).canPop();
|
final canPop = GoRouter.of(context).canPop();
|
||||||
|
|
||||||
@@ -46,107 +84,144 @@ class _TabShellState extends State<TabShell> {
|
|||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Scaffold(
|
PopScope(
|
||||||
backgroundColor: cs.background,
|
canPop: !MallWebHostController.instance.state.value.visible,
|
||||||
resizeToAvoidBottomInset: false,
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
appBar: AppBar(
|
if (!didPop &&
|
||||||
|
MallWebHostController.instance.state.value.visible) {
|
||||||
|
MallWebHostController.instance.handleBack();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
backgroundColor: cs.background,
|
backgroundColor: cs.background,
|
||||||
surfaceTintColor: Colors.transparent,
|
resizeToAvoidBottomInset: false,
|
||||||
elevation: 0,
|
appBar: AppBar(
|
||||||
scrolledUnderElevation: 0,
|
backgroundColor: cs.background,
|
||||||
centerTitle: true,
|
surfaceTintColor: Colors.transparent,
|
||||||
toolbarHeight: 32,
|
elevation: 0,
|
||||||
leading: canPop
|
scrolledUnderElevation: 0,
|
||||||
? IconButton(
|
centerTitle: true,
|
||||||
onPressed: () => context.pop(),
|
toolbarHeight: 32,
|
||||||
icon: const Icon(Icons.chevron_left, size: 20),
|
leading: canPop
|
||||||
)
|
? IconButton(
|
||||||
: null,
|
onPressed: () => context.pop(),
|
||||||
title: AnimatedOpacity(
|
icon: const Icon(Icons.chevron_left, size: 20),
|
||||||
opacity: _showCompactHeader ? 1 : 0,
|
)
|
||||||
duration: const Duration(milliseconds: 160),
|
: null,
|
||||||
curve: Curves.easeOut,
|
title: AnimatedOpacity(
|
||||||
child: Column(
|
opacity: _showCompactHeader ? 1 : 0,
|
||||||
mainAxisSize: MainAxisSize.min,
|
duration: const Duration(milliseconds: 160),
|
||||||
children: [
|
curve: Curves.easeOut,
|
||||||
Text(
|
child: Column(
|
||||||
header.title,
|
mainAxisSize: MainAxisSize.min,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: cs.foreground,
|
|
||||||
height: 1.1,
|
|
||||||
letterSpacing: 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (header.subtitle != null) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
Text(
|
||||||
header.subtitle!,
|
header.title,
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w700,
|
||||||
color: cs.mutedForeground,
|
color: cs.foreground,
|
||||||
height: 1.1,
|
height: 1.1,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (header.subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
header.subtitle!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: cs.mutedForeground,
|
||||||
|
height: 1.1,
|
||||||
|
letterSpacing: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
body: ColoredBox(
|
||||||
body: ColoredBox(
|
color: cs.background,
|
||||||
color: cs.background,
|
child: NotificationListener<ScrollNotification>(
|
||||||
child: NotificationListener<ScrollNotification>(
|
onNotification: _handleScrollNotification,
|
||||||
onNotification: _handleScrollNotification,
|
child: widget.navigationShell,
|
||||||
child: Stack(children: [Positioned.fill(child: widget.child)]),
|
),
|
||||||
),
|
),
|
||||||
),
|
bottomNavigationBar: SafeArea(
|
||||||
bottomNavigationBar: SafeArea(
|
top: false,
|
||||||
top: false,
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
DecoratedBox(
|
||||||
DecoratedBox(
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: cs.background,
|
||||||
color: cs.background,
|
border: Border(
|
||||||
border: Border(
|
top: BorderSide(color: cs.border, width: 1),
|
||||||
top: BorderSide(color: cs.border, width: 1),
|
),
|
||||||
),
|
),
|
||||||
),
|
child: SizedBox(
|
||||||
child: SizedBox(
|
height: 62,
|
||||||
height: 62,
|
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
||||||
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
child: Center(
|
||||||
child: Center(
|
child: ConstrainedBox(
|
||||||
child: ConstrainedBox(
|
constraints: const BoxConstraints(
|
||||||
constraints: const BoxConstraints(
|
maxWidth: Adaptive.tabsMaxWidth,
|
||||||
maxWidth: Adaptive.tabsMaxWidth,
|
),
|
||||||
),
|
child: Row(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
for (var i = 0; i < _labels.length; i++)
|
||||||
for (var i = 0; i < _tabs.length; i++)
|
Expanded(
|
||||||
Expanded(
|
child: _TabButton(
|
||||||
child: _TabButton(
|
icon: i == _index
|
||||||
icon: i == _index
|
? _filledIcons[i]
|
||||||
? _filledIcons[i]
|
: _outlineIcons[i],
|
||||||
: _outlineIcons[i],
|
label: _labels[i],
|
||||||
label: _labels[i],
|
selected: i == _index,
|
||||||
selected: i == _index,
|
onTap: () =>
|
||||||
onTap: () => context.go(_tabs[i]),
|
widget.navigationShell.goBranch(
|
||||||
|
i,
|
||||||
|
initialLocation: i == _index,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (mall.isVisible)
|
||||||
|
Positioned(
|
||||||
|
bottom: 39,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: _MallDockButton(
|
||||||
|
title: mall.title,
|
||||||
|
badge: mall.badgeText,
|
||||||
|
onTap: () => _openMall(mall),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValueListenableBuilder<MallWebHostState>(
|
||||||
|
valueListenable: MallWebHostController.instance.state,
|
||||||
|
builder: (context, state, _) => Positioned.fill(
|
||||||
|
child: Offstage(
|
||||||
|
offstage: !state.visible,
|
||||||
|
child: MallWebHost(
|
||||||
|
onExit: MallWebHostController.instance.dismiss,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -183,6 +258,72 @@ class _TabShellState extends State<TabShell> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MallDockButton extends StatelessWidget {
|
||||||
|
const _MallDockButton({
|
||||||
|
required this.title,
|
||||||
|
required this.badge,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
final String title;
|
||||||
|
final String badge;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: title,
|
||||||
|
child: Material(
|
||||||
|
color: cs.primary,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.shopping_bag_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: cs.primaryForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.primaryForeground,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (badge.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 5,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.background,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
badge,
|
||||||
|
style: TextStyle(fontSize: 10, color: cs.foreground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TabButton extends StatelessWidget {
|
class _TabButton extends StatelessWidget {
|
||||||
const _TabButton({
|
const _TabButton({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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 '../storage/storage_service.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 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 {
|
||||||
|
await StorageService.to.preferences.remove('ads.splash.entry');
|
||||||
|
await StorageService.to.preferences.remove('ads.splash.daily');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'dart:io' show Platform;
|
|||||||
import 'package:android_id/android_id.dart';
|
import 'package:android_id/android_id.dart';
|
||||||
import 'package:android_oaid/android_oaid.dart';
|
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 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: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';
|
||||||
@@ -12,6 +12,7 @@ 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 '../privacy/privacy_gate.dart';
|
||||||
|
|
||||||
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
||||||
/// AuthInterceptor 每个请求注入 commonHeaders() + platformIdentityHeaders(),
|
/// AuthInterceptor 每个请求注入 commonHeaders() + platformIdentityHeaders(),
|
||||||
@@ -33,6 +34,7 @@ class DeviceHeaderService {
|
|||||||
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 = '';
|
||||||
@@ -52,9 +54,11 @@ class DeviceHeaderService {
|
|||||||
|
|
||||||
// Align with tradingagents-flutter-app: Android only initializes device
|
// Align with tradingagents-flutter-app: Android only initializes device
|
||||||
// information after the user has accepted the privacy policy.
|
// information after the user has accepted the privacy policy.
|
||||||
if (!kIsWeb &&
|
if (requiresAndroidPrivacyConsent(
|
||||||
Platform.isAndroid &&
|
isWeb: kIsWeb,
|
||||||
!StorageService.to.getBool(storagePrivacyAgreed)) {
|
platform: defaultTargetPlatform,
|
||||||
|
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
|
||||||
|
)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +69,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 (_) {}
|
||||||
@@ -182,12 +187,19 @@ 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 =>
|
bool get privacyConsentRequired => requiresAndroidPrivacyConsent(
|
||||||
!kIsWeb &&
|
isWeb: kIsWeb,
|
||||||
Platform.isAndroid &&
|
platform: defaultTargetPlatform,
|
||||||
!StorageService.to.getBool(storagePrivacyAgreed);
|
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
|
||||||
|
);
|
||||||
|
|
||||||
/// iOS 首次启动页:仅在首次安装后展示一次。
|
/// iOS 首次启动页:仅在首次安装后展示一次。
|
||||||
bool get iosStartupRequired =>
|
bool get iosStartupRequired =>
|
||||||
@@ -234,6 +246,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,98 @@
|
|||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'storage_keys.dart';
|
||||||
|
import 'storage_service.dart';
|
||||||
|
|
||||||
|
abstract interface class SecureKeyValueStorage {
|
||||||
|
Future<String?> read(String key);
|
||||||
|
Future<void> write(String key, String value);
|
||||||
|
Future<void> delete(String key);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlutterSecureKeyValueStorage implements SecureKeyValueStorage {
|
||||||
|
const FlutterSecureKeyValueStorage();
|
||||||
|
|
||||||
|
static const _storage = FlutterSecureStorage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) => _storage.read(key: key);
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) =>
|
||||||
|
_storage.write(key: key, value: value);
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) => _storage.delete(key: key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sensitive identity values stay behind one injectable storage boundary.
|
||||||
|
/// Legacy plaintext preferences are migrated only after a secure write works.
|
||||||
|
class SecureIdentityStorage {
|
||||||
|
SecureIdentityStorage(
|
||||||
|
this._preferences, {
|
||||||
|
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
|
||||||
|
}) : _storage = secureStorage;
|
||||||
|
|
||||||
|
static const _accessTokenKey = 'access_token';
|
||||||
|
static const _legacySecureJwtKey = 'jwt_token';
|
||||||
|
|
||||||
|
final SharedPreferences _preferences;
|
||||||
|
final SecureKeyValueStorage _storage;
|
||||||
|
|
||||||
|
Future<String?> restoreAccessToken() async {
|
||||||
|
final current = _normalized(await _storage.read(_accessTokenKey));
|
||||||
|
final legacyPreferences = _normalized(
|
||||||
|
_preferences.getString(storageLegacyAccessToken),
|
||||||
|
);
|
||||||
|
final legacySecure = current == null
|
||||||
|
? _normalized(await _storage.read(_legacySecureJwtKey))
|
||||||
|
: null;
|
||||||
|
final token = current ?? legacyPreferences ?? legacySecure;
|
||||||
|
if (current == null && token != null) {
|
||||||
|
await _storage.write(_accessTokenKey, token);
|
||||||
|
}
|
||||||
|
await _storage.delete(_legacySecureJwtKey);
|
||||||
|
await _preferences.remove(storageLegacyAccessToken);
|
||||||
|
if (token == null) {
|
||||||
|
await _preferences.remove(storageAuthSessionActive);
|
||||||
|
} else {
|
||||||
|
await _preferences.setBool(storageAuthSessionActive, true);
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> readJwt() async {
|
||||||
|
final token = _normalized(await _storage.read(_accessTokenKey));
|
||||||
|
if (token == null || _preferences.containsKey(storageLegacyAccessToken)) {
|
||||||
|
return restoreAccessToken();
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveJwt(String token) async {
|
||||||
|
final normalized = _normalized(token);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw ArgumentError.value(token, 'token', 'must not be empty');
|
||||||
|
}
|
||||||
|
await _storage.write(_accessTokenKey, normalized);
|
||||||
|
await _storage.delete(_legacySecureJwtKey);
|
||||||
|
await _preferences.remove(storageLegacyAccessToken);
|
||||||
|
await _preferences.setBool(storageAuthSessionActive, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearJwt() async {
|
||||||
|
await _storage.delete(_accessTokenKey);
|
||||||
|
await _storage.delete(_legacySecureJwtKey);
|
||||||
|
await _preferences.remove(storageLegacyAccessToken);
|
||||||
|
await _preferences.remove(storageAuthSessionActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
|
||||||
|
(ref) => SecureIdentityStorage(StorageService.to.preferences),
|
||||||
|
);
|
||||||
|
|
||||||
|
String? _normalized(String? value) {
|
||||||
|
final normalized = value?.trim() ?? '';
|
||||||
|
return normalized.isEmpty ? null : normalized;
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ 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 storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
|
||||||
|
const storagePushPermissionPrompted = 'push_permission_prompted';
|
||||||
|
|
||||||
|
// Previous builds could persist credentials in SharedPreferences. These keys
|
||||||
|
// exist only for one-time migration into secure storage.
|
||||||
|
const storageLegacyAccessToken = 'access_token';
|
||||||
|
const storageAuthSessionActive = 'auth_session_active';
|
||||||
|
|
||||||
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
||||||
// header;未写入则为空、注入时被过滤。
|
// header;未写入则为空、注入时被过滤。
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class StorageService {
|
|||||||
static StorageService? _instance;
|
static StorageService? _instance;
|
||||||
static StorageService get to => _instance!;
|
static StorageService get to => _instance!;
|
||||||
|
|
||||||
|
SharedPreferences get preferences => _prefs;
|
||||||
|
|
||||||
static Future<StorageService> get instance async {
|
static Future<StorageService> get instance async {
|
||||||
if (_instance != null) return _instance!;
|
if (_instance != null) return _instance!;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -17,4 +19,6 @@ class StorageService {
|
|||||||
Future<void> setBool(String key, bool v) => _prefs.setBool(key, v);
|
Future<void> setBool(String key, bool v) => _prefs.setBool(key, v);
|
||||||
String? getString(String key) => _prefs.getString(key);
|
String? getString(String key) => _prefs.getString(key);
|
||||||
Future<void> setString(String key, String v) => _prefs.setString(key, v);
|
Future<void> setString(String key, String v) => _prefs.setString(key, v);
|
||||||
|
bool containsKey(String key) => _prefs.containsKey(key);
|
||||||
|
Future<void> remove(String key) => _prefs.remove(key);
|
||||||
}
|
}
|
||||||
@@ -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, as in Yanting.
|
||||||
|
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,104 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
/// 金值统一的网络图片入口。
|
||||||
|
///
|
||||||
|
/// 图片会自动使用 [CachedNetworkImage] 的磁盘缓存;业务页面不应直接创建
|
||||||
|
/// `Image.network`,以保证加载和失败状态的视觉及缓存策略一致。
|
||||||
|
class AppNetworkImage extends StatelessWidget {
|
||||||
|
const AppNetworkImage({
|
||||||
|
super.key,
|
||||||
|
required this.imageUrl,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.alignment = Alignment.center,
|
||||||
|
this.borderRadius,
|
||||||
|
this.cacheKey,
|
||||||
|
this.httpHeaders,
|
||||||
|
this.placeholder,
|
||||||
|
this.errorWidget,
|
||||||
|
this.semanticLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String imageUrl;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final BoxFit fit;
|
||||||
|
final Alignment alignment;
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
final String? cacheKey;
|
||||||
|
final Map<String, String>? httpHeaders;
|
||||||
|
final Widget? placeholder;
|
||||||
|
final Widget? errorWidget;
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final child = imageUrl.trim().isEmpty
|
||||||
|
? _ImageFallback(errorWidget: errorWidget)
|
||||||
|
: CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
cacheKey: cacheKey,
|
||||||
|
httpHeaders: httpHeaders,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: fit,
|
||||||
|
alignment: alignment,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 150),
|
||||||
|
placeholder: (_, _) =>
|
||||||
|
placeholder ?? const _ImageLoadingPlaceholder(),
|
||||||
|
errorWidget: (_, _, _) => _ImageFallback(errorWidget: errorWidget),
|
||||||
|
);
|
||||||
|
|
||||||
|
final clipped = borderRadius == null
|
||||||
|
? child
|
||||||
|
: ClipRRect(borderRadius: borderRadius!, child: child);
|
||||||
|
if (semanticLabel == null) return clipped;
|
||||||
|
return Semantics(image: true, label: semanticLabel, child: clipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageLoadingPlaceholder extends StatelessWidget {
|
||||||
|
const _ImageLoadingPlaceholder();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = ShadTheme.of(context).colorScheme;
|
||||||
|
return ColoredBox(
|
||||||
|
color: colors.muted,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageFallback extends StatelessWidget {
|
||||||
|
const _ImageFallback({required this.errorWidget});
|
||||||
|
|
||||||
|
final Widget? errorWidget;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (errorWidget != null) return errorWidget!;
|
||||||
|
final colors = ShadTheme.of(context).colorScheme;
|
||||||
|
return ColoredBox(
|
||||||
|
color: colors.muted,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.image_not_supported_outlined,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:easy_refresh/easy_refresh.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
typedef RefreshAction = Future<void> Function();
|
||||||
|
|
||||||
|
/// Shared pull-to-refresh and load-more wrapper for future list features.
|
||||||
|
class AppRefresh extends StatelessWidget {
|
||||||
|
const AppRefresh({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.onRefresh,
|
||||||
|
this.onLoad,
|
||||||
|
this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final RefreshAction? onRefresh;
|
||||||
|
final RefreshAction? onLoad;
|
||||||
|
final EasyRefreshController? controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => EasyRefresh(
|
||||||
|
controller: controller,
|
||||||
|
onRefresh: onRefresh,
|
||||||
|
onLoad: onLoad,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import 'app_state_card.dart';
|
||||||
|
|
||||||
|
/// Shared loading, failure and empty-state contract for feature pages.
|
||||||
|
class AsyncStateView<T> extends StatelessWidget {
|
||||||
|
const AsyncStateView({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
required this.data,
|
||||||
|
this.isEmpty,
|
||||||
|
this.empty,
|
||||||
|
this.loading,
|
||||||
|
this.onRetry,
|
||||||
|
this.errorTitle = '加载失败',
|
||||||
|
this.errorSubtitle = '请检查网络后重试',
|
||||||
|
this.emptyIcon = Icons.inbox_outlined,
|
||||||
|
this.emptyTitle = '暂无内容',
|
||||||
|
this.emptySubtitle = '暂时没有可展示的数据',
|
||||||
|
});
|
||||||
|
|
||||||
|
final AsyncValue<T> state;
|
||||||
|
final Widget Function(T value) data;
|
||||||
|
final bool Function(T value)? isEmpty;
|
||||||
|
final Widget Function()? empty;
|
||||||
|
final Widget? loading;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
final String errorTitle;
|
||||||
|
final String errorSubtitle;
|
||||||
|
final IconData emptyIcon;
|
||||||
|
final String emptyTitle;
|
||||||
|
final String emptySubtitle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return state.when(
|
||||||
|
loading: () =>
|
||||||
|
loading ?? const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, _) => Center(
|
||||||
|
child: AppStateCard(
|
||||||
|
icon: Icons.error_outline,
|
||||||
|
title: errorTitle,
|
||||||
|
subtitle: errorSubtitle,
|
||||||
|
buttonLabel: onRetry == null ? null : '重试',
|
||||||
|
onButtonPressed: onRetry,
|
||||||
|
buttonStyle: AppStateButtonStyle.filled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (value) => isEmpty?.call(value) == true
|
||||||
|
? empty?.call() ??
|
||||||
|
Center(
|
||||||
|
child: AppStateCard(
|
||||||
|
icon: emptyIcon,
|
||||||
|
title: emptyTitle,
|
||||||
|
subtitle: emptySubtitle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: data(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:easy_refresh/easy_refresh.dart';
|
||||||
|
|
||||||
|
/// Applies one Chinese copy set to every pull-to-refresh/list-load control.
|
||||||
|
void configureEasyRefreshChineseLocalization() {
|
||||||
|
EasyRefresh.defaultHeaderBuilder = () => const ClassicHeader(
|
||||||
|
dragText: '下拉刷新',
|
||||||
|
armedText: '松开刷新',
|
||||||
|
readyText: '刷新中...',
|
||||||
|
processingText: '刷新中...',
|
||||||
|
processedText: '刷新成功',
|
||||||
|
noMoreText: '没有更多了',
|
||||||
|
failedText: '刷新失败',
|
||||||
|
messageText: '上次更新于 %T',
|
||||||
|
);
|
||||||
|
EasyRefresh.defaultFooterBuilder = () => const ClassicFooter(
|
||||||
|
dragText: '上拉加载',
|
||||||
|
armedText: '松开加载更多',
|
||||||
|
readyText: '加载中...',
|
||||||
|
processingText: '加载中...',
|
||||||
|
processedText: '加载完成',
|
||||||
|
noMoreText: '没有更多了',
|
||||||
|
failedText: '加载失败',
|
||||||
|
messageText: '上次更新于 %T',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,32 @@
|
|||||||
|
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() =>
|
||||||
|
_running ??= _refresh().whenComplete(() => _running = null);
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
try {
|
||||||
|
_banner();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await _splash();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await _mall();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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/storage_service.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);
|
||||||
|
final AdsApiClient _api;
|
||||||
|
static const _entryKey = 'ads.splash.entry';
|
||||||
|
static const _dailyKey = 'ads.splash.daily';
|
||||||
|
Future<SplashAdEntry?> coldLaunchDecision() async {
|
||||||
|
final raw = StorageService.to.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 = StorageService.to.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 = StorageService.to.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 StorageService.to.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 StorageService.to.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 StorageService.to.preferences.remove(_dailyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearEntry() async {
|
||||||
|
await StorageService.to.preferences.remove(_entryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final splashAdControllerProvider = Provider(
|
||||||
|
(ref) => SplashAdController(ref.watch(adsApiClientProvider)),
|
||||||
|
);
|
||||||
@@ -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,294 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../../common/network/dio_provider.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(ProtoMessage fields) => AdCreative(
|
||||||
|
adId: fields.string(1),
|
||||||
|
placement: fields.string(2),
|
||||||
|
targetApp: fields.string(3),
|
||||||
|
title: fields.string(4),
|
||||||
|
imageUrl: fields.string(6),
|
||||||
|
mediaType: switch (fields.integer(7)) {
|
||||||
|
1 => AdMediaType.image,
|
||||||
|
2 => AdMediaType.gif,
|
||||||
|
3 => AdMediaType.video,
|
||||||
|
_ => AdMediaType.unsupported,
|
||||||
|
},
|
||||||
|
h5Url: fields.string(9),
|
||||||
|
trackingId: fields.string(10),
|
||||||
|
countdownSeconds: fields.integer(11),
|
||||||
|
endAtMs: fields.integer(13),
|
||||||
|
trackedUrl: fields.string(14),
|
||||||
|
openMode: fields.integer(15) == 2 ? 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdsApiClient {
|
||||||
|
AdsApiClient(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
Future<GetAdResult> getAdResult(String placement) async {
|
||||||
|
final response = await _dio.get<List<int>>(
|
||||||
|
'/api/v1/ads',
|
||||||
|
queryParameters: {'placement': placement},
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
final root = ProtoMessage(response.data ?? const []);
|
||||||
|
final creative = root.message(2);
|
||||||
|
final ad = AdCreative.fromProto(creative);
|
||||||
|
return GetAdResult(
|
||||||
|
root.integer(1) != 0,
|
||||||
|
ad.isRenderable ? ad : null,
|
||||||
|
root.integer(3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FeaturedAdsState> listAds(String placement) async {
|
||||||
|
final response = await _dio.get<List<int>>(
|
||||||
|
'/api/v1/ads/list',
|
||||||
|
queryParameters: {'placement': placement},
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
final root = ProtoMessage(response.data ?? const []);
|
||||||
|
final ads = root
|
||||||
|
.messages(2)
|
||||||
|
.map(AdCreative.fromProto)
|
||||||
|
.where((ad) => ad.isRenderable)
|
||||||
|
.toList(growable: false);
|
||||||
|
return FeaturedAdsState(
|
||||||
|
root.integer(1) != 0,
|
||||||
|
ads,
|
||||||
|
trackingId: root.string(3).isEmpty ? null : root.string(3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> report(
|
||||||
|
AdCreative ad,
|
||||||
|
String event, {
|
||||||
|
String? clickId,
|
||||||
|
String? trackingId,
|
||||||
|
}) => _dio.post<List<int>>(
|
||||||
|
'/api/v1/ads/events',
|
||||||
|
data: _Writer()
|
||||||
|
.string(1, event)
|
||||||
|
.string(2, ad.placement)
|
||||||
|
.string(3, ad.adId)
|
||||||
|
.string(4, trackingId ?? ad.trackingId)
|
||||||
|
.string(5, clickId ?? '')
|
||||||
|
.integer(6, DateTime.now().millisecondsSinceEpoch)
|
||||||
|
.toBytes(),
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
String newClickId() => const Uuid().v4();
|
||||||
|
}
|
||||||
|
|
||||||
|
final adsApiClientProvider = Provider(
|
||||||
|
(ref) => AdsApiClient(ref.watch(dioProvider)),
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProtoMessage {
|
||||||
|
ProtoMessage(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> ? String.fromCharCodes(value) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
ProtoMessage message(int tag) {
|
||||||
|
final value = _fields[tag]?.lastOrNull;
|
||||||
|
return ProtoMessage(value is List<int> ? value : const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ProtoMessage> messages(int tag) => (_fields[tag] ?? const [])
|
||||||
|
.whereType<List<int>>()
|
||||||
|
.map(ProtoMessage.new)
|
||||||
|
.toList();
|
||||||
|
Map<int, List<Object>> _read() {
|
||||||
|
final out = <int, List<Object>>{};
|
||||||
|
var i = 0;
|
||||||
|
int v() {
|
||||||
|
var n = 0, s = 0;
|
||||||
|
while (i < bytes.length) {
|
||||||
|
final b = bytes[i++];
|
||||||
|
n |= (b & 127) << s;
|
||||||
|
if (b < 128) return n;
|
||||||
|
s += 7;
|
||||||
|
}
|
||||||
|
throw const FormatException('Invalid protobuf');
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < bytes.length) {
|
||||||
|
final key = v(), tag = key >> 3;
|
||||||
|
Object value;
|
||||||
|
if ((key & 7) == 0) {
|
||||||
|
value = v();
|
||||||
|
} else if ((key & 7) == 2) {
|
||||||
|
final l = v(), end = i + l;
|
||||||
|
if (end > bytes.length) throw const FormatException('Invalid protobuf');
|
||||||
|
value = bytes.sublist(i, end);
|
||||||
|
i = end;
|
||||||
|
} else {
|
||||||
|
throw const FormatException('Unsupported protobuf');
|
||||||
|
}
|
||||||
|
(out[tag] ??= []).add(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on List<Object> {
|
||||||
|
Object? get lastOrNull => isEmpty ? null : last;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Writer {
|
||||||
|
final b = BytesBuilder(copy: false);
|
||||||
|
_Writer string(int t, String value) {
|
||||||
|
final d = value.codeUnits;
|
||||||
|
v((t << 3) | 2);
|
||||||
|
v(d.length);
|
||||||
|
b.add(d);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
_Writer integer(int t, int value) {
|
||||||
|
v(t << 3);
|
||||||
|
v(value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List toBytes() => b.toBytes();
|
||||||
|
void v(int n) {
|
||||||
|
while (n > 127) {
|
||||||
|
b.addByte((n & 127) | 128);
|
||||||
|
n >>= 7;
|
||||||
|
}
|
||||||
|
b.addByte(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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 '../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 Image.network(
|
||||||
|
widget.ad.imageUrl,
|
||||||
|
fit: widget.fit,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
errorBuilder: (_, _, _) => _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,82 @@
|
|||||||
|
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: FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped),
|
||||||
|
child: Text('跳过 ${remain}s'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,20 +1,25 @@
|
|||||||
|
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/auth_interceptor.dart';
|
||||||
import '../../../common/storage/storage_keys.dart';
|
import '../../../common/storage/storage_keys.dart';
|
||||||
import '../../../common/storage/storage_service.dart';
|
import '../../../common/storage/storage_service.dart';
|
||||||
|
import '../../../common/network/dio_provider.dart';
|
||||||
import '../data/auth_models.dart';
|
import '../data/auth_models.dart';
|
||||||
|
import '../data/one_key_login_api.dart';
|
||||||
|
|
||||||
final authProvider =
|
final authProvider =
|
||||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
||||||
(ref) => AuthController(),
|
(ref) => AuthController(ref.read(dioProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||||
AuthController() : super(const AsyncLoading()) {
|
AuthController(this._dio) : super(const AsyncLoading()) {
|
||||||
_bootstrap();
|
_bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
Future<void> _bootstrap() async {
|
Future<void> _bootstrap() async {
|
||||||
final token = await loadJwt();
|
final token = await loadJwt();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
@@ -54,6 +59,24 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test environment reuses Yanting's one-key verification endpoint until
|
||||||
|
/// 金值 has its own account contract.
|
||||||
|
Future<void> verifyOneKeyLogin(String loginToken) async {
|
||||||
|
final reply = await OneKeyLoginApi(_dio).verify(loginToken);
|
||||||
|
await saveJwt(reply.token);
|
||||||
|
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
|
||||||
|
state = AsyncData(
|
||||||
|
LoggedInAuthState(
|
||||||
|
user: JinzhiUser(
|
||||||
|
phone: reply.phone,
|
||||||
|
nickname: '金值用户',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
token: reply.token,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
await clearJwt();
|
await clearJwt();
|
||||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
||||||
|
|||||||
@@ -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,556 @@
|
|||||||
|
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<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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Availability checking already pre-accelerates the carrier page. Start
|
||||||
|
// the token request directly here; the SDK performs its own preflight when
|
||||||
|
// needed and reports its timeout/error through the registered callback.
|
||||||
|
unawaited(_startNativeLogin(uiConfig));
|
||||||
|
return _result.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startNativeLogin(UMCustomModel uiConfig) async {
|
||||||
|
try {
|
||||||
|
_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);
|
||||||
|
// 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,92 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../common/network/dio_provider.dart';
|
||||||
|
|
||||||
|
/// Compatible with Yanting's protobuf `/api/v1/auth/onekey/verify` contract.
|
||||||
|
class OneKeyLoginApi {
|
||||||
|
OneKeyLoginApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<OneKeyLoginReply> verify(String loginToken) async {
|
||||||
|
final bytes = utf8.encode(loginToken);
|
||||||
|
final request = Uint8List.fromList([
|
||||||
|
0x0A,
|
||||||
|
..._varint(bytes.length),
|
||||||
|
...bytes,
|
||||||
|
]);
|
||||||
|
final response = await _dio.post<List<int>>(
|
||||||
|
'/api/v1/auth/onekey/verify',
|
||||||
|
data: request,
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
return _decode(Uint8List.fromList(response.data ?? const []));
|
||||||
|
}
|
||||||
|
|
||||||
|
OneKeyLoginReply _decode(Uint8List data) {
|
||||||
|
final fields = _fields(data);
|
||||||
|
final token = utf8.decode(fields[1] ?? const []);
|
||||||
|
final user = _fields(Uint8List.fromList(fields[3] ?? const []));
|
||||||
|
if (token.isEmpty) throw StateError('一键登录服务未返回登录凭证');
|
||||||
|
return OneKeyLoginReply(
|
||||||
|
token: token,
|
||||||
|
phone: utf8.decode(user[2] ?? const []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<int, List<int>> _fields(Uint8List data) {
|
||||||
|
final result = <int, List<int>>{};
|
||||||
|
var i = 0;
|
||||||
|
while (i < data.length) {
|
||||||
|
final tag = _readVarint(data, i);
|
||||||
|
i = tag.$2;
|
||||||
|
final wireType = tag.$1 & 7;
|
||||||
|
if (wireType == 0) {
|
||||||
|
i = _readVarint(data, i).$2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (wireType != 2) {
|
||||||
|
throw const FormatException('Unsupported protobuf field');
|
||||||
|
}
|
||||||
|
final length = _readVarint(data, i);
|
||||||
|
i = length.$2;
|
||||||
|
final end = i + length.$1;
|
||||||
|
if (end > data.length) {
|
||||||
|
throw const FormatException('Invalid protobuf payload');
|
||||||
|
}
|
||||||
|
result[tag.$1 >> 3] = data.sublist(i, end);
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
(int, int) _readVarint(Uint8List data, int index) {
|
||||||
|
var value = 0;
|
||||||
|
var shift = 0;
|
||||||
|
while (index < data.length) {
|
||||||
|
final byte = data[index++];
|
||||||
|
value |= (byte & 0x7f) << shift;
|
||||||
|
if (byte & 0x80 == 0) return (value, index);
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
throw const FormatException('Invalid protobuf varint');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _varint(int value) {
|
||||||
|
final result = <int>[];
|
||||||
|
while (value > 0x7f) {
|
||||||
|
result.add((value & 0x7f) | 0x80);
|
||||||
|
value >>= 7;
|
||||||
|
}
|
||||||
|
result.add(value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OneKeyLoginReply {
|
||||||
|
const OneKeyLoginReply({required this.token, required this.phone});
|
||||||
|
final String token;
|
||||||
|
final String phone;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import '../../../common/router/app_router.dart';
|
|||||||
import '../../../common/theme/spacing.dart';
|
import '../../../common/theme/spacing.dart';
|
||||||
import '../../../common/widgets/circle_back_button.dart';
|
import '../../../common/widgets/circle_back_button.dart';
|
||||||
import '../application/auth_controller.dart';
|
import '../application/auth_controller.dart';
|
||||||
|
import '../application/login_launcher.dart';
|
||||||
|
|
||||||
class LoginPage extends HookConsumerWidget {
|
class LoginPage extends HookConsumerWidget {
|
||||||
const LoginPage({super.key, this.next});
|
const LoginPage({super.key, this.next});
|
||||||
@@ -31,6 +32,7 @@ class LoginPage extends HookConsumerWidget {
|
|||||||
final agreed = useState(false);
|
final agreed = useState(false);
|
||||||
final sending = useState(false);
|
final sending = useState(false);
|
||||||
final verifying = useState(false);
|
final verifying = useState(false);
|
||||||
|
final oneKeyLoading = useState(false);
|
||||||
final countdown = useState(0);
|
final countdown = useState(0);
|
||||||
final error = useState<String?>(null);
|
final error = useState<String?>(null);
|
||||||
final timer = useRef<Timer?>(null);
|
final timer = useRef<Timer?>(null);
|
||||||
@@ -163,6 +165,9 @@ class LoginPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> launchOneKey() =>
|
||||||
|
LoginLauncher.start(context, ref, next: next);
|
||||||
|
|
||||||
void onSendTap() {
|
void onSendTap() {
|
||||||
if (sending.value || countdown.value > 0) return;
|
if (sending.value || countdown.value > 0) return;
|
||||||
if (phoneDigits().length != 11) {
|
if (phoneDigits().length != 11) {
|
||||||
@@ -222,6 +227,8 @@ class LoginPage extends HookConsumerWidget {
|
|||||||
agreed: agreed.value,
|
agreed: agreed.value,
|
||||||
onToggleAgreed: () => agreed.value = !agreed.value,
|
onToggleAgreed: () => agreed.value = !agreed.value,
|
||||||
onSend: onSendTap,
|
onSend: onSendTap,
|
||||||
|
oneKeyLoading: oneKeyLoading.value,
|
||||||
|
onOneKey: launchOneKey,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -240,6 +247,8 @@ class _PhoneStep extends StatelessWidget {
|
|||||||
required this.onToggleAgreed,
|
required this.onToggleAgreed,
|
||||||
required this.error,
|
required this.error,
|
||||||
required this.onSend,
|
required this.onSend,
|
||||||
|
required this.oneKeyLoading,
|
||||||
|
required this.onOneKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ShadColorScheme cs;
|
final ShadColorScheme cs;
|
||||||
@@ -251,6 +260,8 @@ class _PhoneStep extends StatelessWidget {
|
|||||||
final VoidCallback onToggleAgreed;
|
final VoidCallback onToggleAgreed;
|
||||||
final String? error;
|
final String? error;
|
||||||
final VoidCallback onSend;
|
final VoidCallback onSend;
|
||||||
|
final bool oneKeyLoading;
|
||||||
|
final VoidCallback onOneKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -295,6 +306,13 @@ class _PhoneStep extends StatelessWidget {
|
|||||||
dim: !phoneReady,
|
dim: !phoneReady,
|
||||||
onTap: onSend,
|
onTap: onSend,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_PrimaryButton(
|
||||||
|
label: '本机号码一键登录',
|
||||||
|
enabled: !oneKeyLoading,
|
||||||
|
loading: oneKeyLoading,
|
||||||
|
onTap: onOneKey,
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
|
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../../common/network/dio_provider.dart';
|
||||||
|
|
||||||
|
/// 金值商城广告位的公共契约;商城是否展示完全由服务端配置决定。
|
||||||
|
const mallSourceApp = 'jinzhi';
|
||||||
|
|
||||||
|
class MallTabConfig {
|
||||||
|
const MallTabConfig({
|
||||||
|
required this.enabled,
|
||||||
|
required this.url,
|
||||||
|
this.title = '商城',
|
||||||
|
this.adId = '',
|
||||||
|
this.trackingId = '',
|
||||||
|
this.openMode = 'in_app',
|
||||||
|
this.badgeText = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const MallTabConfig.disabled() : this(enabled: false, url: '');
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
final String url;
|
||||||
|
final String title;
|
||||||
|
final String adId;
|
||||||
|
final String trackingId;
|
||||||
|
final String openMode;
|
||||||
|
final String badgeText;
|
||||||
|
|
||||||
|
bool get isVisible => enabled && initialUri != null;
|
||||||
|
bool get opensInApp => openMode == 'in_app';
|
||||||
|
|
||||||
|
Uri? get initialUri => attributedUri(clickId: '');
|
||||||
|
|
||||||
|
Uri? attributedUri({required String clickId, bool isDark = false}) {
|
||||||
|
final uri = Uri.tryParse(url.trim());
|
||||||
|
if (!_isAllowed(uri)) return null;
|
||||||
|
return uri!.replace(
|
||||||
|
queryParameters: {
|
||||||
|
...uri.queryParameters,
|
||||||
|
'from': uri.queryParameters['from']?.trim().isNotEmpty == true
|
||||||
|
? uri.queryParameters['from']!
|
||||||
|
: mallSourceApp,
|
||||||
|
'source_app': mallSourceApp,
|
||||||
|
'source_entry': 'mall_tab',
|
||||||
|
'placement': 'mall_tab',
|
||||||
|
'hook_type': 'mall_tab',
|
||||||
|
'target_app': 'mall',
|
||||||
|
if (adId.isNotEmpty) 'ad_id': adId,
|
||||||
|
if (trackingId.isNotEmpty) 'tracking_id': trackingId,
|
||||||
|
if (clickId.isNotEmpty) 'click_id': clickId,
|
||||||
|
if (clickId.isNotEmpty)
|
||||||
|
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
'isNight': isDark ? '1' : '0',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isAllowed(Uri? uri) {
|
||||||
|
if (uri == null || uri.host.isEmpty) return false;
|
||||||
|
if (uri.scheme == 'https') return true;
|
||||||
|
return !kReleaseMode &&
|
||||||
|
uri.scheme == 'http' &&
|
||||||
|
const {'localhost', '127.0.0.1', '::1'}.contains(uri.host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MallTabConfigController extends StateNotifier<MallTabConfig> {
|
||||||
|
MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
Future<void>? _loading;
|
||||||
|
|
||||||
|
Future<MallTabConfig> ensureLoaded() async {
|
||||||
|
if (state.isVisible) return state;
|
||||||
|
await (_loading ??= refresh().whenComplete(() => _loading = null));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get<List<int>>(
|
||||||
|
'/api/v1/ads',
|
||||||
|
queryParameters: const {'placement': 'mall_tab'},
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
final reply = _ProtoMessage(response.data ?? const []).message(2);
|
||||||
|
final enabled = _ProtoMessage(response.data ?? const []).integer(1) != 0;
|
||||||
|
final trackedUrl = reply.string(14);
|
||||||
|
final h5Url = reply.string(9);
|
||||||
|
final url = _validUrl(trackedUrl) ?? _validUrl(h5Url);
|
||||||
|
if (!enabled || url == null) {
|
||||||
|
state = const MallTabConfig.disabled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = MallTabConfig(
|
||||||
|
enabled: true,
|
||||||
|
url: url,
|
||||||
|
title: _nonEmpty(reply.string(4), '商城'),
|
||||||
|
adId: reply.string(1),
|
||||||
|
trackingId: reply.string(10),
|
||||||
|
openMode: _openMode(reply.integer(15)),
|
||||||
|
badgeText: reply.string(17).trim(),
|
||||||
|
);
|
||||||
|
await _report('ad_impression');
|
||||||
|
} catch (_) {
|
||||||
|
state = const MallTabConfig.disabled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> recordClick() async {
|
||||||
|
if (!state.isVisible) return null;
|
||||||
|
final clickId = const Uuid().v4();
|
||||||
|
try {
|
||||||
|
await _report('ad_click', clickId: clickId);
|
||||||
|
} catch (_) {
|
||||||
|
// 归因上报失败不能阻断商城入口。
|
||||||
|
}
|
||||||
|
return clickId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _report(String event, {String clickId = ''}) =>
|
||||||
|
_dio.post<List<int>>(
|
||||||
|
'/api/v1/ads/events',
|
||||||
|
data: _ProtoWriter()
|
||||||
|
.string(1, event)
|
||||||
|
.string(2, 'mall_tab')
|
||||||
|
.string(3, state.adId)
|
||||||
|
.string(4, state.trackingId)
|
||||||
|
.string(5, clickId)
|
||||||
|
.integer(6, DateTime.now().millisecondsSinceEpoch)
|
||||||
|
.toBytes(),
|
||||||
|
options: protobufOptions(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final mallTabConfigProvider =
|
||||||
|
StateNotifierProvider<MallTabConfigController, MallTabConfig>(
|
||||||
|
(ref) => MallTabConfigController(ref.watch(dioProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
String _nonEmpty(String value, String fallback) =>
|
||||||
|
value.trim().isEmpty ? fallback : value.trim();
|
||||||
|
|
||||||
|
String? _validUrl(String raw) {
|
||||||
|
final uri = Uri.tryParse(raw.trim());
|
||||||
|
return MallTabConfig._isAllowed(uri) ? uri.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _openMode(int value) => switch (value) {
|
||||||
|
1 => 'native_app',
|
||||||
|
2 => 'in_app',
|
||||||
|
_ => 'external',
|
||||||
|
};
|
||||||
|
|
||||||
|
class _ProtoMessage {
|
||||||
|
_ProtoMessage(this._bytes);
|
||||||
|
final List<int> _bytes;
|
||||||
|
|
||||||
|
int integer(int wanted) =>
|
||||||
|
_fields()[wanted] is int ? _fields()[wanted]! as int : 0;
|
||||||
|
String string(int wanted) => _fields()[wanted] is List<int>
|
||||||
|
? String.fromCharCodes(_fields()[wanted]! as List<int>)
|
||||||
|
: '';
|
||||||
|
_ProtoMessage message(int wanted) => _ProtoMessage(
|
||||||
|
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<int, Object> _fields() {
|
||||||
|
final values = <int, Object>{};
|
||||||
|
var index = 0;
|
||||||
|
int readVarint() {
|
||||||
|
var value = 0;
|
||||||
|
var shift = 0;
|
||||||
|
while (index < _bytes.length && shift < 64) {
|
||||||
|
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 = readVarint();
|
||||||
|
final tag = key >> 3;
|
||||||
|
switch (key & 7) {
|
||||||
|
case 0:
|
||||||
|
values[tag] = readVarint();
|
||||||
|
case 2:
|
||||||
|
final length = readVarint();
|
||||||
|
final end = index + length;
|
||||||
|
if (end > _bytes.length) {
|
||||||
|
throw const FormatException('Invalid protobuf');
|
||||||
|
}
|
||||||
|
values[tag] = _bytes.sublist(index, end);
|
||||||
|
index = end;
|
||||||
|
default:
|
||||||
|
throw const FormatException('Unsupported protobuf');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProtoWriter {
|
||||||
|
final _bytes = BytesBuilder(copy: false);
|
||||||
|
_ProtoWriter string(int tag, String value) {
|
||||||
|
final data = value.codeUnits;
|
||||||
|
_varint((tag << 3) | 2);
|
||||||
|
_varint(data.length);
|
||||||
|
_bytes.add(data);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ProtoWriter integer(int tag, int value) {
|
||||||
|
_varint(tag << 3);
|
||||||
|
_varint(value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List toBytes() => _bytes.toBytes();
|
||||||
|
void _varint(int value) {
|
||||||
|
while (value > 127) {
|
||||||
|
_bytes.addByte((value & 127) | 128);
|
||||||
|
value >>= 7;
|
||||||
|
}
|
||||||
|
_bytes.addByte(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
import '../../../common/router/app_router.dart';
|
import '../../../common/router/app_router.dart';
|
||||||
import '../../../common/widgets/adaptive.dart';
|
import '../../../common/widgets/adaptive.dart';
|
||||||
import '../../auth/application/auth_controller.dart';
|
import '../../auth/application/auth_controller.dart';
|
||||||
|
import '../../auth/application/login_launcher.dart';
|
||||||
import '../../auth/data/auth_models.dart';
|
import '../../auth/data/auth_models.dart';
|
||||||
import '../../profile/application/profile_settings_controller.dart';
|
import '../../profile/application/profile_settings_controller.dart';
|
||||||
|
|
||||||
@@ -58,10 +59,6 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FilledButton(
|
|
||||||
onPressed: () => context.push(AppRoutes.settings),
|
|
||||||
child: const Text('设置'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -103,9 +100,7 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: loggedIn
|
onPressed: () => LoginLauncher.start(context, ref),
|
||||||
? () => context.push(AppRoutes.login)
|
|
||||||
: () => context.push(AppRoutes.login),
|
|
||||||
child: Text(loggedIn ? '切换账号' : '去登录'),
|
child: Text(loggedIn ? '切换账号' : '去登录'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../../common/services/device_header_service.dart';
|
||||||
|
import '../../auth/data/auth_models.dart';
|
||||||
|
|
||||||
|
class FeedbackEmailService {
|
||||||
|
const FeedbackEmailService._();
|
||||||
|
|
||||||
|
static const recipient = 'business@yqzhice.top';
|
||||||
|
static const productName = '金值';
|
||||||
|
|
||||||
|
static Future<void> send(AuthState? authState) async {
|
||||||
|
final email = await _buildEmail(authState);
|
||||||
|
final uri = composeMailtoUri(
|
||||||
|
recipient: email.recipient,
|
||||||
|
subject: email.subject,
|
||||||
|
body: email.body,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final launched = await launchUrl(
|
||||||
|
uri,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!launched) throw const FeedbackEmailUnavailableException();
|
||||||
|
} catch (_) {
|
||||||
|
throw const FeedbackEmailUnavailableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static Uri composeMailtoUri({
|
||||||
|
required String recipient,
|
||||||
|
required String subject,
|
||||||
|
required String body,
|
||||||
|
}) => Uri(
|
||||||
|
scheme: 'mailto',
|
||||||
|
path: recipient,
|
||||||
|
query: _encodeQueryParameters({'subject': subject, 'body': body}),
|
||||||
|
);
|
||||||
|
|
||||||
|
static String _encodeQueryParameters(
|
||||||
|
Map<String, String> parameters,
|
||||||
|
) => parameters.entries
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
'${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value)}',
|
||||||
|
)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
static Future<_FeedbackEmail> _buildEmail(AuthState? authState) async {
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
final device = await _loadDeviceInfo();
|
||||||
|
try {
|
||||||
|
await DeviceHeaderService.to.ensureInitialized();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final headers = DeviceHeaderService.to.commonHeaders();
|
||||||
|
final loggedIn = authState is LoggedInAuthState ? authState : null;
|
||||||
|
final version = '${packageInfo.version}+${packageInfo.buildNumber}';
|
||||||
|
final diagnostics = [
|
||||||
|
'App version: $version',
|
||||||
|
'Channel: ${headers['User-Channel'] ?? ''}',
|
||||||
|
'OS version: ${device.osVersion}',
|
||||||
|
'Device manufacturer: ${device.manufacturer}',
|
||||||
|
'Device model: ${device.model}',
|
||||||
|
'Login type: ${loggedIn == null ? 'guest' : 'phone'}',
|
||||||
|
'Phone: ${loggedIn?.user.phone ?? ''}',
|
||||||
|
].join('\n');
|
||||||
|
return _FeedbackEmail(
|
||||||
|
recipient: recipient,
|
||||||
|
subject:
|
||||||
|
'$productName反馈[$version]-[${device.manufacturer}]-[${device.osVersion}]',
|
||||||
|
body: [
|
||||||
|
'$productName团队你好,',
|
||||||
|
'请描述你的问题,可附上截图或视频:',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'----',
|
||||||
|
diagnostics,
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_FeedbackDeviceInfo> _loadDeviceInfo() async {
|
||||||
|
final plugin = DeviceInfoPlugin();
|
||||||
|
try {
|
||||||
|
if (kIsWeb) {
|
||||||
|
final info = await plugin.webBrowserInfo;
|
||||||
|
return _FeedbackDeviceInfo(
|
||||||
|
osVersion: info.platform ?? 'web',
|
||||||
|
manufacturer: info.browserName.name,
|
||||||
|
model: info.userAgent ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
final info = await plugin.androidInfo;
|
||||||
|
return _FeedbackDeviceInfo(
|
||||||
|
osVersion: info.version.release,
|
||||||
|
manufacturer: info.manufacturer,
|
||||||
|
model: info.model,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||||
|
final info = await plugin.iosInfo;
|
||||||
|
return _FeedbackDeviceInfo(
|
||||||
|
osVersion: info.systemVersion,
|
||||||
|
manufacturer: 'Apple',
|
||||||
|
model: info.utsname.machine,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const _FeedbackDeviceInfo(
|
||||||
|
osVersion: '',
|
||||||
|
manufacturer: '',
|
||||||
|
model: '',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const _FeedbackDeviceInfo(
|
||||||
|
osVersion: '',
|
||||||
|
manufacturer: '',
|
||||||
|
model: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeedbackEmailUnavailableException implements Exception {
|
||||||
|
const FeedbackEmailUnavailableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedbackEmail {
|
||||||
|
const _FeedbackEmail({
|
||||||
|
required this.recipient,
|
||||||
|
required this.subject,
|
||||||
|
required this.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String recipient;
|
||||||
|
final String subject;
|
||||||
|
final String body;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedbackDeviceInfo {
|
||||||
|
const _FeedbackDeviceInfo({
|
||||||
|
required this.osVersion,
|
||||||
|
required this.manufacturer,
|
||||||
|
required this.model,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String osVersion;
|
||||||
|
final String manufacturer;
|
||||||
|
final String model;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/update_api_client.dart';
|
||||||
|
|
||||||
|
class UpdateSessionGate {
|
||||||
|
var _used = false;
|
||||||
|
bool takeColdLaunchSlot() => _used ? false : (_used = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final updateSessionGateProvider = Provider<UpdateSessionGate>(
|
||||||
|
(_) => UpdateSessionGate(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class UpdateController extends StateNotifier<AsyncValue<UpgradeInfo?>> {
|
||||||
|
UpdateController(this._read) : super(const AsyncData(null));
|
||||||
|
final T Function<T>(ProviderListenable<T>) _read;
|
||||||
|
|
||||||
|
Future<UpgradeInfo> checkUpgrade() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final result = await _read(updateApiClientProvider).checkUpgrade();
|
||||||
|
state = AsyncData(result);
|
||||||
|
return result;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
state = AsyncError(error, stackTrace);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final updateControllerProvider =
|
||||||
|
StateNotifierProvider<UpdateController, AsyncValue<UpgradeInfo?>>(
|
||||||
|
(ref) => UpdateController(ref.read),
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import 'update_controller.dart';
|
||||||
|
import 'update_service.dart';
|
||||||
|
|
||||||
|
/// 前后台切换仅低频检查一次,网络失败或无新版本均静默结束。
|
||||||
|
abstract final class UpdateForegroundService {
|
||||||
|
static DateTime? _lastCheck;
|
||||||
|
static Future<void>? _running;
|
||||||
|
static Future<void> check(BuildContext context, WidgetRef ref) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastCheck != null &&
|
||||||
|
now.difference(_lastCheck!) < const Duration(minutes: 30)) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
return _running ??= _run(context, ref).whenComplete(() => _running = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _run(BuildContext context, WidgetRef ref) async {
|
||||||
|
_lastCheck = DateTime.now();
|
||||||
|
try {
|
||||||
|
final info = await ref
|
||||||
|
.read(updateControllerProvider.notifier)
|
||||||
|
.checkUpgrade();
|
||||||
|
if (context.mounted) {
|
||||||
|
await UpdateService.showIfNeeded(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
info,
|
||||||
|
source: UpgradeCheckSource.automatic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_app_update/flutter_app_update.dart';
|
||||||
|
import 'package:flutter_app_update/result_model.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
final updateProgressProvider = StateProvider<double?>((_) => null);
|
||||||
|
final updateUrlProvider = StateProvider<String?>((_) => null);
|
||||||
|
final updateDialogVisibleProvider = StateProvider<bool>((_) => false);
|
||||||
|
|
||||||
|
bool _listenerRegistered = false;
|
||||||
|
|
||||||
|
/// The native download callback can outlive the update dialog, so it uses the
|
||||||
|
/// app's provider container instead of retaining a dialog WidgetRef.
|
||||||
|
void ensureUpdateProgressListener(BuildContext context) {
|
||||||
|
if (_listenerRegistered) return;
|
||||||
|
_listenerRegistered = true;
|
||||||
|
final container = ProviderScope.containerOf(context, listen: false);
|
||||||
|
AzhonAppUpdate.listener((result) => applyUpdateProgress(container, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void applyUpdateProgress(ProviderContainer container, ResultModel result) {
|
||||||
|
switch (result.type) {
|
||||||
|
case ResultType.downloading:
|
||||||
|
if (result.progress == null || result.max == null || result.max == 0) {
|
||||||
|
container.read(updateProgressProvider.notifier).state = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
container.read(updateProgressProvider.notifier).state =
|
||||||
|
result.progress!.toDouble() / result.max!.toDouble();
|
||||||
|
break;
|
||||||
|
case ResultType.done:
|
||||||
|
container.read(updateProgressProvider.notifier).state = 1;
|
||||||
|
container.read(updateUrlProvider.notifier).state = result.apk;
|
||||||
|
break;
|
||||||
|
case ResultType.error:
|
||||||
|
case ResultType.cancel:
|
||||||
|
container.read(updateProgressProvider.notifier).state = null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_app_update/flutter_app_update.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../data/update_api_client.dart';
|
||||||
|
import '../presentation/update_dialog.dart';
|
||||||
|
import 'update_progress.dart';
|
||||||
|
|
||||||
|
enum UpgradeCheckSource { automatic, manual }
|
||||||
|
|
||||||
|
class UpdateService {
|
||||||
|
static bool get isSupported =>
|
||||||
|
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||||
|
|
||||||
|
static Future<void> showIfNeeded(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
UpgradeInfo info, {
|
||||||
|
required UpgradeCheckSource source,
|
||||||
|
VoidCallback? onLatest,
|
||||||
|
}) async {
|
||||||
|
if (ref.read(updateDialogVisibleProvider)) return;
|
||||||
|
|
||||||
|
final shouldShow = source == UpgradeCheckSource.automatic
|
||||||
|
? info.show == 1 && info.hasPublishedUpdate
|
||||||
|
: info.hasPublishedUpdate;
|
||||||
|
if (!shouldShow) {
|
||||||
|
onLatest?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final uri = Uri.tryParse(info.url);
|
||||||
|
if (uri == null || uri.scheme != 'https' || !uri.hasAuthority) return;
|
||||||
|
final force = info.force == 1;
|
||||||
|
ref.read(updateDialogVisibleProvider.notifier).state = true;
|
||||||
|
try {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isDismissible: !force,
|
||||||
|
enableDrag: !force,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => UpdateDialog(upgrade: info, source: source),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
ref.read(updateProgressProvider.notifier).state = null;
|
||||||
|
ref.read(updateUrlProvider.notifier).state = null;
|
||||||
|
ref.read(updateDialogVisibleProvider.notifier).state = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> startUpdate(
|
||||||
|
String downloadUrl, {
|
||||||
|
bool showNotification = true,
|
||||||
|
}) async {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await _openIosUpdateUrl(downloadUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await AzhonAppUpdate.update(
|
||||||
|
UpdateModel(
|
||||||
|
downloadUrl,
|
||||||
|
'jinzhi-update.apk',
|
||||||
|
'ic_launcher',
|
||||||
|
'https://apps.apple.com/search?term=%E9%87%91%E5%80%BC',
|
||||||
|
showNotification: showNotification,
|
||||||
|
showBgdToast: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> install(String url) async {
|
||||||
|
if (kIsWeb) return;
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await _openIosUpdateUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await AzhonAppUpdate.install(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _openIosUpdateUrl(String downloadUrl) async {
|
||||||
|
final uri = Uri.tryParse(downloadUrl);
|
||||||
|
if (uri != null && uri.scheme == 'https' && uri.hasAuthority) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../common/network/dio_provider.dart';
|
||||||
|
|
||||||
|
/// 金值 `jinzhi.v1.UpgradeReply` 的 HTTP protobuf 契约。
|
||||||
|
class UpgradeInfo {
|
||||||
|
const UpgradeInfo({
|
||||||
|
required this.show,
|
||||||
|
required this.force,
|
||||||
|
required this.message,
|
||||||
|
required this.url,
|
||||||
|
required this.isLatestVersion,
|
||||||
|
required this.latestVersion,
|
||||||
|
required this.versionCode,
|
||||||
|
required this.hash,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int show;
|
||||||
|
final int force;
|
||||||
|
final String message;
|
||||||
|
final String url;
|
||||||
|
final int isLatestVersion;
|
||||||
|
final String latestVersion;
|
||||||
|
final String versionCode;
|
||||||
|
final String hash;
|
||||||
|
|
||||||
|
bool get hasPublishedUpdate =>
|
||||||
|
isLatestVersion != 1 &&
|
||||||
|
url.trim().isNotEmpty &&
|
||||||
|
(latestVersion.trim().isNotEmpty || versionCode.trim().isNotEmpty);
|
||||||
|
|
||||||
|
factory UpgradeInfo.fromProtobuf(List<int> bytes) {
|
||||||
|
final fields = _ProtoReader(bytes).readFields();
|
||||||
|
return UpgradeInfo(
|
||||||
|
show: fields.integer(1),
|
||||||
|
force: fields.integer(2),
|
||||||
|
message: fields.string(3),
|
||||||
|
url: fields.string(4),
|
||||||
|
isLatestVersion: fields.integer(5),
|
||||||
|
latestVersion: fields.string(6),
|
||||||
|
versionCode: fields.string(7),
|
||||||
|
hash: fields.string(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateApiClient {
|
||||||
|
UpdateApiClient(
|
||||||
|
this._dio, {
|
||||||
|
required String apiPathPrefix,
|
||||||
|
required String updatePackageName,
|
||||||
|
}) : _apiPathPrefix = _normalizePrefix(apiPathPrefix),
|
||||||
|
_updatePackageName = updatePackageName.trim();
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
final String _apiPathPrefix;
|
||||||
|
final String _updatePackageName;
|
||||||
|
|
||||||
|
Options get _options {
|
||||||
|
final options = protobufOptions();
|
||||||
|
if (_updatePackageName.isEmpty) return options;
|
||||||
|
return options.copyWith(
|
||||||
|
headers: {...?options.headers, 'pkg': _updatePackageName},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UpgradeInfo> checkUpgrade() async {
|
||||||
|
final response = await _dio.post<List<int>>(
|
||||||
|
_path('/sys/upgrade'),
|
||||||
|
// Uint8List ensures Dio sends the empty protobuf as 0 bytes instead of
|
||||||
|
// JSON-encoding an empty Dart List as `[]` (Content-Length: 2).
|
||||||
|
data: Uint8List(0), // jinzhi.v1.GetUpgradeRequest
|
||||||
|
options: _options,
|
||||||
|
);
|
||||||
|
return UpgradeInfo.fromProtobuf(response.data ?? const <int>[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reportClick({
|
||||||
|
required String buildId,
|
||||||
|
required String source,
|
||||||
|
}) async {
|
||||||
|
await _dio.post<List<int>>(
|
||||||
|
_path('/active/upgrade/click'),
|
||||||
|
data: _ProtoWriter()
|
||||||
|
.string(1, buildId)
|
||||||
|
.string(2, source)
|
||||||
|
.toBytes(), // jinzhi.v1.UpgradeClickRequest
|
||||||
|
options: _options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _path(String endpoint) => '$_apiPathPrefix$endpoint';
|
||||||
|
|
||||||
|
static String _normalizePrefix(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty || trimmed == '/') return '';
|
||||||
|
final withLeadingSlash = trimmed.startsWith('/') ? trimmed : '/$trimmed';
|
||||||
|
return withLeadingSlash.endsWith('/')
|
||||||
|
? withLeadingSlash.substring(0, withLeadingSlash.length - 1)
|
||||||
|
: withLeadingSlash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final updateApiClientProvider = Provider<UpdateApiClient>(
|
||||||
|
(ref) => UpdateApiClient(
|
||||||
|
ref.watch(dioProvider),
|
||||||
|
apiPathPrefix: ref.watch(appConfigProvider).apiPathPrefix,
|
||||||
|
updatePackageName: ref.watch(appConfigProvider).updatePackageName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _ProtoFields {
|
||||||
|
final _values = <int, Object>{};
|
||||||
|
int integer(int tag) => _values[tag] as int? ?? 0;
|
||||||
|
String string(int tag) => _values[tag] as String? ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProtoReader {
|
||||||
|
_ProtoReader(this._bytes);
|
||||||
|
final List<int> _bytes;
|
||||||
|
var _index = 0;
|
||||||
|
|
||||||
|
_ProtoFields readFields() {
|
||||||
|
final result = _ProtoFields();
|
||||||
|
while (_index < _bytes.length) {
|
||||||
|
final key = _varint();
|
||||||
|
final tag = key >> 3;
|
||||||
|
switch (key & 7) {
|
||||||
|
case 0:
|
||||||
|
result._values[tag] = _varint();
|
||||||
|
case 2:
|
||||||
|
final length = _varint();
|
||||||
|
final end = _index + length;
|
||||||
|
if (end > _bytes.length) {
|
||||||
|
throw const FormatException('Invalid protobuf');
|
||||||
|
}
|
||||||
|
result._values[tag] = utf8.decode(_bytes.sublist(_index, end));
|
||||||
|
_index = end;
|
||||||
|
default:
|
||||||
|
throw const FormatException('Unsupported protobuf wire type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _varint() {
|
||||||
|
var value = 0;
|
||||||
|
var shift = 0;
|
||||||
|
while (_index < _bytes.length && shift < 64) {
|
||||||
|
final byte = _bytes[_index++];
|
||||||
|
value |= (byte & 0x7f) << shift;
|
||||||
|
if (byte < 0x80) return value;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
throw const FormatException('Invalid protobuf varint');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProtoWriter {
|
||||||
|
final _bytes = BytesBuilder(copy: false);
|
||||||
|
_ProtoWriter string(int tag, String value) {
|
||||||
|
final bytes = utf8.encode(value);
|
||||||
|
_varint((tag << 3) | 2);
|
||||||
|
_varint(bytes.length);
|
||||||
|
_bytes.add(bytes);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List toBytes() => _bytes.toBytes();
|
||||||
|
void _varint(int value) {
|
||||||
|
while (value > 0x7f) {
|
||||||
|
_bytes.addByte((value & 0x7f) | 0x80);
|
||||||
|
value >>= 7;
|
||||||
|
}
|
||||||
|
_bytes.addByte(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -8,9 +10,13 @@ import 'package:video_player/video_player.dart';
|
|||||||
|
|
||||||
import '../../../common/config/app_h5_urls.dart';
|
import '../../../common/config/app_h5_urls.dart';
|
||||||
import '../../../common/services/device_header_service.dart';
|
import '../../../common/services/device_header_service.dart';
|
||||||
|
import '../../../common/push/umeng_push_service.dart';
|
||||||
|
import '../../../common/push/notification_permission.dart';
|
||||||
|
import '../../../common/privacy/umeng_privacy_consent.dart';
|
||||||
import '../../../common/theme/jinzhi_theme.dart';
|
import '../../../common/theme/jinzhi_theme.dart';
|
||||||
import '../../../common/widgets/adaptive.dart';
|
import '../../../common/widgets/adaptive.dart';
|
||||||
import '../../../common/router/app_router.dart';
|
import '../../../common/router/app_router.dart';
|
||||||
|
import '../../../common/tracking/umeng_service.dart';
|
||||||
|
|
||||||
class PrivacyConsentPage extends StatefulWidget {
|
class PrivacyConsentPage extends StatefulWidget {
|
||||||
const PrivacyConsentPage({super.key});
|
const PrivacyConsentPage({super.key});
|
||||||
@@ -47,12 +53,46 @@ class _PrivacyConsentPageState extends State<PrivacyConsentPage> {
|
|||||||
Future<void> _agree() async {
|
Future<void> _agree() async {
|
||||||
await DeviceHeaderService.to.markPrivacyAgreed();
|
await DeviceHeaderService.to.markPrivacyAgreed();
|
||||||
await DeviceHeaderService.to.ensureInitialized();
|
await DeviceHeaderService.to.ensureInitialized();
|
||||||
if (mounted) context.go(AppRoutes.assets);
|
if (mounted) {
|
||||||
|
context.go(AppRoutes.assets);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_completePostConsentInitialization();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _completePostConsentInitialization() async {
|
||||||
|
// Consent navigation must never depend on an optional native SDK. The
|
||||||
|
// calls below still run in order, but failures are retried by normal app
|
||||||
|
// startup instead of trapping the user on the consent page.
|
||||||
|
try {
|
||||||
|
await UmengPrivacyConsent.submitGranted();
|
||||||
|
await UmengService.initIfAllowed();
|
||||||
|
await UmengPushService.startIfAllowed();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _enter() async {
|
Future<void> _enter() async {
|
||||||
await DeviceHeaderService.to.markIosStartupSeen();
|
await DeviceHeaderService.to.markIosStartupSeen();
|
||||||
if (mounted) context.go(AppRoutes.assets);
|
if (!mounted) return;
|
||||||
|
// 首屏跳转与可选 SDK 调用严格解耦。原生初始化、ATT 或网络问题都不得
|
||||||
|
// 阻断“立即进入”。
|
||||||
|
context.go(AppRoutes.assets);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
unawaited(_completeIosPostEnterInitialization());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _completeIosPostEnterInitialization() async {
|
||||||
|
// iOS 不展示 Android 顶部说明;这里在可见首页后直接请求系统通知授权。
|
||||||
|
// U-Push 注册保留,供 APNs token/友盟通道建立使用。
|
||||||
|
try {
|
||||||
|
await NotificationPermission.requestFromSettings();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await UmengService.requestTrackingAuthorizationIfNeeded();
|
||||||
|
await UmengPushService.startIfAllowed();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
|
|
||||||
import '../../../common/config/app_h5_urls.dart';
|
import '../../../common/config/app_h5_urls.dart';
|
||||||
|
import '../../../common/push/notification_permission.dart';
|
||||||
import '../../../common/router/app_router.dart';
|
import '../../../common/router/app_router.dart';
|
||||||
|
import '../../../common/services/cache_clear_service.dart';
|
||||||
import '../../../common/theme/app_text.dart';
|
import '../../../common/theme/app_text.dart';
|
||||||
import '../../../common/theme/spacing.dart';
|
import '../../../common/theme/spacing.dart';
|
||||||
import '../../../common/theme/theme_controller.dart';
|
import '../../../common/theme/theme_controller.dart';
|
||||||
@@ -20,6 +21,10 @@ import '../../auth/application/auth_controller.dart';
|
|||||||
import '../../auth/data/auth_models.dart';
|
import '../../auth/data/auth_models.dart';
|
||||||
import '../../profile/application/profile_settings_controller.dart';
|
import '../../profile/application/profile_settings_controller.dart';
|
||||||
import '../../profile/data/profile_models.dart';
|
import '../../profile/data/profile_models.dart';
|
||||||
|
import '../application/feedback_email_service.dart';
|
||||||
|
import '../application/update_controller.dart';
|
||||||
|
import '../application/update_service.dart';
|
||||||
|
import '../data/update_api_client.dart';
|
||||||
|
|
||||||
class SettingsPage extends HookConsumerWidget {
|
class SettingsPage extends HookConsumerWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
@@ -31,8 +36,25 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
final profileSettings = ref.watch(profileSettingsControllerProvider);
|
final profileSettings = ref.watch(profileSettingsControllerProvider);
|
||||||
final authAsync = ref.watch(authProvider);
|
final authAsync = ref.watch(authProvider);
|
||||||
final loggedIn = authAsync.valueOrNull is LoggedInAuthState;
|
final loggedIn = authAsync.valueOrNull is LoggedInAuthState;
|
||||||
final contactDebugTapCount = useState(0);
|
final updateSupported = UpdateService.isSupported;
|
||||||
final contactLastTapAt = useRef<DateTime?>(null);
|
final upgrade = ref.watch(updateControllerProvider).valueOrNull;
|
||||||
|
final notificationEnabled = useState(false);
|
||||||
|
useEffect(() {
|
||||||
|
if (!updateSupported) return null;
|
||||||
|
Future<void>(() async {
|
||||||
|
try {
|
||||||
|
await ref.read(updateControllerProvider.notifier).checkUpgrade();
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [updateSupported]);
|
||||||
|
useEffect(() {
|
||||||
|
Future<void>(
|
||||||
|
() async => notificationEnabled.value =
|
||||||
|
await NotificationPermission.isGranted(),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}, const []);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: cs.background,
|
backgroundColor: cs.background,
|
||||||
@@ -128,6 +150,36 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
trailing: '占位',
|
trailing: '占位',
|
||||||
onTap: () => _showWidgetSheet(context),
|
onTap: () => _showWidgetSheet(context),
|
||||||
),
|
),
|
||||||
|
Divider(height: 1, thickness: 1, color: cs.border),
|
||||||
|
_SwitchRow(
|
||||||
|
title: '推送通知',
|
||||||
|
subtitle: notificationEnabled.value
|
||||||
|
? '已允许接收服务提醒'
|
||||||
|
: '在系统设置中开启服务提醒',
|
||||||
|
value: notificationEnabled.value,
|
||||||
|
onChanged: (enabled) async {
|
||||||
|
if (enabled) {
|
||||||
|
final granted =
|
||||||
|
await NotificationPermission.requestFromSettings();
|
||||||
|
notificationEnabled.value = granted;
|
||||||
|
if (!granted) {
|
||||||
|
await NotificationPermission.openSystemSettings();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await NotificationPermission.openSystemSettings();
|
||||||
|
notificationEnabled.value =
|
||||||
|
await NotificationPermission.isGranted();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Divider(height: 1, thickness: 1, color: cs.border),
|
||||||
|
_NavRow(
|
||||||
|
icon: FlutterRemix.delete_bin_6_line,
|
||||||
|
label: '清除缓存',
|
||||||
|
trailing: '图片与网页缓存',
|
||||||
|
showArrow: true,
|
||||||
|
onTap: () => _confirmClearCache(context),
|
||||||
|
),
|
||||||
if (kDebugMode) ...[
|
if (kDebugMode) ...[
|
||||||
Divider(height: 1, thickness: 1, color: cs.border),
|
Divider(height: 1, thickness: 1, color: cs.border),
|
||||||
_NavRow(
|
_NavRow(
|
||||||
@@ -146,18 +198,16 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
_NavRow(
|
_NavRow(
|
||||||
icon: FlutterRemix.file_list_3_line,
|
icon: FlutterRemix.file_list_3_line,
|
||||||
label: '用户协议',
|
label: '用户协议',
|
||||||
onTap: () => launchUrlString(
|
onTap: () => context.push(
|
||||||
AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl),
|
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl))}',
|
||||||
mode: LaunchMode.inAppBrowserView,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Divider(height: 1, thickness: 1, color: cs.border),
|
Divider(height: 1, thickness: 1, color: cs.border),
|
||||||
_NavRow(
|
_NavRow(
|
||||||
icon: FlutterRemix.shield_check_line,
|
icon: FlutterRemix.shield_check_line,
|
||||||
label: '隐私政策',
|
label: '隐私政策',
|
||||||
onTap: () => launchUrlString(
|
onTap: () => context.push(
|
||||||
AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl),
|
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl))}',
|
||||||
mode: LaunchMode.inAppBrowserView,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Divider(height: 1, thickness: 1, color: cs.border),
|
Divider(height: 1, thickness: 1, color: cs.border),
|
||||||
@@ -194,26 +244,37 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
const _Section('关于'),
|
const _Section('关于'),
|
||||||
_Card(
|
_Card(
|
||||||
children: [
|
children: [
|
||||||
const _AboutRow(),
|
_AboutRow(
|
||||||
|
upgrade: upgrade,
|
||||||
|
enabled: updateSupported,
|
||||||
|
onTap: () async {
|
||||||
|
try {
|
||||||
|
final result = await ref
|
||||||
|
.read(updateControllerProvider.notifier)
|
||||||
|
.checkUpgrade();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await UpdateService.showIfNeeded(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
result,
|
||||||
|
source: UpgradeCheckSource.manual,
|
||||||
|
onLatest: () => toastInfo(msg: '已经是最新版本'),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) toastInfo(msg: '检查更新失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
Divider(height: 1, thickness: 1, color: cs.border),
|
Divider(height: 1, thickness: 1, color: cs.border),
|
||||||
_NavRow(
|
_NavRow(
|
||||||
icon: FlutterRemix.mail_line,
|
icon: FlutterRemix.mail_line,
|
||||||
label: '联系我们',
|
label: '联系我们',
|
||||||
trailing: 'business@hnsiderui.cn',
|
trailing: FeedbackEmailService.recipient,
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
final now = DateTime.now();
|
try {
|
||||||
final lastTapAt = contactLastTapAt.value;
|
await FeedbackEmailService.send(authAsync.valueOrNull);
|
||||||
if (lastTapAt == null ||
|
} catch (_) {
|
||||||
now.difference(lastTapAt) >
|
if (context.mounted) _showFeedbackEmailFailDialog(context);
|
||||||
const Duration(milliseconds: 500)) {
|
|
||||||
contactDebugTapCount.value = 0;
|
|
||||||
}
|
|
||||||
contactLastTapAt.value = now;
|
|
||||||
contactDebugTapCount.value += 1;
|
|
||||||
if (contactDebugTapCount.value >= 10) {
|
|
||||||
contactDebugTapCount.value = 0;
|
|
||||||
contactLastTapAt.value = null;
|
|
||||||
context.push(AppRoutes.debug);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -226,6 +287,38 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearCache(BuildContext context) async {
|
||||||
|
final systemSettings = CacheClearService.requiresSystemSettings;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('清除缓存'),
|
||||||
|
content: Text(
|
||||||
|
systemSettings
|
||||||
|
? 'Android 需要在系统应用详情中完成缓存清理,是否立即前往?'
|
||||||
|
: '将清除图片和网页缓存。下次打开相关页面时需要重新加载。',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: Text(systemSettings ? '前往设置' : '确认清除'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
if (systemSettings) {
|
||||||
|
await CacheClearService.openAndroidSystemSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await CacheClearService.clear();
|
||||||
|
if (context.mounted) toastInfo(msg: '缓存已清除');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||||
final cs = ShadTheme.of(context).colorScheme;
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
final ok = await showModalBottomSheet<bool>(
|
final ok = await showModalBottomSheet<bool>(
|
||||||
@@ -337,13 +430,15 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
_ChoiceButton(
|
_ChoiceButton(
|
||||||
label: PriceColorMode.redUpGreenDown.label,
|
label: PriceColorMode.redUpGreenDown.label,
|
||||||
selected: current == PriceColorMode.redUpGreenDown,
|
selected: current == PriceColorMode.redUpGreenDown,
|
||||||
onTap: () => Navigator.of(ctx).pop(PriceColorMode.redUpGreenDown),
|
onTap: () =>
|
||||||
|
Navigator.of(ctx).pop(PriceColorMode.redUpGreenDown),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_ChoiceButton(
|
_ChoiceButton(
|
||||||
label: PriceColorMode.greenUpRedDown.label,
|
label: PriceColorMode.greenUpRedDown.label,
|
||||||
selected: current == PriceColorMode.greenUpRedDown,
|
selected: current == PriceColorMode.greenUpRedDown,
|
||||||
onTap: () => Navigator.of(ctx).pop(PriceColorMode.greenUpRedDown),
|
onTap: () =>
|
||||||
|
Navigator.of(ctx).pop(PriceColorMode.greenUpRedDown),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -351,9 +446,9 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (selected != null && context.mounted) {
|
if (selected != null && context.mounted) {
|
||||||
ref.read(profileSettingsControllerProvider.notifier).setPriceColorMode(
|
ref
|
||||||
selected,
|
.read(profileSettingsControllerProvider.notifier)
|
||||||
);
|
.setPriceColorMode(selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +815,24 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showFeedbackEmailFailDialog(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('无法发送反馈'),
|
||||||
|
content: const Text(
|
||||||
|
'请配置并登录系统邮箱,或使用其他邮箱发送邮件至 ${FeedbackEmailService.recipient}。',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class _Section extends StatelessWidget {
|
class _Section extends StatelessWidget {
|
||||||
const _Section(this.title);
|
const _Section(this.title);
|
||||||
|
|
||||||
@@ -779,7 +892,7 @@ class _ThemeSegmentCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = ShadTheme.of(context).colorScheme;
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
final options = <(ThemeMode, String, IconData)>[
|
final options = <(ThemeMode, String, IconData)>[
|
||||||
(ThemeMode.system, '跟随系统', Icons.brightness_auto_outlined),
|
(ThemeMode.system, '系统主题', Icons.brightness_auto_outlined),
|
||||||
(ThemeMode.light, '浅色', Icons.light_mode_outlined),
|
(ThemeMode.light, '浅色', Icons.light_mode_outlined),
|
||||||
(ThemeMode.dark, '深色', Icons.dark_mode_outlined),
|
(ThemeMode.dark, '深色', Icons.dark_mode_outlined),
|
||||||
];
|
];
|
||||||
@@ -939,6 +1052,7 @@ class _NavRow extends StatelessWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.showArrow = false,
|
||||||
this.destructive = false,
|
this.destructive = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -946,6 +1060,7 @@ class _NavRow extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final String? trailing;
|
final String? trailing;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final bool showArrow;
|
||||||
final bool destructive;
|
final bool destructive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -972,7 +1087,7 @@ class _NavRow extends StatelessWidget {
|
|||||||
color: cs.mutedForeground,
|
color: cs.mutedForeground,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (onTap != null && trailing == null)
|
if (onTap != null && (trailing == null || showArrow))
|
||||||
Icon(
|
Icon(
|
||||||
FlutterRemix.arrow_right_s_line,
|
FlutterRemix.arrow_right_s_line,
|
||||||
size: 20,
|
size: 20,
|
||||||
@@ -986,7 +1101,15 @@ class _NavRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AboutRow extends StatelessWidget {
|
class _AboutRow extends StatelessWidget {
|
||||||
const _AboutRow();
|
const _AboutRow({
|
||||||
|
required this.upgrade,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final UpgradeInfo? upgrade;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -997,33 +1120,47 @@ class _AboutRow extends StatelessWidget {
|
|||||||
final v = snap.hasData
|
final v = snap.hasData
|
||||||
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
||||||
: '';
|
: '';
|
||||||
return Padding(
|
final label = upgrade?.hasPublishedUpdate == true
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
? '有新版 ${upgrade!.latestVersion}'
|
||||||
child: Row(
|
: v;
|
||||||
children: [
|
return InkWell(
|
||||||
SizedBox(
|
onTap: enabled ? onTap : null,
|
||||||
width: 24,
|
child: Padding(
|
||||||
child: Icon(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||||
FlutterRemix.information_line,
|
child: Row(
|
||||||
size: 20,
|
children: [
|
||||||
color: cs.foreground,
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Icon(
|
||||||
|
FlutterRemix.information_line,
|
||||||
|
size: 20,
|
||||||
|
color: cs.foreground,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 13),
|
||||||
const SizedBox(width: 13),
|
Expanded(
|
||||||
Expanded(
|
child: Text(
|
||||||
child: Text(
|
'金值',
|
||||||
'金值',
|
style: TextStyle(fontSize: 15, color: cs.foreground),
|
||||||
style: TextStyle(fontSize: 15, color: cs.foreground),
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
Text(
|
label,
|
||||||
v,
|
style: AppText.meta.copyWith(
|
||||||
style: AppText.meta.copyWith(
|
fontSize: 13,
|
||||||
fontSize: 13,
|
color: cs.mutedForeground,
|
||||||
color: cs.mutedForeground,
|
),
|
||||||
),
|
),
|
||||||
),
|
if (enabled) ...[
|
||||||
],
|
const SizedBox(width: 6),
|
||||||
|
Icon(
|
||||||
|
FlutterRemix.arrow_right_s_line,
|
||||||
|
size: 20,
|
||||||
|
color: cs.mutedForeground,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1081,9 +1218,7 @@ class _ChoiceButton extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? cs.primary : cs.secondary,
|
color: selected ? cs.primary : cs.secondary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(color: selected ? cs.primary : cs.border),
|
||||||
color: selected ? cs.primary : cs.border,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -1182,10 +1317,7 @@ class _ValueRow extends StatelessWidget {
|
|||||||
style: TextStyle(fontSize: 15, color: cs.foreground),
|
style: TextStyle(fontSize: 15, color: cs.foreground),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(value, style: TextStyle(fontSize: 13, color: cs.mutedForeground)),
|
||||||
value,
|
|
||||||
style: TextStyle(fontSize: 13, color: cs.mutedForeground),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||