Author SHA1 Message Date
jingyun 50a1ac0af7 fix:报错处理 2026-08-14 09:39:11 +08:00
91 changed files with 226 additions and 5088 deletions

No files matched your search

+4 -32
View File
@@ -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
+4 -36
View File
@@ -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",
+5 -18
View File
@@ -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()
Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

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 -14
View File
@@ -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 -10
View File
@@ -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 -79
View File
@@ -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
+7 -24
View File
@@ -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}}
Binary file not shown.

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 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

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 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

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 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

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 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

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}}
Binary file not shown.

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}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 B

+52 -67
View File
@@ -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>
-8
View File
@@ -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>
+2 -52
View File
@@ -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);
+4 -9
View File
@@ -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();
}
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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
View File
@@ -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();
}
-86
View File
@@ -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));
}
}
-20
View File
@@ -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',
);
},
),
],
);
});
-43
View File
@@ -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);
}
-28
View File
@@ -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('回到首页'),
),
],
),
),
),
);
}
+93 -229
View File
@@ -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(),
-1
View File
@@ -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.
-82
View File
@@ -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());
}
}
}
-294
View File
@@ -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);
}
}
-122
View File
@@ -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),
),
],
),
);
}
}
-123
View File
@@ -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;
}
-130
View File
@@ -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:
-17
View File
@@ -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
-14
View File
@@ -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);
});
}