Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50a1ac0af7 |
No files matched your search
@@ -1,18 +1,9 @@
|
||||
.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
|
||||
.PHONY: runner run test lint apk ipa build-web
|
||||
|
||||
WEB_API_URL ?= https://jinzhi.api.local
|
||||
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)
|
||||
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
|
||||
COMMON_DART_DEFINES := --dart-define=GITVERSION=$(GITVERSION)
|
||||
|
||||
runner:
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
@@ -27,29 +18,10 @@ lint:
|
||||
flutter analyze
|
||||
|
||||
apk:
|
||||
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)"
|
||||
flutter build apk $(COMMON_DART_DEFINES) --release --target-platform android-arm64
|
||||
|
||||
ipa:
|
||||
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)"
|
||||
flutter build ipa $(COMMON_DART_DEFINES) --release
|
||||
|
||||
build-web:
|
||||
API_BASE_URL=$(WEB_API_URL) BASE_HREF=$(WEB_BASE_HREF) bash scripts/build_web.sh
|
||||
@@ -14,16 +14,6 @@ if (!androidSigningPropertiesFile.isFile) {
|
||||
}
|
||||
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 {
|
||||
val value = androidSigningProperties.getProperty(name)?.trim()
|
||||
if (value.isNullOrBlank()) {
|
||||
@@ -38,7 +28,7 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
signingConfigs {
|
||||
create("yanting") {
|
||||
create("release") {
|
||||
keyAlias = requireSigningProperty("keyAlias")
|
||||
keyPassword = requireSigningProperty("keyPassword")
|
||||
storeFile = file(requireSigningProperty("storeFile"))
|
||||
@@ -57,9 +47,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
// Temporary U-Verify test identity: the Yanting AppKey/login secret
|
||||
// loaded from assets/env/.env is registered against this package.
|
||||
applicationId = "com.yanting.read"
|
||||
applicationId = "com.jinzhi.app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
@@ -67,37 +55,17 @@ android {
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
}
|
||||
|
||||
val umengChannel = umengBuildValue("UMENG_CHANNEL").ifBlank { "test" }
|
||||
val umengChannel = System.getenv("UMENG_CHANNEL") ?: ""
|
||||
manifestPlaceholders.putAll(
|
||||
mutableMapOf(
|
||||
"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 {
|
||||
debug {
|
||||
// U-Verify validates the registered package-and-certificate pair.
|
||||
signingConfig = signingConfigs.getByName("yanting")
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("yanting")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_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" />
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />-->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />-->
|
||||
|
||||
|
||||
<application
|
||||
android:label="金值"
|
||||
android:name=".JinzhiApplication"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:usesCleartextTraffic="false">
|
||||
@@ -32,12 +31,6 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</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>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
@@ -48,12 +41,6 @@
|
||||
<meta-data
|
||||
android:name="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>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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,21 +1,5 @@
|
||||
package com.jinzhi.app
|
||||
|
||||
import com.umeng.commonsdk.UMConfigure
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class MainActivity : FlutterActivity()
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 394 B |
|
Before Width: | Height: | Size: 751 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 479 B |
|
Before Width: | Height: | Size: 369 B |
@@ -1,9 +0,0 @@
|
||||
<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,8 @@ allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// android_id on the Git main branch uses EasyProtector 1.1.2. Its
|
||||
// original JCenter publication is retired; Appodeal maintains it.
|
||||
maven("https://maven.aliyun.com/repository/public")
|
||||
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
||||
maven("https://maven.aliyun.com/repository/google")
|
||||
@@ -23,20 +25,6 @@ subprojects {
|
||||
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") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
<!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,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '15.0'
|
||||
# platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
@@ -39,14 +39,5 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |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
|
||||
@@ -1,11 +1,7 @@
|
||||
PODS:
|
||||
- app_tracking_transparency (0.0.1):
|
||||
- Flutter
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_app_update (0.0.1):
|
||||
- Flutter
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_paid (0.0.1):
|
||||
@@ -20,85 +16,36 @@ PODS:
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.4.8):
|
||||
- Flutter
|
||||
- platform_metadata (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- 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):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- app_tracking_transparency (from `.symlinks/plugins/app_tracking_transparency/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_paid (from `.symlinks/plugins/flutter_paid/ios`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- 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`)
|
||||
- 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`)
|
||||
- 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:
|
||||
app_tracking_transparency:
|
||||
:path: ".symlinks/plugins/app_tracking_transparency/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_app_update:
|
||||
:path: ".symlinks/plugins/flutter_app_update/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_paid:
|
||||
@@ -111,54 +58,29 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
platform_metadata:
|
||||
:path: ".symlinks/plugins/platform_metadata/ios"
|
||||
shared_preferences_foundation:
|
||||
: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:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_paid: 6055d15f439071fefb849e3cebc279915ed70db5
|
||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
||||
platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023
|
||||
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
|
||||
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||
|
||||
PODFILE CHECKSUM: c5e833ba06eec2784e07140760ce1599fc8e3bfc
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -198,7 +198,6 @@
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */,
|
||||
90B1F719ECC32B8C9ADF27D1 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -300,10 +299,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -347,23 +350,6 @@
|
||||
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;
|
||||
};
|
||||
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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -487,7 +473,6 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
@@ -498,7 +483,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -674,7 +659,6 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
@@ -685,7 +669,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -701,7 +685,6 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
@@ -712,7 +695,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"images":[{"filename":"jinzhi_onetap_back.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
||||
|
Before Width: | Height: | Size: 394 B |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 382 B |
|
Before Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 463 B |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 322 B |
|
Before Width: | Height: | Size: 598 B |
|
Before Width: | Height: | Size: 461 B |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1 +0,0 @@
|
||||
{"images":[{"filename":"jinzhi_onetap_checked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
||||
|
Before Width: | Height: | Size: 479 B |
@@ -1 +0,0 @@
|
||||
{"images":[{"filename":"jinzhi_onetap_unchecked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
||||
|
Before Width: | Height: | Size: 369 B |
@@ -1,75 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>金值</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>金值</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>jinzhi</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>金值</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>金值</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</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/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</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>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(APS_ENVIRONMENT)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -7,63 +5,15 @@ import 'package:shadcn_ui/shadcn_ui.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/spacing.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 ConsumerStatefulWidget {
|
||||
class JinzhiApp extends ConsumerWidget {
|
||||
const JinzhiApp({super.key});
|
||||
|
||||
@override
|
||||
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) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Keep the app-wide foreground refresh coordinator alive. Features opt in
|
||||
// by registering only while their screen is visible.
|
||||
ref.watch(refreshCoordinatorProvider);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/widgets.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/widgets/easy_refresh_localization.dart';
|
||||
@@ -15,7 +12,6 @@ import 'app.dart';
|
||||
Future<Widget> bootstrap() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
configureEasyRefreshChineseLocalization();
|
||||
await UmengConfig.load();
|
||||
await StorageService.instance;
|
||||
await SecureIdentityStorage(
|
||||
StorageService.to.preferences,
|
||||
@@ -29,10 +25,9 @@ Future<Widget> bootstrap() async {
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
}
|
||||
}
|
||||
if (!kIsWeb) {
|
||||
// Shorebird's native updater performs the automatic launch-time update.
|
||||
// Reading the running patch is diagnostic state and must not delay startup.
|
||||
unawaited(ShorebirdService.instance.initCurrentPatch());
|
||||
}
|
||||
// if (!kIsWeb) {
|
||||
// await ShorebirdService.instance.initCurrentPatch();
|
||||
// ShorebirdService.instance.checkForUpdateInBackground();
|
||||
// }
|
||||
return const JinzhiApp();
|
||||
}
|
||||
@@ -4,7 +4,7 @@ class AppConfig {
|
||||
factory AppConfig.fromEnvironment() {
|
||||
const url = String.fromEnvironment(
|
||||
'JINZHI_API_BASE_URL',
|
||||
defaultValue: 'https://yanting.api.hnsiderui.cn',
|
||||
defaultValue: 'https://jinzhi.api.local',
|
||||
);
|
||||
return const AppConfig(apiBaseUrl: url);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ class AppH5Urls {
|
||||
|
||||
static const baseUrl = String.fromEnvironment(
|
||||
'JINZHI_H5_BASE_URL',
|
||||
defaultValue: 'https://yanting.h5.hnsiderui.cn',
|
||||
defaultValue: 'https://jinzhi.h5.local',
|
||||
);
|
||||
|
||||
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
|
||||
|
||||
@@ -7,8 +7,6 @@ class UmengConfig {
|
||||
'6a27b1f46f259537c7b616be'; // Default placeholder
|
||||
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
|
||||
static String _androidPushSecret = 'xxx'; // Default placeholder
|
||||
static String _androidLoginSecret = '';
|
||||
static String _iosLoginSecret = '';
|
||||
|
||||
static Future<void> load() async {
|
||||
await dotenv.load(
|
||||
@@ -19,14 +17,9 @@ class UmengConfig {
|
||||
_iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey;
|
||||
_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 iosAppKey => _iosAppKey;
|
||||
static String get androidPushSecret => _androidPushSecret;
|
||||
static String get androidLoginSecret => _androidLoginSecret;
|
||||
static String get iosLoginSecret => _iosLoginSecret;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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,10 +15,8 @@ import '../../features/news/presentation/news_page.dart';
|
||||
import '../../features/profile/presentation/profile_page.dart';
|
||||
import '../../features/settings/presentation/settings_page.dart';
|
||||
import '../../features/settings/presentation/privacy_consent_page.dart';
|
||||
import '../../features/web/presentation/web_page.dart';
|
||||
import '../services/device_header_service.dart';
|
||||
import 'tab_shell.dart';
|
||||
import 'route_error_page.dart';
|
||||
|
||||
abstract final class AppRoutes {
|
||||
static const assets = '/assets';
|
||||
@@ -35,18 +33,13 @@ abstract final class AppRoutes {
|
||||
static const debugTypography = '/debug/typography';
|
||||
static const fancyDioInspector = '/debug/fancy_dio_inspector';
|
||||
static const privacyConsent = '/privacy-consent';
|
||||
static const web = '/web';
|
||||
}
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final startupListenable = ValueNotifier<int>(0);
|
||||
return GoRouter(
|
||||
navigatorKey: rootNavigatorKey,
|
||||
initialLocation: AppRoutes.assets,
|
||||
refreshListenable: startupListenable,
|
||||
errorBuilder: (_, state) => RouteErrorPage(error: state.error),
|
||||
redirect: (context, state) {
|
||||
final loc = state.matchedLocation;
|
||||
final onConsent = loc == AppRoutes.privacyConsent;
|
||||
@@ -138,19 +131,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: AppRoutes.privacyConsent,
|
||||
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',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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,18 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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 '../widgets/adaptive.dart';
|
||||
import 'app_router.dart';
|
||||
|
||||
class TabShell extends ConsumerStatefulWidget {
|
||||
class TabShell extends StatefulWidget {
|
||||
const TabShell({
|
||||
super.key,
|
||||
required this.navigationShell,
|
||||
@@ -23,29 +18,16 @@ class TabShell extends ConsumerStatefulWidget {
|
||||
final String location;
|
||||
|
||||
@override
|
||||
ConsumerState<TabShell> createState() => _TabShellState();
|
||||
State<TabShell> createState() => _TabShellState();
|
||||
}
|
||||
|
||||
class _TabShellState extends ConsumerState<TabShell> {
|
||||
class _TabShellState extends State<TabShell> {
|
||||
static const double _compactHeaderThreshold = 34;
|
||||
|
||||
bool _showCompactHeader = false;
|
||||
|
||||
int get _index => widget.navigationShell.currentIndex;
|
||||
|
||||
@override
|
||||
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
|
||||
void didUpdateWidget(covariant TabShell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -54,28 +36,9 @@ class _TabShellState extends ConsumerState<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
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final mall = ref.watch(mallTabConfigProvider);
|
||||
final header = _headerForPath(widget.location);
|
||||
final canPop = GoRouter.of(context).canPop();
|
||||
|
||||
@@ -84,144 +47,111 @@ class _TabShellState extends ConsumerState<TabShell> {
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Stack(
|
||||
children: [
|
||||
PopScope(
|
||||
canPop: !MallWebHostController.instance.state.value.visible,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop &&
|
||||
MallWebHostController.instance.state.value.visible) {
|
||||
MallWebHostController.instance.handleBack();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.background,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.background,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
toolbarHeight: 32,
|
||||
leading: canPop
|
||||
? IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.chevron_left, size: 20),
|
||||
)
|
||||
: null,
|
||||
title: AnimatedOpacity(
|
||||
opacity: _showCompactHeader ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
toolbarHeight: 32,
|
||||
leading: canPop
|
||||
? IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.chevron_left, size: 20),
|
||||
)
|
||||
: null,
|
||||
title: AnimatedOpacity(
|
||||
opacity: _showCompactHeader ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
header.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
height: 1.1,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
if (header.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
header.title,
|
||||
header.subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.1,
|
||||
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(
|
||||
color: cs.background,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: widget.navigationShell,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
border: Border(
|
||||
top: BorderSide(color: cs.border, width: 1),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 62,
|
||||
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Adaptive.tabsMaxWidth,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _labels.length; i++)
|
||||
Expanded(
|
||||
child: _TabButton(
|
||||
icon: i == _index
|
||||
? _filledIcons[i]
|
||||
: _outlineIcons[i],
|
||||
label: _labels[i],
|
||||
selected: i == _index,
|
||||
onTap: () =>
|
||||
widget.navigationShell.goBranch(
|
||||
i,
|
||||
initialLocation: i == _index,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mall.isVisible)
|
||||
Positioned(
|
||||
bottom: 39,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: _MallDockButton(
|
||||
title: mall.title,
|
||||
badge: mall.badgeText,
|
||||
onTap: () => _openMall(mall),
|
||||
),
|
||||
body: ColoredBox(
|
||||
color: cs.background,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: widget.navigationShell,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<MallWebHostState>(
|
||||
valueListenable: MallWebHostController.instance.state,
|
||||
builder: (context, state, _) => Positioned.fill(
|
||||
child: Offstage(
|
||||
offstage: !state.visible,
|
||||
child: MallWebHost(
|
||||
onExit: MallWebHostController.instance.dismiss,
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
border: Border(
|
||||
top: BorderSide(color: cs.border, width: 1),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 62,
|
||||
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Adaptive.tabsMaxWidth,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _labels.length; i++)
|
||||
Expanded(
|
||||
child: _TabButton(
|
||||
icon: i == _index
|
||||
? _filledIcons[i]
|
||||
: _outlineIcons[i],
|
||||
label: _labels[i],
|
||||
selected: i == _index,
|
||||
onTap: () =>
|
||||
widget.navigationShell.goBranch(
|
||||
i,
|
||||
initialLocation: i == _index,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -258,72 +188,6 @@ class _TabShellState extends ConsumerState<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 {
|
||||
const _TabButton({
|
||||
required this.icon,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ class DeviceHeaderService {
|
||||
static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL');
|
||||
|
||||
String _deviceId = '';
|
||||
String _packageName = '';
|
||||
String _version = '';
|
||||
String _build = '';
|
||||
String _deviceModel = '';
|
||||
@@ -69,7 +68,6 @@ class DeviceHeaderService {
|
||||
Future<void> _initializeBase() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
_packageName = info.packageName.trim();
|
||||
_version = info.version;
|
||||
_build = info.buildNumber;
|
||||
} catch (_) {}
|
||||
@@ -187,12 +185,6 @@ class DeviceHeaderService {
|
||||
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**(合规要求:同意前不得采集设备
|
||||
/// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
|
||||
bool get privacyConsentRequired => requiresAndroidPrivacyConsent(
|
||||
@@ -246,7 +238,6 @@ class DeviceHeaderService {
|
||||
/// 公共 header(不含 token)。11
|
||||
Map<String, String> commonHeaders() => _compact({
|
||||
'X-Device-Id': _deviceId,
|
||||
'pkg': _packageName,
|
||||
'User-Version': _version,
|
||||
'User-BuildID': _build,
|
||||
'User-Platform': _platform(),
|
||||
|
||||
@@ -7,7 +7,6 @@ const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
|
||||
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
||||
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
|
||||
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.
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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 拒绝/不可用不能阻断启动。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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)),
|
||||
);
|
||||
@@ -1,57 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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,80 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.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 '../../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';
|
||||
|
||||
class AssetsPage extends ConsumerStatefulWidget {
|
||||
class AssetsPage extends ConsumerWidget {
|
||||
const AssetsPage({super.key});
|
||||
|
||||
@override
|
||||
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) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final summary = ref.watch(assetPortfolioControllerProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return SafeArea(
|
||||
@@ -135,8 +71,6 @@ class _AssetsPageState extends ConsumerState<AssetsPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const HomeAdBanner(),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/auth_interceptor.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../data/auth_models.dart';
|
||||
import '../data/one_key_login_api.dart';
|
||||
|
||||
final authProvider =
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
||||
(ref) => AuthController(ref.read(dioProvider)),
|
||||
(ref) => AuthController(),
|
||||
);
|
||||
|
||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
AuthController(this._dio) : super(const AsyncLoading()) {
|
||||
AuthController() : super(const AsyncLoading()) {
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final token = await loadJwt();
|
||||
if (token == null || token.isEmpty) {
|
||||
@@ -59,24 +54,6 @@ 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 {
|
||||
await clearJwt();
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
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 }
|
||||
@@ -1,92 +0,0 @@
|
||||
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,7 +16,6 @@ import '../../../common/router/app_router.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/widgets/circle_back_button.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../application/login_launcher.dart';
|
||||
|
||||
class LoginPage extends HookConsumerWidget {
|
||||
const LoginPage({super.key, this.next});
|
||||
@@ -32,7 +31,6 @@ class LoginPage extends HookConsumerWidget {
|
||||
final agreed = useState(false);
|
||||
final sending = useState(false);
|
||||
final verifying = useState(false);
|
||||
final oneKeyLoading = useState(false);
|
||||
final countdown = useState(0);
|
||||
final error = useState<String?>(null);
|
||||
final timer = useRef<Timer?>(null);
|
||||
@@ -165,9 +163,6 @@ class LoginPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> launchOneKey() =>
|
||||
LoginLauncher.start(context, ref, next: next);
|
||||
|
||||
void onSendTap() {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (phoneDigits().length != 11) {
|
||||
@@ -227,8 +222,6 @@ class LoginPage extends HookConsumerWidget {
|
||||
agreed: agreed.value,
|
||||
onToggleAgreed: () => agreed.value = !agreed.value,
|
||||
onSend: onSendTap,
|
||||
oneKeyLoading: oneKeyLoading.value,
|
||||
onOneKey: launchOneKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -247,8 +240,6 @@ class _PhoneStep extends StatelessWidget {
|
||||
required this.onToggleAgreed,
|
||||
required this.error,
|
||||
required this.onSend,
|
||||
required this.oneKeyLoading,
|
||||
required this.onOneKey,
|
||||
});
|
||||
|
||||
final ShadColorScheme cs;
|
||||
@@ -260,8 +251,6 @@ class _PhoneStep extends StatelessWidget {
|
||||
final VoidCallback onToggleAgreed;
|
||||
final String? error;
|
||||
final VoidCallback onSend;
|
||||
final bool oneKeyLoading;
|
||||
final VoidCallback onOneKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -306,13 +295,6 @@ class _PhoneStep extends StatelessWidget {
|
||||
dim: !phoneReady,
|
||||
onTap: onSend,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PrimaryButton(
|
||||
label: '本机号码一键登录',
|
||||
enabled: !oneKeyLoading,
|
||||
loading: oneKeyLoading,
|
||||
onTap: onOneKey,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
|
||||
],
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
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,7 +6,6 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../../auth/application/login_launcher.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
import '../../profile/application/profile_settings_controller.dart';
|
||||
|
||||
@@ -59,6 +58,10 @@ class ProfilePage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => context.push(AppRoutes.settings),
|
||||
child: const Text('设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -100,7 +103,9 @@ class ProfilePage extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.tonal(
|
||||
onPressed: () => LoginLauncher.start(context, ref),
|
||||
onPressed: loggedIn
|
||||
? () => context.push(AppRoutes.login)
|
||||
: () => context.push(AppRoutes.login),
|
||||
child: Text(loggedIn ? '切换账号' : '去登录'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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),
|
||||
);
|
||||
@@ -1,36 +0,0 @@
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
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,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -10,13 +8,9 @@ import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.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/widgets/adaptive.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/tracking/umeng_service.dart';
|
||||
|
||||
class PrivacyConsentPage extends StatefulWidget {
|
||||
const PrivacyConsentPage({super.key});
|
||||
@@ -53,46 +47,12 @@ class _PrivacyConsentPageState extends State<PrivacyConsentPage> {
|
||||
Future<void> _agree() async {
|
||||
await DeviceHeaderService.to.markPrivacyAgreed();
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
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 (_) {}
|
||||
if (mounted) context.go(AppRoutes.assets);
|
||||
}
|
||||
|
||||
Future<void> _enter() async {
|
||||
await DeviceHeaderService.to.markIosStartupSeen();
|
||||
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 (_) {}
|
||||
if (mounted) context.go(AppRoutes.assets);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_remix/flutter_remix.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.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/push/notification_permission.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/services/cache_clear_service.dart';
|
||||
import '../../../common/theme/app_text.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/theme/theme_controller.dart';
|
||||
@@ -22,9 +20,6 @@ import '../../auth/data/auth_models.dart';
|
||||
import '../../profile/application/profile_settings_controller.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 {
|
||||
const SettingsPage({super.key});
|
||||
@@ -36,25 +31,6 @@ class SettingsPage extends HookConsumerWidget {
|
||||
final profileSettings = ref.watch(profileSettingsControllerProvider);
|
||||
final authAsync = ref.watch(authProvider);
|
||||
final loggedIn = authAsync.valueOrNull is LoggedInAuthState;
|
||||
final updateSupported = UpdateService.isSupported;
|
||||
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(
|
||||
backgroundColor: cs.background,
|
||||
@@ -150,36 +126,6 @@ class SettingsPage extends HookConsumerWidget {
|
||||
trailing: '占位',
|
||||
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) ...[
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
@@ -198,16 +144,18 @@ class SettingsPage extends HookConsumerWidget {
|
||||
_NavRow(
|
||||
icon: FlutterRemix.file_list_3_line,
|
||||
label: '用户协议',
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl))}',
|
||||
onTap: () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
icon: FlutterRemix.shield_check_line,
|
||||
label: '隐私政策',
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl))}',
|
||||
onTap: () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
@@ -244,27 +192,7 @@ class SettingsPage extends HookConsumerWidget {
|
||||
const _Section('关于'),
|
||||
_Card(
|
||||
children: [
|
||||
_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: '检查更新失败');
|
||||
}
|
||||
},
|
||||
),
|
||||
const _AboutRow(),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
icon: FlutterRemix.mail_line,
|
||||
@@ -287,38 +215,6 @@ 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 {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final ok = await showModalBottomSheet<bool>(
|
||||
@@ -892,7 +788,7 @@ class _ThemeSegmentCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final options = <(ThemeMode, String, IconData)>[
|
||||
(ThemeMode.system, '系统主题', Icons.brightness_auto_outlined),
|
||||
(ThemeMode.system, '跟随系统', Icons.brightness_auto_outlined),
|
||||
(ThemeMode.light, '浅色', Icons.light_mode_outlined),
|
||||
(ThemeMode.dark, '深色', Icons.dark_mode_outlined),
|
||||
];
|
||||
@@ -1052,7 +948,6 @@ class _NavRow extends StatelessWidget {
|
||||
required this.label,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.showArrow = false,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
@@ -1060,7 +955,6 @@ class _NavRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final bool showArrow;
|
||||
final bool destructive;
|
||||
|
||||
@override
|
||||
@@ -1087,7 +981,7 @@ class _NavRow extends StatelessWidget {
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
if (onTap != null && (trailing == null || showArrow))
|
||||
if (onTap != null && trailing == null)
|
||||
Icon(
|
||||
FlutterRemix.arrow_right_s_line,
|
||||
size: 20,
|
||||
@@ -1101,15 +995,7 @@ class _NavRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _AboutRow extends StatelessWidget {
|
||||
const _AboutRow({
|
||||
required this.upgrade,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final UpgradeInfo? upgrade;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
const _AboutRow();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1120,47 +1006,33 @@ class _AboutRow extends StatelessWidget {
|
||||
final v = snap.hasData
|
||||
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
||||
: '';
|
||||
final label = upgrade?.hasPublishedUpdate == true
|
||||
? '有新版 ${upgrade!.latestVersion}'
|
||||
: v;
|
||||
return InkWell(
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Icon(
|
||||
FlutterRemix.information_line,
|
||||
size: 20,
|
||||
color: cs.foreground,
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Icon(
|
||||
FlutterRemix.information_line,
|
||||
size: 20,
|
||||
color: cs.foreground,
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'金值',
|
||||
style: TextStyle(fontSize: 15, color: cs.foreground),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'金值',
|
||||
style: TextStyle(fontSize: 15, color: cs.foreground),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 13,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
v,
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 13,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
if (enabled) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
FlutterRemix.arrow_right_s_line,
|
||||
size: 20,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../application/update_progress.dart';
|
||||
import '../application/update_service.dart';
|
||||
import '../data/update_api_client.dart';
|
||||
|
||||
class UpdateDialog extends ConsumerStatefulWidget {
|
||||
const UpdateDialog({super.key, required this.upgrade, required this.source});
|
||||
|
||||
final UpgradeInfo upgrade;
|
||||
final UpgradeCheckSource source;
|
||||
|
||||
@override
|
||||
ConsumerState<UpdateDialog> createState() => _UpdateDialogState();
|
||||
}
|
||||
|
||||
class _UpdateDialogState extends ConsumerState<UpdateDialog> {
|
||||
bool _clickReported = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ensureUpdateProgressListener(context);
|
||||
final colorScheme = ShadTheme.of(context).colorScheme;
|
||||
final progress = ref.watch(updateProgressProvider);
|
||||
final forced = widget.upgrade.force == 1;
|
||||
final canStart = progress == null || progress <= 0 || progress == 1;
|
||||
final version = widget.upgrade.latestVersion.trim().isEmpty
|
||||
? widget.upgrade.versionCode
|
||||
: widget.upgrade.latestVersion;
|
||||
|
||||
return PopScope(
|
||||
canPop: !forced,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 28, 22, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'发现新版本 V$version',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: colorScheme.foreground,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (widget.upgrade.message.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.upgrade.message,
|
||||
style: TextStyle(
|
||||
color: colorScheme.mutedForeground,
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
if (canStart)
|
||||
FilledButton(
|
||||
onPressed: () => _startUpdate(progress),
|
||||
child: Text(progress == 1 ? '安装此版本' : '升级此版本'),
|
||||
)
|
||||
else
|
||||
LinearProgressIndicator(
|
||||
value: progress.clamp(0, 1),
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.muted,
|
||||
),
|
||||
if (!forced && !canStart) ...[
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('转后台下载'),
|
||||
),
|
||||
],
|
||||
if (!forced) ...[
|
||||
const SizedBox(height: 6),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startUpdate(double? progress) async {
|
||||
if (!_clickReported) {
|
||||
_clickReported = true;
|
||||
unawaited(_reportClick());
|
||||
}
|
||||
final localPackage = ref.read(updateUrlProvider);
|
||||
if (progress == 1 && localPackage != null && localPackage.isNotEmpty) {
|
||||
await UpdateService.install(localPackage);
|
||||
return;
|
||||
}
|
||||
await UpdateService.startUpdate(widget.upgrade.url);
|
||||
}
|
||||
|
||||
Future<void> _reportClick() async {
|
||||
if (widget.upgrade.versionCode.isEmpty) return;
|
||||
try {
|
||||
await ref
|
||||
.read(updateApiClientProvider)
|
||||
.reportClick(
|
||||
buildId: widget.upgrade.versionCode,
|
||||
source: widget.source == UpgradeCheckSource.manual
|
||||
? 'manual'
|
||||
: 'auto',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class MallWebHostState {
|
||||
const MallWebHostState({this.url, this.visible = false});
|
||||
final String? url;
|
||||
final bool visible;
|
||||
}
|
||||
|
||||
/// 进程常驻的商城 WebView 状态,切换原生 Tab 时不销毁商城页面。
|
||||
class MallWebHostController {
|
||||
MallWebHostController._();
|
||||
static final instance = MallWebHostController._();
|
||||
final state = ValueNotifier(const MallWebHostState());
|
||||
Future<void> Function()? _backHandler;
|
||||
|
||||
void preload(String url) {
|
||||
final old = state.value;
|
||||
if (old.url == url) return;
|
||||
state.value = MallWebHostState(url: url, visible: old.visible);
|
||||
}
|
||||
|
||||
void open(String url) =>
|
||||
state.value = MallWebHostState(url: url, visible: true);
|
||||
void dismiss() => state.value = MallWebHostState(url: state.value.url);
|
||||
void clear() => state.value = const MallWebHostState();
|
||||
void attachBackHandler(Future<void> Function() handler) =>
|
||||
_backHandler = handler;
|
||||
void detachBackHandler() => _backHandler = null;
|
||||
Future<void> handleBack() => _backHandler?.call() ?? Future.value();
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../application/mall_web_host_controller.dart';
|
||||
|
||||
/// 常驻在 Tab Shell 上层的商城 H5 宿主。
|
||||
class MallWebHost extends StatefulWidget {
|
||||
const MallWebHost({super.key, required this.onExit});
|
||||
final VoidCallback onExit;
|
||||
|
||||
@override
|
||||
State<MallWebHost> createState() => _MallWebHostState();
|
||||
}
|
||||
|
||||
class _MallWebHostState extends State<MallWebHost> {
|
||||
final _host = MallWebHostController.instance;
|
||||
WebViewController? _controller;
|
||||
String? _loadedUrl;
|
||||
String? _error;
|
||||
int _progress = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_host.state.addListener(_sync);
|
||||
_host.attachBackHandler(_goBack);
|
||||
_sync();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.state.removeListener(_sync);
|
||||
_host.detachBackHandler();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
final url = _host.state.value.url;
|
||||
if (url != null && url != _loadedUrl) _load(url);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _load(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
_error = '商城链接不可用';
|
||||
return;
|
||||
}
|
||||
_error = null;
|
||||
_progress = 0;
|
||||
_loadedUrl = rawUrl;
|
||||
_controller ??= WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close') widget.onExit();
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (value) {
|
||||
if (mounted) setState(() => _progress = value);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame == false) return;
|
||||
if (mounted) setState(() => _error = error.description);
|
||||
},
|
||||
onNavigationRequest: (request) async {
|
||||
final target = Uri.tryParse(request.url);
|
||||
if (target == null ||
|
||||
target.scheme == 'http' ||
|
||||
target.scheme == 'https') {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
if (await canLaunchUrl(target)) {
|
||||
await launchUrl(target, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
},
|
||||
),
|
||||
);
|
||||
unawaited(_controller!.loadRequest(uri));
|
||||
}
|
||||
|
||||
Future<void> _goBack() async {
|
||||
final controller = _controller;
|
||||
if (controller != null && await controller.canGoBack()) {
|
||||
await controller.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = _host.state.value;
|
||||
if (current.url == null) return const SizedBox.shrink();
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_controller != null)
|
||||
Positioned.fill(child: WebViewWidget(controller: _controller!)),
|
||||
if (_error != null)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
const Text('商城加载失败'),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => _load(_loadedUrl ?? ''),
|
||||
child: const Text('重新加载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_error == null && _progress < 100)
|
||||
const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
/// Shared HTTPS WebView host. Mall-specific residency and attribution are
|
||||
/// layered above this page so ordinary H5 links never receive mall state.
|
||||
class AppWebPage extends StatefulWidget {
|
||||
const AppWebPage({
|
||||
super.key,
|
||||
required this.initialUri,
|
||||
this.hideTitle = false,
|
||||
this.allowedHosts = const <String>{},
|
||||
});
|
||||
|
||||
final Uri initialUri;
|
||||
final bool hideTitle;
|
||||
final Set<String> allowedHosts;
|
||||
|
||||
@override
|
||||
State<AppWebPage> createState() => _AppWebPageState();
|
||||
}
|
||||
|
||||
class _AppWebPageState extends State<AppWebPage> {
|
||||
late final WebViewController _controller;
|
||||
var _progress = 0;
|
||||
String? _error;
|
||||
String _title = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close' && mounted) context.pop();
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (progress) => setState(() => _progress = progress),
|
||||
onPageFinished: (_) async {
|
||||
final title = await _controller.getTitle();
|
||||
if (mounted && title != null && title.trim().isNotEmpty) {
|
||||
setState(() => _title = title.trim());
|
||||
}
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame == true && mounted) {
|
||||
setState(() => _error = error.description);
|
||||
}
|
||||
},
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
),
|
||||
)
|
||||
..loadRequest(widget.initialUri);
|
||||
}
|
||||
|
||||
NavigationDecision _onNavigationRequest(NavigationRequest request) {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null || !_isHttp(uri)) return NavigationDecision.prevent;
|
||||
if (_allowsInApp(uri)) return NavigationDecision.navigate;
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
bool _allowsInApp(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
if (host == widget.initialUri.host.toLowerCase()) return true;
|
||||
return widget.allowedHosts.any(
|
||||
(allowed) => host == allowed || host.endsWith('.$allowed'),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isHttp(Uri uri) =>
|
||||
uri.hasAuthority && (uri.scheme == 'https' || uri.scheme == 'http');
|
||||
|
||||
Future<bool> _onPop() async {
|
||||
if (await _controller.canGoBack()) {
|
||||
await _controller.goBack();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = _error == null
|
||||
? Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_progress < 100)
|
||||
LinearProgressIndicator(value: _progress / 100),
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('页面加载失败\n$_error', textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (!didPop && await _onPop() && context.mounted) context.pop();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: widget.hideTitle
|
||||
? null
|
||||
: AppBar(title: Text(_title.isEmpty ? '加载中' : _title)),
|
||||
body: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Uri? parseSafeWebUri(String? raw) {
|
||||
final uri = Uri.tryParse(raw?.trim() ?? '');
|
||||
if (uri == null || !uri.hasAuthority) return null;
|
||||
return uri.scheme == 'https' || uri.scheme == 'http' ? uri : null;
|
||||
}
|
||||
@@ -34,14 +34,6 @@ packages:
|
||||
url: "https://gitea.neuronlabs.art/third-party-project/flutter-packages.git"
|
||||
source: git
|
||||
version: "0.4.0"
|
||||
android_intent_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: android_intent_plus
|
||||
sha256: "4d4bbd619d698cbee3c400ba89246ecb0f525146b0ae05389f62ede47abd5bec"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
android_oaid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -59,14 +51,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
app_tracking_transparency:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_tracking_transparency
|
||||
sha256: "3a52669c0645ffd7efe9a460047616de3e3f2128338f9ec7cdb681628d36238e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -432,14 +416,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
flutter_app_update:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_app_update
|
||||
sha256: "336a87c9b8f5c2f040ac314939f61a567e812a3ec3c066d8c99a524d95171d81"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.4"
|
||||
flutter_cache_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -976,54 +952,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.3"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.1"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.6.1"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4+1"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1438,32 +1366,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
umeng_common_sdk:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: umeng_common_sdk
|
||||
sha256: "8374ced1ab1d89f596ff71c0eff044d5395337145c9a69a6be02f5d8a678e42c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
umeng_push_sdk:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/umeng_push_sdk"
|
||||
ref: main
|
||||
resolved-ref: "51fd4ba38d57fdc8fb23e744aa65bf2368878b7f"
|
||||
url: "https://gitea.neuronlabs.art/third-party-project/flutter-packages.git"
|
||||
source: git
|
||||
version: "2.3.0"
|
||||
umeng_verify_sdk:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/umeng_verify_sdk"
|
||||
ref: main
|
||||
resolved-ref: "51fd4ba38d57fdc8fb23e744aa65bf2368878b7f"
|
||||
url: "https://gitea.neuronlabs.art/third-party-project/flutter-packages.git"
|
||||
source: git
|
||||
version: "2.1.0"
|
||||
universal_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1664,38 +1566,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.13.0"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.15.1"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: e50cd97ad28e888f6a4f862a05eab917a635417f9b134df64c3abb78250c6446
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.25.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,13 +39,6 @@ dependencies:
|
||||
# 网络图片与磁盘缓存
|
||||
cached_network_image: ^3.4.1
|
||||
flutter_cache_manager: ^3.4.1
|
||||
webview_flutter: 4.13.0
|
||||
webview_flutter_wkwebview: 3.25.0
|
||||
flutter_app_update: ^3.2.1
|
||||
android_intent_plus: ^6.0.0
|
||||
permission_handler: ^12.0.1
|
||||
umeng_common_sdk: 1.3.0
|
||||
app_tracking_transparency: ^2.0.6+1
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
video_player: ^2.10.0 # 启动页视频背景
|
||||
# 工具
|
||||
@@ -71,16 +64,6 @@ dependencies:
|
||||
git:
|
||||
url: https://gitea.neuronlabs.art/zhouyu/platform_metadata.git
|
||||
ref: main
|
||||
umeng_push_sdk:
|
||||
git:
|
||||
url: https://gitea.neuronlabs.art/third-party-project/flutter-packages.git
|
||||
path: packages/umeng_push_sdk
|
||||
ref: main
|
||||
umeng_verify_sdk:
|
||||
git:
|
||||
url: https://gitea.neuronlabs.art/third-party-project/flutter-packages.git
|
||||
path: packages/umeng_verify_sdk
|
||||
ref: main
|
||||
|
||||
flutter_dotenv: ^6.0.1
|
||||
fluttertoast: ^9.0.0
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/web/presentation/web_page.dart';
|
||||
|
||||
void main() {
|
||||
test('accepts only http and https URLs with a host', () {
|
||||
expect(
|
||||
parseSafeWebUri('https://h5.example.com/page')?.host,
|
||||
'h5.example.com',
|
||||
);
|
||||
expect(parseSafeWebUri('mailto:test@example.com'), isNull);
|
||||
expect(parseSafeWebUri('javascript:alert(1)'), isNull);
|
||||
expect(parseSafeWebUri(''), isNull);
|
||||
});
|
||||
}
|
||||