Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a96109bc | ||
|
|
428969245d | ||
|
|
2e10f01496 | ||
|
|
7d571e22eb | ||
|
|
088bb61411 | ||
|
|
aeb7ebd642 | ||
|
|
d59c3212b8 | ||
|
|
8cf919283b | ||
|
|
75ba309e8c | ||
|
|
3513fbac0f | ||
|
|
ab99010306 | ||
|
|
08023aff82 | ||
|
|
0b4b113de8 | ||
|
|
975f115859 | ||
|
|
2d7523f901 | ||
|
|
f42ad566e0 | ||
|
|
35eed6b5b8 |
No files matched your search
@@ -45,10 +45,3 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
/android/.kotlin/
|
|
||||||
|
|
||||||
# Release signing material and packaged runtime secrets are restored locally
|
|
||||||
# from the team's secure storage; never commit them.
|
|
||||||
/android/key.properties
|
|
||||||
/android/app/files/*.jks
|
|
||||||
/assets/env/.env
|
|
||||||
@@ -1,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_API_URL ?= https://jinzhi.api.yqzhice.top
|
||||||
WEB_BASE_HREF ?= /flutter/
|
WEB_BASE_HREF ?= /flutter/
|
||||||
SHOREBIRD_TOKEN_FILE ?= $(HOME)/.shorebird_tokens/jinzhi.env
|
|
||||||
SHOREBIRD = if [ ! -f "$(SHOREBIRD_TOKEN_FILE)" ]; then echo "错误: 缺少 Shorebird token 文件: $(SHOREBIRD_TOKEN_FILE)"; exit 1; fi; set -a; . "$(SHOREBIRD_TOKEN_FILE)"; set +a; shorebird
|
|
||||||
GITVERSION := $(shell git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)
|
GITVERSION := $(shell git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)
|
||||||
RELEASE_VERSION ?= latest
|
COMMON_DART_DEFINES := --dart-define=GITVERSION=$(GITVERSION)
|
||||||
PATCH_TRACK ?= staging
|
|
||||||
COMMON_DART_DEFINES := --dart-define="GITVERSION=$(GITVERSION)"
|
|
||||||
APP_VERSION := $(shell sed -n 's/^version: *//p' pubspec.yaml | head -n 1)
|
|
||||||
APK_VERSION_NAME := $(subst .,,${word 1,$(subst +, ,$(APP_VERSION))})
|
|
||||||
APK_VERSION_CODE := $(word 2,$(subst +, ,$(APP_VERSION)))
|
|
||||||
RELEASE_APK := jinzhi-$(APK_VERSION_NAME)-$(APK_VERSION_CODE)-release.apk
|
|
||||||
RELEASE_APK_DIR := build/app/outputs/flutter-apk
|
|
||||||
|
|
||||||
runner:
|
runner:
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
@@ -27,29 +18,10 @@ lint:
|
|||||||
flutter analyze
|
flutter analyze
|
||||||
|
|
||||||
apk:
|
apk:
|
||||||
flutter build apk --obfuscate --split-debug-info=./build/symbos $(COMMON_DART_DEFINES) --release --target-platform android-arm64
|
flutter build apk $(COMMON_DART_DEFINES) --release --target-platform android-arm64
|
||||||
$(MAKE) rename_release_apk
|
|
||||||
|
|
||||||
rename_release_apk:
|
|
||||||
@test -f "$(RELEASE_APK_DIR)/app-release.apk" || (echo "错误: 未找到 release APK: $(RELEASE_APK_DIR)/app-release.apk"; exit 1)
|
|
||||||
cp "$(RELEASE_APK_DIR)/app-release.apk" "$(RELEASE_APK_DIR)/$(RELEASE_APK)"
|
|
||||||
@echo "Release APK: $(RELEASE_APK_DIR)/$(RELEASE_APK)"
|
|
||||||
|
|
||||||
ipa:
|
ipa:
|
||||||
flutter build ipa --obfuscate --split-debug-info=./build/symbos $(COMMON_DART_DEFINES) --release
|
flutter build ipa $(COMMON_DART_DEFINES) --release
|
||||||
|
|
||||||
jinzhi_shorebird_android:
|
|
||||||
$(SHOREBIRD) release android --flutter-version=3.35.6 --split-debug-info=./build/symbos --target-platform android-arm64 --artifact apk -- --dart-define="GITVERSION=$(GITVERSION)"
|
|
||||||
$(MAKE) rename_release_apk
|
|
||||||
|
|
||||||
jinzhi_shorebird_ios:
|
|
||||||
$(SHOREBIRD) release ios --flutter-version=3.35.6 --split-debug-info=./build/symbos -- --dart-define="GITVERSION=$(GITVERSION)"
|
|
||||||
|
|
||||||
jinzhi_shorebird_android_patch:
|
|
||||||
$(SHOREBIRD) patch --platforms=android --release-version=$(RELEASE_VERSION) --split-debug-info=./build/symbos --track=$(PATCH_TRACK) -- --dart-define="GITVERSION=$(GITVERSION)"
|
|
||||||
|
|
||||||
jinzhi_shorebird_ios_patch:
|
|
||||||
$(SHOREBIRD) patch --platforms=ios --release-version=$(RELEASE_VERSION) --split-debug-info=./build/symbos --track=$(PATCH_TRACK) -- --dart-define="GITVERSION=$(GITVERSION)"
|
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
API_BASE_URL=$(WEB_API_URL) BASE_HREF=$(WEB_BASE_HREF) bash scripts/build_web.sh
|
API_BASE_URL=$(WEB_API_URL) BASE_HREF=$(WEB_BASE_HREF) bash scripts/build_web.sh
|
||||||
@@ -14,16 +14,6 @@ if (!androidSigningPropertiesFile.isFile) {
|
|||||||
}
|
}
|
||||||
androidSigningPropertiesFile.inputStream().use { androidSigningProperties.load(it) }
|
androidSigningPropertiesFile.inputStream().use { androidSigningProperties.load(it) }
|
||||||
|
|
||||||
val umengProperties = Properties()
|
|
||||||
val umengPropertiesFile = rootProject.file("../assets/env/.env")
|
|
||||||
if (umengPropertiesFile.isFile) {
|
|
||||||
umengPropertiesFile.inputStream().use { umengProperties.load(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun umengBuildValue(name: String): String =
|
|
||||||
System.getenv(name)?.takeIf { it.isNotBlank() }
|
|
||||||
?: umengProperties.getProperty(name)?.trim().orEmpty()
|
|
||||||
|
|
||||||
fun requireSigningProperty(name: String): String {
|
fun requireSigningProperty(name: String): String {
|
||||||
val value = androidSigningProperties.getProperty(name)?.trim()
|
val value = androidSigningProperties.getProperty(name)?.trim()
|
||||||
if (value.isNullOrBlank()) {
|
if (value.isNullOrBlank()) {
|
||||||
@@ -33,12 +23,12 @@ fun requireSigningProperty(name: String): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.jinzhi.app"
|
namespace = "com.jinzhi.butler"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("yanting") {
|
create("release") {
|
||||||
keyAlias = requireSigningProperty("keyAlias")
|
keyAlias = requireSigningProperty("keyAlias")
|
||||||
keyPassword = requireSigningProperty("keyPassword")
|
keyPassword = requireSigningProperty("keyPassword")
|
||||||
storeFile = file(requireSigningProperty("storeFile"))
|
storeFile = file(requireSigningProperty("storeFile"))
|
||||||
@@ -57,9 +47,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
// Temporary U-Verify test identity: the Yanting AppKey/login secret
|
applicationId = "com.jinzhi.butler"
|
||||||
// loaded from assets/env/.env is registered against this package.
|
|
||||||
applicationId = "com.yanting.read"
|
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -67,37 +55,17 @@ android {
|
|||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
ndk {
|
val umengChannel = System.getenv("UMENG_CHANNEL") ?: ""
|
||||||
abiFilters += "arm64-v8a"
|
|
||||||
}
|
|
||||||
|
|
||||||
val umengChannel = umengBuildValue("UMENG_CHANNEL").ifBlank { "test" }
|
|
||||||
manifestPlaceholders.putAll(
|
manifestPlaceholders.putAll(
|
||||||
mutableMapOf(
|
mutableMapOf(
|
||||||
"UMENG_CHANNEL" to umengChannel,
|
"UMENG_CHANNEL" to umengChannel,
|
||||||
"UMENG_APPKEY" to umengBuildValue("UMENG_ANDROID_APP_KEY"),
|
|
||||||
"UMENG_MESSAGE_SECRET" to umengBuildValue("UMENG_ANDROID_PUSH_SECRET"),
|
|
||||||
"XIAOMI_APP_ID" to umengBuildValue("XIAOMI_APP_ID"),
|
|
||||||
"XIAOMI_APP_KEY" to umengBuildValue("XIAOMI_APP_KEY"),
|
|
||||||
"OPPO_APP_KEY" to umengBuildValue("OPPO_APP_KEY"),
|
|
||||||
"OPPO_APP_SECRET" to umengBuildValue("OPPO_APP_SECRET"),
|
|
||||||
"MEIZU_APP_ID" to umengBuildValue("MEIZU_APP_ID"),
|
|
||||||
"MEIZU_APP_KEY" to umengBuildValue("MEIZU_APP_KEY"),
|
|
||||||
"HUAWEI_APP_ID" to umengBuildValue("HUAWEI_APP_ID"),
|
|
||||||
"HONOR_APP_ID" to umengBuildValue("HONOR_APP_ID"),
|
|
||||||
"VIVO_APP_KEY" to umengBuildValue("VIVO_APP_KEY"),
|
|
||||||
"VIVO_APP_ID" to umengBuildValue("VIVO_APP_ID"),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
|
||||||
// U-Verify validates the registered package-and-certificate pair.
|
|
||||||
signingConfig = signingConfigs.getByName("yanting")
|
|
||||||
}
|
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("yanting")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
<!-- Debug-only local API support. Release inherits false from main. -->
|
the Flutter tool needs it to communicate with the running application
|
||||||
<application
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
android:usesCleartextTraffic="true"
|
-->
|
||||||
tools:replace="android:usesCleartextTraffic" />
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<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
|
<application
|
||||||
android:label="金值"
|
android:label="金值"
|
||||||
android:name=".JinzhiApplication"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:usesCleartextTraffic="false">
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -32,12 +35,6 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="jinzhi" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
@@ -48,12 +45,6 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="UMENG_CHANNEL"
|
android:name="UMENG_CHANNEL"
|
||||||
android:value="${UMENG_CHANNEL}" />
|
android:value="${UMENG_CHANNEL}" />
|
||||||
<meta-data
|
|
||||||
android:name="UMENG_APPKEY"
|
|
||||||
android:value="${UMENG_APPKEY}" />
|
|
||||||
<meta-data
|
|
||||||
android:name="UMENG_MESSAGE_SECRET"
|
|
||||||
android:value="${UMENG_MESSAGE_SECRET}" />
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.jinzhi.butler
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="52dp"
|
|
||||||
android:height="60dp"
|
|
||||||
android:viewportWidth="104"
|
|
||||||
android:viewportHeight="120">
|
|
||||||
<path
|
|
||||||
android:fillColor="#F7F7F7"
|
|
||||||
android:pathData="M31,48L15,60L31,72L36,67L27,61L43,61L43,53L27,53L36,53Z" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 394 B |
|
Before Width: | Height: | Size: 751 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 479 B |
|
Before Width: | Height: | Size: 369 B |
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="52dp"
|
|
||||||
android:height="60dp"
|
|
||||||
android:viewportWidth="104"
|
|
||||||
android:viewportHeight="120">
|
|
||||||
<path
|
|
||||||
android:fillColor="#252529"
|
|
||||||
android:pathData="M31,48L15,60L31,72L36,67L27,61L43,61L43,53L27,53L36,53Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -2,10 +2,6 @@ allprojects {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven("https://maven.aliyun.com/repository/public")
|
|
||||||
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
|
||||||
maven("https://maven.aliyun.com/repository/google")
|
|
||||||
maven("https://maven.aliyun.com/repository/jcenter")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,20 +19,6 @@ subprojects {
|
|||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Gitea U-Verify plugin still targets an older Android Gradle Plugin and
|
|
||||||
// declares only the manifest package. AGP 8 requires an explicit namespace;
|
|
||||||
// keep the dependency as a Git package and supply its published namespace here.
|
|
||||||
subprojects {
|
|
||||||
plugins.withId("com.android.library") {
|
|
||||||
if (project.name == "umeng_verify_sdk") {
|
|
||||||
val androidExtension = extensions.getByName("android")
|
|
||||||
androidExtension.javaClass
|
|
||||||
.getMethod("setNamespace", String::class.java)
|
|
||||||
.invoke(androidExtension, "com.umeng.umeng_verify_sdk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
storePassword=jinzhi123
|
||||||
|
keyPassword=jinzhi123
|
||||||
|
keyAlias=jinzhi
|
||||||
|
storeFile=files/jinzhi.jks
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# .env file
|
||||||
|
# UMENG keys for Muzhi environment (replace with actual keys later)
|
||||||
|
UMENG_ANDROID_APP_KEY=6a27b1f46f259537c7b616be
|
||||||
|
UMENG_IOS_APP_KEY=6a27b24e6f259537c7b617a8
|
||||||
|
UMENG_ANDROID_PUSH_SECRET=xxx
|
||||||
|
# UMENG_CHANNEL is usually set via build flavors or defaults, but can be here if needed.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
应用名称:
|
||||||
|
金值
|
||||||
|
|
||||||
|
iOS平台
|
||||||
|
Bundle ID
|
||||||
|
|
||||||
|
公钥
|
||||||
|
|
||||||
|
证书SHA-1指纹
|
||||||
|
|
||||||
|
Android
|
||||||
|
包名:
|
||||||
|
com.jinzhi.butler
|
||||||
|
MD5:
|
||||||
|
bf875d2980f09f3acca3614b10aa0463
|
||||||
|
公钥(16进制)
|
||||||
|
a61ce1268140ae32cd7aeb43dfb2324a1bb04aba5c7cd5879e6f3b3e052f926ef80b9eccfc53b057a6a01d7dadf573ac19bf4bcfa9c4296fae20a0e1f4f598685321e4b3767bbc3a98d6afd056a522194528c3df1fed3872c697c49bc40fdc963071018ec644233e19e78bae9d3b5e5925e925322c5211f12fb079aa19464abd44bb782d81a30cc55c1b090f691d18aeb73beb26a710f45bb31a605ccf6519fe17e03130666e787bd2de0ceb8fd4bb66f2fd706af34db7840a504da090bb37f50e06153a84c3215ab1e6cbbb225b681baa84d90f97f1e245664d3fb6a1072506f4ba0548a4c28d84b239ed8500b0eb914bd3585565658e964317b48c0d84791b
|
||||||
|
|
||||||
|
签名证书SHA-1指纹
|
||||||
|
70 A8 9B 61 BE 5C 51 0A B8 42 78 2A FE 66 3A 4D 55 B9 10 4F
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
platform :ios, '15.0'
|
# platform :ios, '13.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
@@ -39,13 +39,12 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
|
|
||||||
target.build_configurations.each do |config|
|
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'] ||= [
|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
|
||||||
'$(inherited)',
|
'$(inherited)',
|
||||||
'PERMISSION_NOTIFICATIONS=1',
|
'PERMISSION_CAMERA=1',
|
||||||
|
'PERMISSION_PHOTOS=1',
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- app_tracking_transparency (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- device_info_plus (0.0.1):
|
- device_info_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_app_update (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- flutter_native_splash (2.4.3):
|
- flutter_native_splash (2.4.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_paid (0.0.1):
|
- flutter_paid (0.0.1):
|
||||||
@@ -15,6 +11,11 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- fluttertoast (0.0.2):
|
- fluttertoast (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- gal (1.0.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- image_picker_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -22,83 +23,50 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- permission_handler_apple (9.4.8):
|
- permission_handler_apple (9.4.8):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- photo_manager (3.9.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- platform_metadata (0.0.1):
|
- platform_metadata (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- share_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- sqflite_darwin (0.0.4):
|
- sqflite_darwin (0.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- UMCCommonLog (2.0.2)
|
|
||||||
- UMCommon (7.6.4):
|
|
||||||
- UMDevice
|
|
||||||
- UMDevice (3.6.0)
|
|
||||||
- umeng_common_sdk (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- UMCommon
|
|
||||||
- UMDevice
|
|
||||||
- umeng_push_sdk (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- UMCCommonLog
|
|
||||||
- UMCommon
|
|
||||||
- UMDevice
|
|
||||||
- UMPush
|
|
||||||
- umeng_verify_sdk (2.1.0):
|
|
||||||
- Flutter
|
|
||||||
- UMVerify (>= 3.3.0)
|
|
||||||
- UMPush (4.1.4):
|
|
||||||
- UMCommon
|
|
||||||
- UMVerify (4.0.0):
|
|
||||||
- UMCommon
|
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- video_player_avfoundation (0.0.1):
|
- video_player_avfoundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- webview_flutter_wkwebview (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- app_tracking_transparency (from `.symlinks/plugins/app_tracking_transparency/ios`)
|
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
|
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
- flutter_paid (from `.symlinks/plugins/flutter_paid/ios`)
|
- flutter_paid (from `.symlinks/plugins/flutter_paid/ios`)
|
||||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||||
|
- gal (from `.symlinks/plugins/gal/darwin`)
|
||||||
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
|
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
|
||||||
- platform_metadata (from `.symlinks/plugins/platform_metadata/ios`)
|
- platform_metadata (from `.symlinks/plugins/platform_metadata/ios`)
|
||||||
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- umeng_common_sdk (from `.symlinks/plugins/umeng_common_sdk/ios`)
|
|
||||||
- umeng_push_sdk (from `.symlinks/plugins/umeng_push_sdk/ios`)
|
|
||||||
- umeng_verify_sdk (from `.symlinks/plugins/umeng_verify_sdk/ios`)
|
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- UMCCommonLog
|
|
||||||
- UMCommon
|
|
||||||
- UMDevice
|
|
||||||
- UMPush
|
|
||||||
- UMVerify
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
app_tracking_transparency:
|
|
||||||
:path: ".symlinks/plugins/app_tracking_transparency/ios"
|
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_app_update:
|
|
||||||
:path: ".symlinks/plugins/flutter_app_update/ios"
|
|
||||||
flutter_native_splash:
|
flutter_native_splash:
|
||||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
flutter_paid:
|
flutter_paid:
|
||||||
@@ -107,58 +75,51 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||||
fluttertoast:
|
fluttertoast:
|
||||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||||
|
gal:
|
||||||
|
:path: ".symlinks/plugins/gal/darwin"
|
||||||
|
image_picker_ios:
|
||||||
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
|
photo_manager:
|
||||||
|
:path: ".symlinks/plugins/photo_manager/darwin"
|
||||||
platform_metadata:
|
platform_metadata:
|
||||||
:path: ".symlinks/plugins/platform_metadata/ios"
|
:path: ".symlinks/plugins/platform_metadata/ios"
|
||||||
|
share_plus:
|
||||||
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
umeng_common_sdk:
|
|
||||||
:path: ".symlinks/plugins/umeng_common_sdk/ios"
|
|
||||||
umeng_push_sdk:
|
|
||||||
:path: ".symlinks/plugins/umeng_push_sdk/ios"
|
|
||||||
umeng_verify_sdk:
|
|
||||||
:path: ".symlinks/plugins/umeng_verify_sdk/ios"
|
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
video_player_avfoundation:
|
video_player_avfoundation:
|
||||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||||
webview_flutter_wkwebview:
|
|
||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92
|
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
|
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
flutter_paid: 6055d15f439071fefb849e3cebc279915ed70db5
|
flutter_paid: 6055d15f439071fefb849e3cebc279915ed70db5
|
||||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||||
|
gal: baecd024ebfd13c441269ca7404792a7152fde89
|
||||||
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
||||||
|
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
|
||||||
platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023
|
platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023
|
||||||
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
UMCCommonLog: bea707e50c85cef4b0eb47cc5c7226bb843245ca
|
|
||||||
UMCommon: 001691e59cb7961ec21d3312687499c8516dddc6
|
|
||||||
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
|
|
||||||
umeng_common_sdk: 095b63e6f83b71548725a7c03a6c17b23be6674b
|
|
||||||
umeng_push_sdk: 557ebb6834df105bd828b86eeb8ae0b80959ee79
|
|
||||||
umeng_verify_sdk: 64a823bd4528bf46c40759c613d6ab34935b8293
|
|
||||||
UMPush: e9e81f259799b2b238ee21cc152e4cea31909604
|
|
||||||
UMVerify: d961ffe2e4b9f00ad578c373e420ad17f72a1f7c
|
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: c5e833ba06eec2784e07140760ce1599fc8e3bfc
|
PODFILE CHECKSUM: b6c30c22d7ac3adf21bf40601823fcf685f5b3d6
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */,
|
38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */,
|
||||||
90B1F719ECC32B8C9ADF27D1 /* [CP] Copy Pods Resources */,
|
EA4FE8E3948A035FEE08B35D /* [CP] Copy Pods Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -347,7 +347,22 @@
|
|||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
90B1F719ECC32B8C9ADF27D1 /* [CP] Copy Pods Resources */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Run Script";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
|
};
|
||||||
|
EA4FE8E3948A035FEE08B35D /* [CP] Copy Pods Resources */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -364,21 +379,6 @@
|
|||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Run Script";
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -487,7 +487,6 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
@@ -498,7 +497,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -674,7 +673,6 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
@@ -685,7 +683,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -701,7 +699,6 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
@@ -712,7 +709,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"images":[{"filename":"jinzhi_onetap_back.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
|
||||||
|
Before Width: | Height: | Size: 394 B |
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{ "filename" : "jinzhi_onetap_back_day.png", "idiom" : "universal", "scale" : "1x" },
|
|
||||||
{ "filename" : "jinzhi_onetap_back_day@2x.png", "idiom" : "universal", "scale" : "2x" },
|
|
||||||
{ "filename" : "jinzhi_onetap_back_day@3x.png", "idiom" : "universal", "scale" : "3x" }
|
|
||||||
],
|
|
||||||
"info" : { "author" : "xcode", "version" : 1 }
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 382 B |
|
Before Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 463 B |
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{ "filename" : "jinzhi_onetap_back_night.png", "idiom" : "universal", "scale" : "1x" },
|
|
||||||
{ "filename" : "jinzhi_onetap_back_night@2x.png", "idiom" : "universal", "scale" : "2x" },
|
|
||||||
{ "filename" : "jinzhi_onetap_back_night@3x.png", "idiom" : "universal", "scale" : "3x" }
|
|
||||||
],
|
|
||||||
"info" : { "author" : "xcode", "version" : 1 }
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 322 B |
|
Before Width: | Height: | Size: 598 B |
|
Before Width: | Height: | Size: 461 B |
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{ "filename" : "jinzhi_onetap_button.png", "idiom" : "universal", "scale" : "1x" },
|
|
||||||
{ "filename" : "jinzhi_onetap_button@2x.png", "idiom" : "universal", "scale" : "2x" },
|
|
||||||
{ "filename" : "jinzhi_onetap_button@3x.png", "idiom" : "universal", "scale" : "3x" }
|
|
||||||
],
|
|
||||||
"info" : { "author" : "xcode", "version" : 1 }
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{ "filename" : "jinzhi_onetap_button_pressed.png", "idiom" : "universal", "scale" : "1x" },
|
|
||||||
{ "filename" : "jinzhi_onetap_button_pressed@2x.png", "idiom" : "universal", "scale" : "2x" },
|
|
||||||
{ "filename" : "jinzhi_onetap_button_pressed@3x.png", "idiom" : "universal", "scale" : "3x" }
|
|
||||||
],
|
|
||||||
"info" : { "author" : "xcode", "version" : 1 }
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1 +0,0 @@
|
|||||||
{"images":[{"filename":"jinzhi_onetap_checked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
|
||||||
|
Before Width: | Height: | Size: 479 B |
@@ -1 +0,0 @@
|
|||||||
{"images":[{"filename":"jinzhi_onetap_unchecked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
|
|
||||||
|
Before Width: | Height: | Size: 369 B |
@@ -1,75 +1,66 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
|
||||||
<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>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<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/>
|
<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/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>用于为贵金属持仓添加发票、证书及实物照片。</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>用于为贵金属持仓添加发票、证书及实物照片。</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>用于为贵金属持仓添加发票、证书及实物照片。</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSUserTrackingUsageDescription</key>
|
|
||||||
<string>用于衡量广告投放效果并提供更相关的内容。</string>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
|
||||||
<key>UILaunchStoryboardName</key>
|
|
||||||
<string>LaunchScreen</string>
|
|
||||||
<key>UIMainStoryboardFile</key>
|
|
||||||
<string>Main</string>
|
|
||||||
<key>UIStatusBarHidden</key>
|
|
||||||
<true/>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,94 +1,50 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../common/router/app_router.dart';
|
import '../common/router/app_router.dart';
|
||||||
import '../common/lifecycle/refresh_coordinator.dart';
|
|
||||||
import '../common/push/device_push_registrar.dart';
|
|
||||||
import '../common/push/umeng_push_service.dart';
|
|
||||||
import '../common/theme/jinzhi_theme.dart';
|
import '../common/theme/jinzhi_theme.dart';
|
||||||
import '../common/theme/spacing.dart';
|
import '../common/theme/spacing.dart';
|
||||||
import '../common/theme/theme_controller.dart';
|
import '../common/theme/theme_controller.dart';
|
||||||
import '../features/ads/application/ad_config_foreground_refresher.dart';
|
import '../common/widgets/smart_refresh.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});
|
const JinzhiApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<JinzhiApp> createState() => _JinzhiAppState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
|
||||||
|
|
||||||
class _JinzhiAppState extends ConsumerState<JinzhiApp>
|
|
||||||
with WidgetsBindingObserver {
|
|
||||||
late final DevicePushRegistrar _pushRegistrar;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
_pushRegistrar = DevicePushRegistrar(ref.read)..start();
|
|
||||||
UmengPushService.configureNotificationOpen(DeepLinkService.handle);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
// 首次 iOS 启动在欢迎页点击进入后才注册 APNs,确保系统弹窗不被启动页
|
|
||||||
// 或其他 Overlay 吞掉;后续启动和 Android 保持原有自动初始化。
|
|
||||||
if (!DeviceHeaderService.to.iosStartupRequired) {
|
|
||||||
unawaited(UmengPushService.startIfAllowed());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
unawaited(_pushRegistrar.stop());
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
||||||
if (state == AppLifecycleState.resumed) {
|
|
||||||
unawaited(_pushRegistrar.refresh());
|
|
||||||
unawaited(ref.read(adConfigForegroundRefresherProvider).refresh());
|
|
||||||
final context = rootNavigatorKey.currentContext;
|
|
||||||
if (context != null) {
|
|
||||||
unawaited(UpdateForegroundService.check(context, ref));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// Keep the app-wide foreground refresh coordinator alive. Features opt in
|
|
||||||
// by registering only while their screen is visible.
|
|
||||||
ref.watch(refreshCoordinatorProvider);
|
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
final mode = ref.watch(themeModeControllerProvider);
|
final mode = ref.watch(themeModeControllerProvider);
|
||||||
// DM Sans 作主字体(拉丁/数字),中文走系统级联(iOS 苹方 / Android 厂商
|
final textTheme = ShadTextTheme();
|
||||||
// 中文字体)——主流做法,不显式点名、不内置字体。
|
return RefreshConfiguration(
|
||||||
final dmSansFamily = GoogleFonts.dmSans().fontFamily;
|
springDescription: const SpringDescription(
|
||||||
final textTheme = ShadTextTheme(family: dmSansFamily);
|
mass: 1,
|
||||||
return ShadApp.router(
|
stiffness: 364.718677686,
|
||||||
title: '金值',
|
damping: 35.2,
|
||||||
themeMode: mode,
|
|
||||||
theme: ShadThemeData(
|
|
||||||
brightness: Brightness.light,
|
|
||||||
colorScheme: jinzhiLightScheme,
|
|
||||||
textTheme: textTheme,
|
|
||||||
radius: BorderRadius.circular(Spacing.radiusBase),
|
|
||||||
),
|
),
|
||||||
darkTheme: ShadThemeData(
|
headerBuilder: () => const MaterialClassicHeader(),
|
||||||
brightness: Brightness.dark,
|
footerBuilder: () => jinzhiClassicFooter,
|
||||||
colorScheme: jinzhiDarkScheme,
|
hideFooterWhenNotFull: true,
|
||||||
textTheme: textTheme,
|
headerTriggerDistance: 60,
|
||||||
radius: BorderRadius.circular(Spacing.radiusBase),
|
maxOverScrollExtent: 100,
|
||||||
|
child: ShadApp.router(
|
||||||
|
title: '金值',
|
||||||
|
themeMode: mode,
|
||||||
|
theme: ShadThemeData(
|
||||||
|
brightness: Brightness.light,
|
||||||
|
colorScheme: jinzhiLightScheme,
|
||||||
|
textTheme: textTheme,
|
||||||
|
radius: BorderRadius.circular(Spacing.radiusBase),
|
||||||
|
),
|
||||||
|
darkTheme: ShadThemeData(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
colorScheme: jinzhiDarkScheme,
|
||||||
|
textTheme: textTheme,
|
||||||
|
radius: BorderRadius.circular(Spacing.radiusBase),
|
||||||
|
),
|
||||||
|
routerConfig: router,
|
||||||
),
|
),
|
||||||
routerConfig: router,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,15 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import '../common/services/device_header_service.dart';
|
import '../common/services/device_header_service.dart';
|
||||||
import '../common/config/shorebird_service.dart';
|
|
||||||
import '../common/config/umeng_config.dart';
|
|
||||||
import '../common/storage/secure_identity_storage.dart';
|
|
||||||
import '../common/storage/storage_service.dart';
|
import '../common/storage/storage_service.dart';
|
||||||
import '../common/widgets/easy_refresh_localization.dart';
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
|
||||||
Future<Widget> bootstrap() async {
|
Future<Widget> bootstrap() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
configureEasyRefreshChineseLocalization();
|
|
||||||
await UmengConfig.load();
|
|
||||||
await StorageService.instance;
|
await StorageService.instance;
|
||||||
await SecureIdentityStorage(
|
|
||||||
StorageService.to.preferences,
|
|
||||||
).restoreAccessToken();
|
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
// Before consent this only initializes app version and channel. The
|
// Before consent this only initializes app version and channel. The
|
||||||
@@ -29,10 +19,9 @@ Future<Widget> bootstrap() async {
|
|||||||
await DeviceHeaderService.to.ensureInitialized();
|
await DeviceHeaderService.to.ensureInitialized();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!kIsWeb) {
|
// if (!kIsWeb) {
|
||||||
// Shorebird's native updater performs the automatic launch-time update.
|
// await ShorebirdService.instance.initCurrentPatch();
|
||||||
// Reading the running patch is diagnostic state and must not delay startup.
|
// ShorebirdService.instance.checkForUpdateInBackground();
|
||||||
unawaited(ShorebirdService.instance.initCurrentPatch());
|
// }
|
||||||
}
|
|
||||||
return const JinzhiApp();
|
return const JinzhiApp();
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ class AppConfig {
|
|||||||
factory AppConfig.fromEnvironment() {
|
factory AppConfig.fromEnvironment() {
|
||||||
const url = String.fromEnvironment(
|
const url = String.fromEnvironment(
|
||||||
'JINZHI_API_BASE_URL',
|
'JINZHI_API_BASE_URL',
|
||||||
defaultValue: 'https://yanting.api.hnsiderui.cn',
|
defaultValue: 'https://jinzhi.api.yqzhice.top',
|
||||||
);
|
);
|
||||||
return const AppConfig(apiBaseUrl: url);
|
return const AppConfig(apiBaseUrl: url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class AppH5Urls {
|
|||||||
|
|
||||||
static const baseUrl = String.fromEnvironment(
|
static const baseUrl = String.fromEnvironment(
|
||||||
'JINZHI_H5_BASE_URL',
|
'JINZHI_H5_BASE_URL',
|
||||||
defaultValue: 'https://yanting.h5.hnsiderui.cn',
|
defaultValue: 'https://jinzhi.h5.yqzhice.top',
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
|
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ class UmengConfig {
|
|||||||
'6a27b1f46f259537c7b616be'; // Default placeholder
|
'6a27b1f46f259537c7b616be'; // Default placeholder
|
||||||
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
|
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
|
||||||
static String _androidPushSecret = 'xxx'; // Default placeholder
|
static String _androidPushSecret = 'xxx'; // Default placeholder
|
||||||
static String _androidLoginSecret = '';
|
|
||||||
static String _iosLoginSecret = '';
|
|
||||||
|
|
||||||
static Future<void> load() async {
|
static Future<void> load() async {
|
||||||
await dotenv.load(
|
await dotenv.load(
|
||||||
@@ -19,14 +17,9 @@ class UmengConfig {
|
|||||||
_iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey;
|
_iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey;
|
||||||
_androidPushSecret =
|
_androidPushSecret =
|
||||||
dotenv.env['UMENG_ANDROID_PUSH_SECRET'] ?? _androidPushSecret;
|
dotenv.env['UMENG_ANDROID_PUSH_SECRET'] ?? _androidPushSecret;
|
||||||
_androidLoginSecret =
|
|
||||||
dotenv.env['UMENG_ANDROID_LOGIN_SECRET'] ?? _androidLoginSecret;
|
|
||||||
_iosLoginSecret = dotenv.env['UMENG_IOS_LOGIN_SECRET'] ?? _iosLoginSecret;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String get androidAppKey => _androidAppKey;
|
static String get androidAppKey => _androidAppKey;
|
||||||
static String get iosAppKey => _iosAppKey;
|
static String get iosAppKey => _iosAppKey;
|
||||||
static String get androidPushSecret => _androidPushSecret;
|
static String get androidPushSecret => _androidPushSecret;
|
||||||
static String get androidLoginSecret => _androidLoginSecret;
|
|
||||||
static String get iosLoginSecret => _iosLoginSecret;
|
|
||||||
}
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
typedef RefreshCallback = FutureOr<void> Function();
|
|
||||||
|
|
||||||
/// Coordinates safe refresh points without owning any feature data.
|
|
||||||
///
|
|
||||||
/// A screen registers its callback while it is visible. Registered callbacks
|
|
||||||
/// run when the app returns to the foreground; callers can also request a
|
|
||||||
/// manual refresh through the returned handle.
|
|
||||||
class RefreshCoordinator with WidgetsBindingObserver {
|
|
||||||
RefreshCoordinator() {
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Set<_RefreshRegistration> _registrations = {};
|
|
||||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
|
||||||
|
|
||||||
bool get isForeground => _lifecycleState == AppLifecycleState.resumed;
|
|
||||||
|
|
||||||
RefreshRegistration register({
|
|
||||||
required RefreshCallback onRefresh,
|
|
||||||
bool active = true,
|
|
||||||
}) {
|
|
||||||
final registration = _RefreshRegistration(onRefresh, active);
|
|
||||||
_registrations.add(registration);
|
|
||||||
return RefreshRegistration._(this, registration);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
||||||
_lifecycleState = state;
|
|
||||||
if (state == AppLifecycleState.resumed) refreshVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
void refreshVisible() {
|
|
||||||
for (final registration in _registrations) {
|
|
||||||
if (registration.active) unawaited(_run(registration));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _run(_RefreshRegistration registration) async {
|
|
||||||
try {
|
|
||||||
await registration.onRefresh();
|
|
||||||
} catch (_) {
|
|
||||||
// Refresh failures belong to the owning feature's state/UI.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
_registrations.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RefreshRegistration {
|
|
||||||
RefreshRegistration._(this._coordinator, this._registration);
|
|
||||||
|
|
||||||
final RefreshCoordinator _coordinator;
|
|
||||||
final _RefreshRegistration _registration;
|
|
||||||
|
|
||||||
bool get active => _registration.active;
|
|
||||||
|
|
||||||
set active(bool value) => _registration.active = value;
|
|
||||||
|
|
||||||
void refreshNow() => _coordinator._run(_registration);
|
|
||||||
|
|
||||||
void dispose() => _coordinator._registrations.remove(_registration);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RefreshRegistration {
|
|
||||||
_RefreshRegistration(this.onRefresh, this.active);
|
|
||||||
|
|
||||||
final RefreshCallback onRefresh;
|
|
||||||
bool active;
|
|
||||||
}
|
|
||||||
|
|
||||||
final refreshCoordinatorProvider = Provider<RefreshCoordinator>((ref) {
|
|
||||||
final coordinator = RefreshCoordinator();
|
|
||||||
ref.onDispose(coordinator.dispose);
|
|
||||||
return coordinator;
|
|
||||||
});
|
|
||||||
@@ -1,22 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import '../services/device_header_service.dart';
|
import '../services/device_header_service.dart';
|
||||||
import '../storage/secure_identity_storage.dart';
|
|
||||||
import '../storage/storage_service.dart';
|
|
||||||
|
|
||||||
final _defaultIdentityStorage = SecureIdentityStorage(
|
const _kJwtKey = 'jwt_token';
|
||||||
StorageService.to.preferences,
|
const _storage = FlutterSecureStorage();
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
|
Future<void> saveJwt(String token) =>
|
||||||
Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
|
_storage.write(key: _kJwtKey, value: token);
|
||||||
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
|
Future<void> clearJwt() => _storage.delete(key: _kJwtKey);
|
||||||
|
Future<String?> loadJwt() => _storage.read(key: _kJwtKey);
|
||||||
|
|
||||||
class AuthInterceptor extends Interceptor {
|
class AuthInterceptor extends Interceptor {
|
||||||
AuthInterceptor(this._identityStorage);
|
|
||||||
|
|
||||||
final SecureIdentityStorage _identityStorage;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onRequest(
|
Future<void> onRequest(
|
||||||
RequestOptions options,
|
RequestOptions options,
|
||||||
@@ -25,7 +19,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
await DeviceHeaderService.to.ensureInitialized();
|
await DeviceHeaderService.to.ensureInitialized();
|
||||||
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
|
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
|
||||||
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
|
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
|
||||||
final jwt = await _identityStorage.readJwt();
|
final jwt = await loadJwt();
|
||||||
if (jwt != null && jwt.isNotEmpty) {
|
if (jwt != null && jwt.isNotEmpty) {
|
||||||
options.headers['Authorization'] = 'Bearer $jwt';
|
options.headers['Authorization'] = 'Bearer $jwt';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../storage/secure_identity_storage.dart';
|
|
||||||
import 'auth_interceptor.dart';
|
import 'auth_interceptor.dart';
|
||||||
import 'kratos_error_interceptor.dart';
|
import 'kratos_error_interceptor.dart';
|
||||||
|
|
||||||
@@ -13,20 +12,12 @@ part 'dio_provider.g.dart';
|
|||||||
|
|
||||||
const _protobufContentType = 'application/x-protobuf';
|
const _protobufContentType = 'application/x-protobuf';
|
||||||
|
|
||||||
/// Per-request override for native protobuf-over-HTTP endpoints.
|
|
||||||
Options protobufOptions() => Options(
|
|
||||||
responseType: ResponseType.bytes,
|
|
||||||
contentType: _protobufContentType,
|
|
||||||
headers: const {'Accept': _protobufContentType},
|
|
||||||
);
|
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
|
AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
Dio dio(Ref ref) {
|
Dio dio(Ref ref) {
|
||||||
final config = ref.watch(appConfigProvider);
|
final config = ref.watch(appConfigProvider);
|
||||||
final identityStorage = ref.watch(secureIdentityStorageProvider);
|
|
||||||
final d = Dio(
|
final d = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: config.apiBaseUrl,
|
baseUrl: config.apiBaseUrl,
|
||||||
@@ -40,7 +31,7 @@ Dio dio(Ref ref) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
d.interceptors.add(AuthInterceptor(identityStorage));
|
d.interceptors.add(AuthInterceptor());
|
||||||
d.interceptors.add(KratosErrorInterceptor());
|
d.interceptors.add(KratosErrorInterceptor());
|
||||||
if (!kReleaseMode) {
|
if (!kReleaseMode) {
|
||||||
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// Android requires explicit privacy consent before collecting device
|
|
||||||
/// identifiers. iOS follows its existing startup flow instead.
|
|
||||||
bool requiresAndroidPrivacyConsent({
|
|
||||||
required bool isWeb,
|
|
||||||
required TargetPlatform platform,
|
|
||||||
required bool privacyAgreed,
|
|
||||||
}) => !isWeb && platform == TargetPlatform.android && !privacyAgreed;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
abstract final class UmengPrivacyConsent {
|
|
||||||
static const _channel = MethodChannel('jinzhi/privacy');
|
|
||||||
|
|
||||||
static Future<void> submitGranted() async {
|
|
||||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
|
|
||||||
await _channel.invokeMethod<void>('submitUmengPrivacyGranted');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
|
||||||
|
|
||||||
import '../network/dio_provider.dart';
|
|
||||||
import 'notification_permission.dart';
|
|
||||||
import 'umeng_push_service.dart';
|
|
||||||
|
|
||||||
class DevicePushRegistrar {
|
|
||||||
DevicePushRegistrar(this._read);
|
|
||||||
final T Function<T>(ProviderListenable<T>) _read;
|
|
||||||
StreamSubscription<String>? _subscription;
|
|
||||||
String? _lastToken;
|
|
||||||
bool? _lastPermission;
|
|
||||||
int _attempt = 0;
|
|
||||||
|
|
||||||
void start() {
|
|
||||||
_subscription ??= UmengPushService.onDeviceToken.listen(_register);
|
|
||||||
UmengPushService.emitCachedToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stop() async => _subscription?.cancel();
|
|
||||||
Future<void> refresh() => UmengPushService.refreshDeviceToken();
|
|
||||||
void forceRegisterCurrentToken() {
|
|
||||||
_lastToken = null;
|
|
||||||
_lastPermission = null;
|
|
||||||
UmengPushService.emitCachedToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _register(String token) async {
|
|
||||||
final permission = await NotificationPermission.isGranted();
|
|
||||||
if (token == _lastToken && permission == _lastPermission) return;
|
|
||||||
try {
|
|
||||||
final package = await PackageInfo.fromPlatform();
|
|
||||||
await _read(dioProvider).post<List<int>>(
|
|
||||||
'/api/v1/push/devices',
|
|
||||||
data: _PushRegisterRequest(
|
|
||||||
token: token,
|
|
||||||
platform: defaultTargetPlatform == TargetPlatform.iOS ? 1 : 2,
|
|
||||||
version: package.version,
|
|
||||||
permission: permission,
|
|
||||||
).toBytes(),
|
|
||||||
options: protobufOptions(),
|
|
||||||
);
|
|
||||||
_lastToken = token;
|
|
||||||
_lastPermission = permission;
|
|
||||||
_attempt = 0;
|
|
||||||
} catch (_) {
|
|
||||||
if (_attempt++ >= 3) return;
|
|
||||||
await Future<void>.delayed(Duration(seconds: 1 << (_attempt - 1)));
|
|
||||||
await _register(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PushRegisterRequest {
|
|
||||||
const _PushRegisterRequest({
|
|
||||||
required this.token,
|
|
||||||
required this.platform,
|
|
||||||
required this.version,
|
|
||||||
required this.permission,
|
|
||||||
});
|
|
||||||
final String token;
|
|
||||||
final int platform;
|
|
||||||
final String version;
|
|
||||||
final bool permission;
|
|
||||||
Uint8List toBytes() {
|
|
||||||
final bytes = BytesBuilder(copy: false);
|
|
||||||
void putInt(int value) {
|
|
||||||
while (value > 0x7f) {
|
|
||||||
bytes.addByte((value & 0x7f) | 0x80);
|
|
||||||
value >>= 7;
|
|
||||||
}
|
|
||||||
bytes.addByte(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
void putString(int tag, String value) {
|
|
||||||
final raw = value.codeUnits;
|
|
||||||
putInt((tag << 3) | 2);
|
|
||||||
putInt(raw.length);
|
|
||||||
bytes.add(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
putString(1, token);
|
|
||||||
putInt(2 << 3);
|
|
||||||
putInt(platform);
|
|
||||||
putString(3, version);
|
|
||||||
putInt(4 << 3);
|
|
||||||
putInt(permission ? 1 : 0);
|
|
||||||
return bytes.toBytes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
|
|
||||||
import '../storage/storage_keys.dart';
|
|
||||||
import '../storage/storage_service.dart';
|
|
||||||
import '../widgets/notification_permission_tip.dart';
|
|
||||||
|
|
||||||
abstract final class NotificationPermission {
|
|
||||||
static Future<bool> isGranted() async =>
|
|
||||||
!kIsWeb && (await Permission.notification.status).isGranted;
|
|
||||||
|
|
||||||
/// 对齐研听:Android 13+ 由应用解释后主动申请;iOS 由 U-Push 注册 APNs
|
|
||||||
/// 时触发系统授权,绝不展示 Android 的自定义顶部提示。
|
|
||||||
static Future<bool> requestIfNeeded(BuildContext context) async {
|
|
||||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
|
||||||
return isGranted();
|
|
||||||
}
|
|
||||||
final android = await DeviceInfoPlugin().androidInfo;
|
|
||||||
if (android.version.sdkInt < 33 ||
|
|
||||||
StorageService.to.getBool(storagePushPermissionPrompted)) {
|
|
||||||
return isGranted();
|
|
||||||
}
|
|
||||||
if (await isGranted() || !context.mounted) return true;
|
|
||||||
final tip = showNotificationPermissionTip(context);
|
|
||||||
await StorageService.to.setBool(storagePushPermissionPrompted, true);
|
|
||||||
try {
|
|
||||||
// Match the legacy app's persistent top snackbar: keep the in-app
|
|
||||||
// explanation on screen until the system authorization dialog closes.
|
|
||||||
return (await Permission.notification.request()).isGranted;
|
|
||||||
} finally {
|
|
||||||
tip.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置页不使用顶部说明;iOS/Android 都直接请求系统授权。
|
|
||||||
static Future<bool> requestFromSettings() async {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
final current = await Permission.notification.status;
|
|
||||||
if (current.isGranted) return true;
|
|
||||||
await StorageService.to.setBool(storagePushPermissionPrompted, true);
|
|
||||||
return (await Permission.notification.request()).isGranted;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> openSystemSettings() => openAppSettings();
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:umeng_common_sdk/umeng_common_sdk.dart';
|
|
||||||
import 'package:umeng_push_sdk/umeng_push_sdk.dart';
|
|
||||||
|
|
||||||
import '../config/umeng_config.dart';
|
|
||||||
import '../privacy/privacy_gate.dart';
|
|
||||||
import '../services/device_header_service.dart';
|
|
||||||
import '../storage/storage_keys.dart';
|
|
||||||
import '../storage/storage_service.dart';
|
|
||||||
|
|
||||||
abstract final class UmengPushService {
|
|
||||||
static final _tokens = StreamController<String>.broadcast();
|
|
||||||
static Future<void>? _starting;
|
|
||||||
static bool _started = false;
|
|
||||||
static void Function(String payload)? _onNotificationOpen;
|
|
||||||
static String? _pendingNotificationOpen;
|
|
||||||
|
|
||||||
static Stream<String> get onDeviceToken => _tokens.stream;
|
|
||||||
|
|
||||||
static void configureNotificationOpen(
|
|
||||||
void Function(String payload) callback,
|
|
||||||
) {
|
|
||||||
_onNotificationOpen = callback;
|
|
||||||
final pending = _pendingNotificationOpen;
|
|
||||||
if (pending != null) {
|
|
||||||
_pendingNotificationOpen = null;
|
|
||||||
callback(pending);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> startIfAllowed() {
|
|
||||||
if (kIsWeb ||
|
|
||||||
_started ||
|
|
||||||
requiresAndroidPrivacyConsent(
|
|
||||||
isWeb: kIsWeb,
|
|
||||||
platform: defaultTargetPlatform,
|
|
||||||
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
|
|
||||||
)) {
|
|
||||||
return Future.value();
|
|
||||||
}
|
|
||||||
return _starting ??= _start().whenComplete(() => _starting = null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> _start() async {
|
|
||||||
await DeviceHeaderService.to.ensureInitialized();
|
|
||||||
await UmengCommonSdk.initCommon(
|
|
||||||
UmengConfig.androidAppKey,
|
|
||||||
UmengConfig.iosAppKey,
|
|
||||||
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test',
|
|
||||||
UmengConfig.androidPushSecret,
|
|
||||||
);
|
|
||||||
UmengPushSdk.setTokenCallback(_emit);
|
|
||||||
UmengPushSdk.setNotificationCallback((_) {}, (payload) {
|
|
||||||
final callback = _onNotificationOpen;
|
|
||||||
if (callback == null) {
|
|
||||||
_pendingNotificationOpen = payload;
|
|
||||||
} else {
|
|
||||||
callback(payload);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await UmengPushSdk.register(
|
|
||||||
UmengConfig.iosAppKey,
|
|
||||||
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test',
|
|
||||||
);
|
|
||||||
if (Platform.isAndroid) await UmengPushSdk.setPushEnable(true);
|
|
||||||
_started = true;
|
|
||||||
await refreshDeviceToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> refreshDeviceToken() async {
|
|
||||||
if (!_started || kIsWeb) return;
|
|
||||||
_emit(await UmengPushSdk.getRegisteredId());
|
|
||||||
}
|
|
||||||
|
|
||||||
static void emitCachedToken() =>
|
|
||||||
_emit(StorageService.to.getString(storageDeviceToken));
|
|
||||||
|
|
||||||
static void _emit(String? token) {
|
|
||||||
if (token == null || token.isEmpty) return;
|
|
||||||
_tokens.add(token);
|
|
||||||
unawaited(StorageService.to.setString(storageDeviceToken, token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../features/assets/presentation/asset_add_page.dart';
|
||||||
|
import '../../features/assets/presentation/asset_edit_page.dart';
|
||||||
import '../../features/assets/presentation/assets_page.dart';
|
import '../../features/assets/presentation/assets_page.dart';
|
||||||
|
import '../../features/assets/presentation/holding_detail_page.dart';
|
||||||
import '../../features/auth/presentation/delete_account_page.dart';
|
import '../../features/auth/presentation/delete_account_page.dart';
|
||||||
import '../../features/auth/presentation/login_page.dart';
|
import '../../features/auth/presentation/login_page.dart';
|
||||||
import '../../features/debug/presentation/debug_info_page.dart';
|
import '../../features/debug/presentation/debug_info_page.dart';
|
||||||
@@ -11,20 +14,37 @@ import '../../features/debug/presentation/debug_shorebird_page.dart';
|
|||||||
import '../../features/debug/presentation/fancy_dio_inspector_page.dart';
|
import '../../features/debug/presentation/fancy_dio_inspector_page.dart';
|
||||||
import '../../features/debug/presentation/typography_test_page.dart';
|
import '../../features/debug/presentation/typography_test_page.dart';
|
||||||
import '../../features/market/presentation/market_page.dart';
|
import '../../features/market/presentation/market_page.dart';
|
||||||
|
import '../../features/market/presentation/market_kline_page.dart';
|
||||||
import '../../features/news/presentation/news_page.dart';
|
import '../../features/news/presentation/news_page.dart';
|
||||||
|
import '../../features/news/presentation/news_detail_page.dart';
|
||||||
import '../../features/profile/presentation/profile_page.dart';
|
import '../../features/profile/presentation/profile_page.dart';
|
||||||
|
import '../../features/profile/presentation/profile_action_pages.dart';
|
||||||
import '../../features/settings/presentation/settings_page.dart';
|
import '../../features/settings/presentation/settings_page.dart';
|
||||||
import '../../features/settings/presentation/privacy_consent_page.dart';
|
import '../../features/settings/presentation/privacy_consent_page.dart';
|
||||||
import '../../features/web/presentation/web_page.dart';
|
|
||||||
import '../services/device_header_service.dart';
|
import '../services/device_header_service.dart';
|
||||||
import 'tab_shell.dart';
|
import 'tab_shell.dart';
|
||||||
import 'route_error_page.dart';
|
|
||||||
|
|
||||||
abstract final class AppRoutes {
|
abstract final class AppRoutes {
|
||||||
static const assets = '/assets';
|
static const assets = '/assets';
|
||||||
|
static const assetAdd = '/assets/add';
|
||||||
|
static const assetEdit = '/assets/edit';
|
||||||
static const market = '/market';
|
static const market = '/market';
|
||||||
|
static const marketKline = '/market/kline';
|
||||||
static const news = '/news';
|
static const news = '/news';
|
||||||
|
static const newsDetail = '/news/detail';
|
||||||
static const profile = '/profile';
|
static const profile = '/profile';
|
||||||
|
static const profileInvite = '/profile/invite';
|
||||||
|
static const profileShare = '/profile/share';
|
||||||
|
static const profileLocalBackup = '/profile/local-backup';
|
||||||
|
static const profileCloudBackup = '/profile/cloud-backup';
|
||||||
|
static const profileWidget = '/profile/widget';
|
||||||
|
static const profilePurity = '/profile/purity';
|
||||||
|
static const profileRecycle = '/profile/recycle';
|
||||||
|
static const profilePriceColor = '/profile/price-color';
|
||||||
|
static const profileCurrency = '/profile/currency';
|
||||||
|
static const profileDisclaimer = '/profile/disclaimer';
|
||||||
|
static const profileAbout = '/profile/about';
|
||||||
|
static const assetDetail = '/assets/detail';
|
||||||
static const login = '/login';
|
static const login = '/login';
|
||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
static const deleteAccount = '/delete-account';
|
static const deleteAccount = '/delete-account';
|
||||||
@@ -35,18 +55,13 @@ abstract final class AppRoutes {
|
|||||||
static const debugTypography = '/debug/typography';
|
static const debugTypography = '/debug/typography';
|
||||||
static const fancyDioInspector = '/debug/fancy_dio_inspector';
|
static const fancyDioInspector = '/debug/fancy_dio_inspector';
|
||||||
static const privacyConsent = '/privacy-consent';
|
static const privacyConsent = '/privacy-consent';
|
||||||
static const web = '/web';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final startupListenable = ValueNotifier<int>(0);
|
final startupListenable = ValueNotifier<int>(0);
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
navigatorKey: rootNavigatorKey,
|
|
||||||
initialLocation: AppRoutes.assets,
|
initialLocation: AppRoutes.assets,
|
||||||
refreshListenable: startupListenable,
|
refreshListenable: startupListenable,
|
||||||
errorBuilder: (_, state) => RouteErrorPage(error: state.error),
|
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final loc = state.matchedLocation;
|
final loc = state.matchedLocation;
|
||||||
final onConsent = loc == AppRoutes.privacyConsent;
|
final onConsent = loc == AppRoutes.privacyConsent;
|
||||||
@@ -61,46 +76,107 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
StatefulShellRoute.indexedStack(
|
ShellRoute(
|
||||||
builder: (context, state, navigationShell) => TabShell(
|
builder: (context, state, child) =>
|
||||||
location: state.matchedLocation,
|
TabShell(location: state.matchedLocation, child: child),
|
||||||
navigationShell: navigationShell,
|
routes: [
|
||||||
),
|
GoRoute(
|
||||||
branches: [
|
path: AppRoutes.assets,
|
||||||
StatefulShellBranch(
|
builder: (ctx, s) => const AssetsPage(),
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: AppRoutes.assets,
|
|
||||||
builder: (ctx, s) => const AssetsPage(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
StatefulShellBranch(
|
GoRoute(
|
||||||
routes: [
|
path: AppRoutes.market,
|
||||||
GoRoute(
|
builder: (ctx, s) => const MarketPage(),
|
||||||
path: AppRoutes.market,
|
|
||||||
builder: (ctx, s) => const MarketPage(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
StatefulShellBranch(
|
GoRoute(path: AppRoutes.news, builder: (ctx, s) => const NewsPage()),
|
||||||
routes: [
|
GoRoute(
|
||||||
GoRoute(
|
path: AppRoutes.profile,
|
||||||
path: AppRoutes.news,
|
builder: (ctx, s) => const ProfilePage(),
|
||||||
builder: (ctx, s) => const NewsPage(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
StatefulShellBranch(
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: AppRoutes.profile,
|
|
||||||
builder: (ctx, s) => const ProfilePage(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.assetDetail,
|
||||||
|
builder: (ctx, s) => HoldingDetailPage(id: s.uri.queryParameters['id']),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.assetAdd,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_rightToLeftPage(state: s, child: const AssetAddPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.assetEdit,
|
||||||
|
pageBuilder: (ctx, s) => _rightToLeftPage(
|
||||||
|
state: s,
|
||||||
|
child: AssetEditPage(id: s.uri.queryParameters['id'] ?? ''),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.marketKline,
|
||||||
|
builder: (ctx, s) => MarketKlinePage(
|
||||||
|
metalId: s.uri.queryParameters['metal'],
|
||||||
|
periodId: s.uri.queryParameters['period'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.newsDetail,
|
||||||
|
builder: (ctx, s) => NewsDetailPage(id: s.uri.queryParameters['id']),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileInvite,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const InviteRewardPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileShare,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const ShareCardPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileLocalBackup,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const LocalBackupPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileCloudBackup,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const CloudBackupPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileWidget,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const WidgetSettingsPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profilePurity,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const PuritySettingsPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileRecycle,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const RecycleDiscountPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profilePriceColor,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const PriceColorPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileCurrency,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const CurrencyPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileDisclaimer,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_rightToLeftPage(state: s, child: const DisclaimerPage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.profileAbout,
|
||||||
|
pageBuilder: (ctx, s) =>
|
||||||
|
_bottomUpPage(state: s, child: const AboutPage()),
|
||||||
|
),
|
||||||
GoRoute(path: AppRoutes.debug, builder: (_, s) => const DebugPage()),
|
GoRoute(path: AppRoutes.debug, builder: (_, s) => const DebugPage()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.login,
|
path: AppRoutes.login,
|
||||||
@@ -138,19 +214,58 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: AppRoutes.privacyConsent,
|
path: AppRoutes.privacyConsent,
|
||||||
builder: (_, s) => const PrivacyConsentPage(),
|
builder: (_, s) => const PrivacyConsentPage(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: AppRoutes.web,
|
|
||||||
builder: (_, state) {
|
|
||||||
final uri = parseSafeWebUri(state.uri.queryParameters['url']);
|
|
||||||
if (uri == null) {
|
|
||||||
return const Scaffold(body: Center(child: Text('页面地址不可用')));
|
|
||||||
}
|
|
||||||
return AppWebPage(
|
|
||||||
initialUri: uri,
|
|
||||||
hideTitle: state.uri.queryParameters['hideTitle'] == '1',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
CustomTransitionPage<T> _bottomUpPage<T>({
|
||||||
|
required GoRouterState state,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: state.pageKey,
|
||||||
|
transitionDuration: const Duration(milliseconds: 260),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 220),
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, pageChild) {
|
||||||
|
final curved = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.08),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(curved),
|
||||||
|
child: FadeTransition(opacity: curved, child: pageChild),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomTransitionPage<T> _rightToLeftPage<T>({
|
||||||
|
required GoRouterState state,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: state.pageKey,
|
||||||
|
transitionDuration: const Duration(milliseconds: 260),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 220),
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, pageChild) {
|
||||||
|
final curved = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(1, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(curved),
|
||||||
|
child: FadeTransition(opacity: curved, child: pageChild),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import 'app_router.dart';
|
|
||||||
|
|
||||||
/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。
|
|
||||||
abstract final class DeepLinkService {
|
|
||||||
static void handle(String raw) {
|
|
||||||
final target = _route(raw);
|
|
||||||
if (target != null) rootNavigatorKey.currentContext?.go(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? _route(String raw) {
|
|
||||||
var source = raw.trim();
|
|
||||||
try {
|
|
||||||
final json = jsonDecode(source);
|
|
||||||
if (json is Map) {
|
|
||||||
source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}';
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
final uri = Uri.tryParse(source);
|
|
||||||
if (uri == null) return null;
|
|
||||||
if (uri.scheme == 'jinzhi') {
|
|
||||||
final path = uri.path.isEmpty ? '/assets' : uri.path;
|
|
||||||
return _allowed(path)
|
|
||||||
? '$path${uri.hasQuery ? '?${uri.query}' : ''}'
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
if ((uri.scheme == 'https' || uri.scheme == 'http') && _allowed(uri.path)) {
|
|
||||||
return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool _allowed(String path) => const {
|
|
||||||
'/assets',
|
|
||||||
'/market',
|
|
||||||
'/news',
|
|
||||||
'/profile',
|
|
||||||
'/settings',
|
|
||||||
}.contains(path);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
class RouteErrorPage extends StatelessWidget {
|
|
||||||
const RouteErrorPage({super.key, required this.error});
|
|
||||||
final Object? error;
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.explore_off_outlined, size: 40),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
const Text('页面不存在或已失效'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => context.go('/assets'),
|
|
||||||
child: const Text('回到首页'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,28 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
import '../../features/mall/application/mall_tab_config.dart';
|
|
||||||
import '../../features/web/application/mall_web_host_controller.dart';
|
|
||||||
import '../../features/web/presentation/mall_web_host.dart';
|
|
||||||
import '../theme/app_icons.dart';
|
import '../theme/app_icons.dart';
|
||||||
import '../widgets/adaptive.dart';
|
|
||||||
import 'app_router.dart';
|
|
||||||
|
|
||||||
class TabShell extends ConsumerStatefulWidget {
|
class TabShell extends StatefulWidget {
|
||||||
const TabShell({
|
const TabShell({super.key, required this.child, required this.location});
|
||||||
super.key,
|
|
||||||
required this.navigationShell,
|
|
||||||
required this.location,
|
|
||||||
});
|
|
||||||
|
|
||||||
final StatefulNavigationShell navigationShell;
|
final Widget child;
|
||||||
final String location;
|
final String location;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<TabShell> createState() => _TabShellState();
|
State<TabShell> createState() => _TabShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TabShellState extends ConsumerState<TabShell> {
|
class _TabShellState extends State<TabShell> {
|
||||||
static const double _compactHeaderThreshold = 34;
|
static const double _compactHeaderThreshold = 34;
|
||||||
|
|
||||||
bool _showCompactHeader = false;
|
bool _showCompactHeader = false;
|
||||||
|
|
||||||
int get _index => widget.navigationShell.currentIndex;
|
static const _tabs = ['/assets', '/market', '/news', '/profile'];
|
||||||
|
|
||||||
@override
|
int get _index =>
|
||||||
void initState() {
|
_tabs.indexWhere((t) => widget.location.startsWith(t)).clamp(0, 3);
|
||||||
super.initState();
|
|
||||||
ref.listenManual<MallTabConfig>(mallTabConfigProvider, (_, next) {
|
|
||||||
if (!next.isVisible) return;
|
|
||||||
final uri = next.attributedUri(
|
|
||||||
clickId: '',
|
|
||||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
|
||||||
);
|
|
||||||
if (uri != null) MallWebHostController.instance.preload(uri.toString());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant TabShell oldWidget) {
|
void didUpdateWidget(covariant TabShell oldWidget) {
|
||||||
@@ -54,194 +32,97 @@ 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = ShadTheme.of(context).colorScheme;
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
final mall = ref.watch(mallTabConfigProvider);
|
|
||||||
final header = _headerForPath(widget.location);
|
final header = _headerForPath(widget.location);
|
||||||
final canPop = GoRouter.of(context).canPop();
|
final canPop = GoRouter.of(context).canPop();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: cs.background,
|
backgroundColor: cs.background,
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
body: Stack(
|
appBar: AppBar(
|
||||||
children: [
|
backgroundColor: cs.background,
|
||||||
PopScope(
|
surfaceTintColor: Colors.transparent,
|
||||||
canPop: !MallWebHostController.instance.state.value.visible,
|
elevation: 0,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
scrolledUnderElevation: 0,
|
||||||
if (!didPop &&
|
centerTitle: true,
|
||||||
MallWebHostController.instance.state.value.visible) {
|
toolbarHeight: 32,
|
||||||
MallWebHostController.instance.handleBack();
|
leading: canPop
|
||||||
}
|
? IconButton(
|
||||||
},
|
onPressed: () => context.pop(),
|
||||||
child: Scaffold(
|
icon: const Icon(Icons.chevron_left, size: 20),
|
||||||
backgroundColor: cs.background,
|
)
|
||||||
resizeToAvoidBottomInset: false,
|
: null,
|
||||||
appBar: AppBar(
|
title: AnimatedOpacity(
|
||||||
backgroundColor: cs.background,
|
opacity: _showCompactHeader ? 1 : 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
duration: const Duration(milliseconds: 160),
|
||||||
elevation: 0,
|
curve: Curves.easeOut,
|
||||||
scrolledUnderElevation: 0,
|
child: Column(
|
||||||
centerTitle: true,
|
mainAxisSize: MainAxisSize.min,
|
||||||
toolbarHeight: 32,
|
children: [
|
||||||
leading: canPop
|
Text(
|
||||||
? IconButton(
|
header.title,
|
||||||
onPressed: () => context.pop(),
|
textAlign: TextAlign.center,
|
||||||
icon: const Icon(Icons.chevron_left, size: 20),
|
style: TextStyle(
|
||||||
)
|
fontSize: 15,
|
||||||
: null,
|
fontWeight: FontWeight.w700,
|
||||||
title: AnimatedOpacity(
|
color: cs.foreground,
|
||||||
opacity: _showCompactHeader ? 1 : 0,
|
height: 1.1,
|
||||||
duration: const Duration(milliseconds: 160),
|
letterSpacing: 0,
|
||||||
curve: Curves.easeOut,
|
),
|
||||||
child: Column(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
if (header.subtitle != null) ...[
|
||||||
children: [
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
header.title,
|
header.subtitle!,
|
||||||
textAlign: TextAlign.center,
|
maxLines: 1,
|
||||||
style: TextStyle(
|
overflow: TextOverflow.ellipsis,
|
||||||
fontSize: 15,
|
textAlign: TextAlign.center,
|
||||||
fontWeight: FontWeight.w700,
|
style: TextStyle(
|
||||||
color: cs.foreground,
|
fontSize: 11,
|
||||||
height: 1.1,
|
fontWeight: FontWeight.w400,
|
||||||
letterSpacing: 0,
|
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,
|
body: ColoredBox(
|
||||||
),
|
color: cs.background,
|
||||||
),
|
child: NotificationListener<ScrollNotification>(
|
||||||
bottomNavigationBar: SafeArea(
|
onNotification: _handleScrollNotification,
|
||||||
top: false,
|
child: Stack(children: [Positioned.fill(child: widget.child)]),
|
||||||
child: Column(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
bottomNavigationBar: DecoratedBox(
|
||||||
DecoratedBox(
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: Colors.white,
|
||||||
color: cs.background,
|
border: Border(top: BorderSide(color: cs.border, width: 1)),
|
||||||
border: Border(
|
),
|
||||||
top: BorderSide(color: cs.border, width: 1),
|
child: SafeArea(
|
||||||
),
|
top: false,
|
||||||
),
|
child: SizedBox(
|
||||||
child: SizedBox(
|
height: 62,
|
||||||
height: 62,
|
child: Row(
|
||||||
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
|
children: [
|
||||||
child: Center(
|
for (var i = 0; i < _tabs.length; i++)
|
||||||
child: ConstrainedBox(
|
Expanded(
|
||||||
constraints: const BoxConstraints(
|
child: _TabButton(
|
||||||
maxWidth: Adaptive.tabsMaxWidth,
|
icon: i == _index ? _filledIcons[i] : _outlineIcons[i],
|
||||||
),
|
label: _labels[i],
|
||||||
child: Row(
|
selected: i == _index,
|
||||||
children: [
|
onTap: () => context.go(_tabs[i]),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ValueListenableBuilder<MallWebHostState>(
|
|
||||||
valueListenable: MallWebHostController.instance.state,
|
|
||||||
builder: (context, state, _) => Positioned.fill(
|
|
||||||
child: Offstage(
|
|
||||||
offstage: !state.visible,
|
|
||||||
child: MallWebHost(
|
|
||||||
onExit: MallWebHostController.instance.dismiss,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!kReleaseMode)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 88,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () => context.push(AppRoutes.fancyDioInspector),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: cs.primary,
|
|
||||||
foregroundColor: cs.primaryForeground,
|
|
||||||
minimumSize: const Size(60, 60),
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
child: const Text('请求'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,72 +139,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 {
|
class _TabButton extends StatelessWidget {
|
||||||
const _TabButton({
|
const _TabButton({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -340,12 +155,26 @@ class _TabButton extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = ShadTheme.of(context).colorScheme;
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
final color = selected ? cs.foreground : cs.mutedForeground;
|
final color = selected ? cs.primary : cs.mutedForeground;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
// if (selected)
|
||||||
|
// Container(
|
||||||
|
// width: 22,
|
||||||
|
// height: 2.5,
|
||||||
|
// margin: const EdgeInsets.only(bottom: 6),
|
||||||
|
// decoration: BoxDecoration(
|
||||||
|
// color: cs.primary,
|
||||||
|
// borderRadius: const BorderRadius.vertical(
|
||||||
|
// bottom: Radius.circular(3),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// )
|
||||||
|
// else
|
||||||
|
// const SizedBox(height: 8.5),
|
||||||
Icon(icon, size: 22, color: color),
|
Icon(icon, size: 22, color: color),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import 'dart:io' show Platform;
|
|||||||
import 'package:android_id/android_id.dart';
|
import 'package:android_id/android_id.dart';
|
||||||
import 'package:android_oaid/android_oaid.dart';
|
import 'package:android_oaid/android_oaid.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter_paid/flutter_paid.dart';
|
import 'package:flutter_paid/flutter_paid.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -12,7 +12,6 @@ import 'package:uuid/uuid.dart';
|
|||||||
|
|
||||||
import '../storage/storage_keys.dart';
|
import '../storage/storage_keys.dart';
|
||||||
import '../storage/storage_service.dart';
|
import '../storage/storage_service.dart';
|
||||||
import '../privacy/privacy_gate.dart';
|
|
||||||
|
|
||||||
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
||||||
/// AuthInterceptor 每个请求注入 commonHeaders() + platformIdentityHeaders(),
|
/// AuthInterceptor 每个请求注入 commonHeaders() + platformIdentityHeaders(),
|
||||||
@@ -34,7 +33,6 @@ class DeviceHeaderService {
|
|||||||
static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL');
|
static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL');
|
||||||
|
|
||||||
String _deviceId = '';
|
String _deviceId = '';
|
||||||
String _packageName = '';
|
|
||||||
String _version = '';
|
String _version = '';
|
||||||
String _build = '';
|
String _build = '';
|
||||||
String _deviceModel = '';
|
String _deviceModel = '';
|
||||||
@@ -45,6 +43,8 @@ class DeviceHeaderService {
|
|||||||
String _androidOaid = '';
|
String _androidOaid = '';
|
||||||
String _androidImei = '';
|
String _androidImei = '';
|
||||||
String _androidMac = '';
|
String _androidMac = '';
|
||||||
|
IosDeviceInfo? iosDeviceInfo;
|
||||||
|
AndroidDeviceInfo? androidDeviceInfo;
|
||||||
Future<void>? _baseInitialization;
|
Future<void>? _baseInitialization;
|
||||||
Future<void>? _identityInitialization;
|
Future<void>? _identityInitialization;
|
||||||
|
|
||||||
@@ -54,11 +54,9 @@ class DeviceHeaderService {
|
|||||||
|
|
||||||
// Align with tradingagents-flutter-app: Android only initializes device
|
// Align with tradingagents-flutter-app: Android only initializes device
|
||||||
// information after the user has accepted the privacy policy.
|
// information after the user has accepted the privacy policy.
|
||||||
if (requiresAndroidPrivacyConsent(
|
if (!kIsWeb &&
|
||||||
isWeb: kIsWeb,
|
Platform.isAndroid &&
|
||||||
platform: defaultTargetPlatform,
|
!StorageService.to.getBool(storagePrivacyAgreed)) {
|
||||||
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
|
|
||||||
)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +67,6 @@ class DeviceHeaderService {
|
|||||||
Future<void> _initializeBase() async {
|
Future<void> _initializeBase() async {
|
||||||
try {
|
try {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
_packageName = info.packageName.trim();
|
|
||||||
_version = info.version;
|
_version = info.version;
|
||||||
_build = info.buildNumber;
|
_build = info.buildNumber;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -125,9 +122,23 @@ class DeviceHeaderService {
|
|||||||
_iosIdfv = await _resolveIdfv();
|
_iosIdfv = await _resolveIdfv();
|
||||||
await _collectIosPaidIfMissing();
|
await _collectIosPaidIfMissing();
|
||||||
}
|
}
|
||||||
|
await getDeviceInfo();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> getDeviceInfo() async {
|
||||||
|
try {
|
||||||
|
final deviceInfoPlugin = DeviceInfoPlugin();
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
iosDeviceInfo = await deviceInfoPlugin.iosInfo;
|
||||||
|
} else if (Platform.isAndroid) {
|
||||||
|
androidDeviceInfo = await deviceInfoPlugin.androidInfo;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _collectAndroidIdIfAgreed() async {
|
Future<void> _collectAndroidIdIfAgreed() async {
|
||||||
if (_androidId.isNotEmpty) return;
|
if (_androidId.isNotEmpty) return;
|
||||||
if (!StorageService.to.getBool(storagePrivacyAgreed)) return; // 隐私同意后才采集
|
if (!StorageService.to.getBool(storagePrivacyAgreed)) return; // 隐私同意后才采集
|
||||||
@@ -187,19 +198,12 @@ class DeviceHeaderService {
|
|||||||
await StorageService.to.setBool(storagePrivacyAgreed, true);
|
await StorageService.to.setBool(storagePrivacyAgreed, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ATT 授权后写入 IDFA;后续请求通过 platformIdentityHeaders 自动带上。
|
|
||||||
Future<void> saveIosIdfa(String value) async {
|
|
||||||
if (value.trim().isEmpty) return;
|
|
||||||
await StorageService.to.setString(storageIosIdfa, value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备
|
/// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备
|
||||||
/// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
|
/// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
|
||||||
bool get privacyConsentRequired => requiresAndroidPrivacyConsent(
|
bool get privacyConsentRequired =>
|
||||||
isWeb: kIsWeb,
|
!kIsWeb &&
|
||||||
platform: defaultTargetPlatform,
|
Platform.isAndroid &&
|
||||||
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
|
!StorageService.to.getBool(storagePrivacyAgreed);
|
||||||
);
|
|
||||||
|
|
||||||
/// iOS 首次启动页:仅在首次安装后展示一次。
|
/// iOS 首次启动页:仅在首次安装后展示一次。
|
||||||
bool get iosStartupRequired =>
|
bool get iosStartupRequired =>
|
||||||
@@ -246,7 +250,6 @@ class DeviceHeaderService {
|
|||||||
/// 公共 header(不含 token)。11
|
/// 公共 header(不含 token)。11
|
||||||
Map<String, String> commonHeaders() => _compact({
|
Map<String, String> commonHeaders() => _compact({
|
||||||
'X-Device-Id': _deviceId,
|
'X-Device-Id': _deviceId,
|
||||||
'pkg': _packageName,
|
|
||||||
'User-Version': _version,
|
'User-Version': _version,
|
||||||
'User-BuildID': _build,
|
'User-BuildID': _build,
|
||||||
'User-Platform': _platform(),
|
'User-Platform': _platform(),
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import 'storage_keys.dart';
|
|
||||||
import 'storage_service.dart';
|
|
||||||
|
|
||||||
abstract interface class SecureKeyValueStorage {
|
|
||||||
Future<String?> read(String key);
|
|
||||||
Future<void> write(String key, String value);
|
|
||||||
Future<void> delete(String key);
|
|
||||||
}
|
|
||||||
|
|
||||||
class FlutterSecureKeyValueStorage implements SecureKeyValueStorage {
|
|
||||||
const FlutterSecureKeyValueStorage();
|
|
||||||
|
|
||||||
static const _storage = FlutterSecureStorage();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String?> read(String key) => _storage.read(key: key);
|
|
||||||
@override
|
|
||||||
Future<void> write(String key, String value) =>
|
|
||||||
_storage.write(key: key, value: value);
|
|
||||||
@override
|
|
||||||
Future<void> delete(String key) => _storage.delete(key: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sensitive identity values stay behind one injectable storage boundary.
|
|
||||||
/// Legacy plaintext preferences are migrated only after a secure write works.
|
|
||||||
class SecureIdentityStorage {
|
|
||||||
SecureIdentityStorage(
|
|
||||||
this._preferences, {
|
|
||||||
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
|
|
||||||
}) : _storage = secureStorage;
|
|
||||||
|
|
||||||
static const _accessTokenKey = 'access_token';
|
|
||||||
static const _legacySecureJwtKey = 'jwt_token';
|
|
||||||
|
|
||||||
final SharedPreferences _preferences;
|
|
||||||
final SecureKeyValueStorage _storage;
|
|
||||||
|
|
||||||
Future<String?> restoreAccessToken() async {
|
|
||||||
final current = _normalized(await _storage.read(_accessTokenKey));
|
|
||||||
final legacyPreferences = _normalized(
|
|
||||||
_preferences.getString(storageLegacyAccessToken),
|
|
||||||
);
|
|
||||||
final legacySecure = current == null
|
|
||||||
? _normalized(await _storage.read(_legacySecureJwtKey))
|
|
||||||
: null;
|
|
||||||
final token = current ?? legacyPreferences ?? legacySecure;
|
|
||||||
if (current == null && token != null) {
|
|
||||||
await _storage.write(_accessTokenKey, token);
|
|
||||||
}
|
|
||||||
await _storage.delete(_legacySecureJwtKey);
|
|
||||||
await _preferences.remove(storageLegacyAccessToken);
|
|
||||||
if (token == null) {
|
|
||||||
await _preferences.remove(storageAuthSessionActive);
|
|
||||||
} else {
|
|
||||||
await _preferences.setBool(storageAuthSessionActive, true);
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> readJwt() async {
|
|
||||||
final token = _normalized(await _storage.read(_accessTokenKey));
|
|
||||||
if (token == null || _preferences.containsKey(storageLegacyAccessToken)) {
|
|
||||||
return restoreAccessToken();
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveJwt(String token) async {
|
|
||||||
final normalized = _normalized(token);
|
|
||||||
if (normalized == null) {
|
|
||||||
throw ArgumentError.value(token, 'token', 'must not be empty');
|
|
||||||
}
|
|
||||||
await _storage.write(_accessTokenKey, normalized);
|
|
||||||
await _storage.delete(_legacySecureJwtKey);
|
|
||||||
await _preferences.remove(storageLegacyAccessToken);
|
|
||||||
await _preferences.setBool(storageAuthSessionActive, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearJwt() async {
|
|
||||||
await _storage.delete(_accessTokenKey);
|
|
||||||
await _storage.delete(_legacySecureJwtKey);
|
|
||||||
await _preferences.remove(storageLegacyAccessToken);
|
|
||||||
await _preferences.remove(storageAuthSessionActive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
|
|
||||||
(ref) => SecureIdentityStorage(StorageService.to.preferences),
|
|
||||||
);
|
|
||||||
|
|
||||||
String? _normalized(String? value) {
|
|
||||||
final normalized = value?.trim() ?? '';
|
|
||||||
return normalized.isEmpty ? null : normalized;
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,6 @@ const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
|
|||||||
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
||||||
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
|
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
|
||||||
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
|
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
|
||||||
const storagePushPermissionPrompted = 'push_permission_prompted';
|
|
||||||
|
|
||||||
// Previous builds could persist credentials in SharedPreferences. These keys
|
|
||||||
// exist only for one-time migration into secure storage.
|
|
||||||
const storageLegacyAccessToken = 'access_token';
|
|
||||||
const storageAuthSessionActive = 'auth_session_active';
|
|
||||||
|
|
||||||
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
||||||
// header;未写入则为空、注入时被过滤。
|
// header;未写入则为空、注入时被过滤。
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ class StorageService {
|
|||||||
static StorageService? _instance;
|
static StorageService? _instance;
|
||||||
static StorageService get to => _instance!;
|
static StorageService get to => _instance!;
|
||||||
|
|
||||||
SharedPreferences get preferences => _prefs;
|
|
||||||
|
|
||||||
static Future<StorageService> get instance async {
|
static Future<StorageService> get instance async {
|
||||||
if (_instance != null) return _instance!;
|
if (_instance != null) return _instance!;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -19,6 +17,4 @@ class StorageService {
|
|||||||
Future<void> setBool(String key, bool v) => _prefs.setBool(key, v);
|
Future<void> setBool(String key, bool v) => _prefs.setBool(key, v);
|
||||||
String? getString(String key) => _prefs.getString(key);
|
String? getString(String key) => _prefs.getString(key);
|
||||||
Future<void> setString(String key, String v) => _prefs.setString(key, v);
|
Future<void> setString(String key, String v) => _prefs.setString(key, v);
|
||||||
bool containsKey(String key) => _prefs.containsKey(key);
|
|
||||||
Future<void> remove(String key) => _prefs.remove(key);
|
|
||||||
}
|
}
|
||||||
@@ -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 拒绝/不可用不能阻断启动。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import '../../features/assets/data/asset_models.dart';
|
||||||
|
|
||||||
|
class UploadRepository {
|
||||||
|
const UploadRepository();
|
||||||
|
|
||||||
|
Future<UploadResult> uploadAssetImage(AssetImage image) async {
|
||||||
|
// TODO: Connect to upload backend.
|
||||||
|
return UploadResult(
|
||||||
|
remoteUrl: image.remoteUrl,
|
||||||
|
thumbnailUrl: image.thumbnailUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadResult {
|
||||||
|
const UploadResult({this.remoteUrl, this.thumbnailUrl});
|
||||||
|
|
||||||
|
final String? remoteUrl;
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import '../../features/assets/data/asset_models.dart';
|
||||||
|
import 'upload_repository.dart';
|
||||||
|
|
||||||
|
class UploadService {
|
||||||
|
const UploadService({required this.repository});
|
||||||
|
|
||||||
|
final UploadRepository repository;
|
||||||
|
|
||||||
|
Future<UploadResult> uploadAssetImage(AssetImage image) async {
|
||||||
|
// Placeholder: future implementation will upload the file and return URLs.
|
||||||
|
return repository.uploadAssetImage(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:gal/gal.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:jinzhi_flutter_app/common/services/device_header_service.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
class ImageUtil {
|
||||||
|
static const String _albumName = 'jinzhi';
|
||||||
|
|
||||||
|
static Stream<FileResponse> downloadImage(String url, String key) {
|
||||||
|
return DefaultCacheManager().getFileStream(
|
||||||
|
url,
|
||||||
|
key: key,
|
||||||
|
withProgress: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> downloadImageInFile(String url, String key) async {
|
||||||
|
final file = await DefaultCacheManager().getSingleFile(url, key: key);
|
||||||
|
return file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> requestForImagePermission() async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
PermissionStatus photoStatus = await Permission.photosAddOnly.status;
|
||||||
|
if (photoStatus != PermissionStatus.granted) {
|
||||||
|
photoStatus = await Permission.photosAddOnly.request();
|
||||||
|
if (photoStatus != PermissionStatus.granted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (Platform.isAndroid) {
|
||||||
|
int? sdkInt = DeviceHeaderService.to.androidDeviceInfo?.version.sdkInt;
|
||||||
|
if (sdkInt != null && sdkInt >= 33) {
|
||||||
|
// ✅ Android 13+ 不需要读权限,直接返回 true
|
||||||
|
// gal 插件内部通过 MediaStore 写入
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// ✅ Android 10-12 仍可能需要 storage 权限
|
||||||
|
final status = await Permission.storage.request();
|
||||||
|
return status.isGranted;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> saveImage(FileInfo fileInfo) async {
|
||||||
|
await Gal.putImage(fileInfo.file.path, album: _albumName);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> saveImageData(ByteData byteData) async {
|
||||||
|
Uint8List imageBytes = byteData.buffer.asUint8List();
|
||||||
|
await Gal.putImageBytes(
|
||||||
|
imageBytes,
|
||||||
|
name: 'XWallpaper_share',
|
||||||
|
album: _albumName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> saveImageUint8List(Uint8List imageBytes) async {
|
||||||
|
await Gal.putImageBytes(
|
||||||
|
imageBytes,
|
||||||
|
name: 'XWallpaper_share',
|
||||||
|
album: _albumName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> saveUIImageData(ui.Image image) async {
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
Uint8List imageBytes = byteData!.buffer.asUint8List();
|
||||||
|
await Gal.putImageBytes(
|
||||||
|
imageBytes,
|
||||||
|
name: 'XWallpaper_share',
|
||||||
|
album: _albumName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Uint8List> createTransparentImage(double aspectRatio) async {
|
||||||
|
double minSize = 100;
|
||||||
|
|
||||||
|
double width = minSize * aspectRatio;
|
||||||
|
double height = minSize;
|
||||||
|
|
||||||
|
final recorder = PictureRecorder();
|
||||||
|
final canvas = Canvas(
|
||||||
|
recorder,
|
||||||
|
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
|
||||||
|
);
|
||||||
|
final paint = Paint()..color = Colors.transparent;
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
|
||||||
|
final picture = recorder.endRecording();
|
||||||
|
final img = await picture.toImage(width.toInt(), height.toInt());
|
||||||
|
final pngBytes = await img.toByteData(format: ImageByteFormat.png);
|
||||||
|
|
||||||
|
return pngBytes!.buffer.asUint8List();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载图片并返回 Uint8List(简单实现)
|
||||||
|
static Future<Uint8List?> fetchThumbBytes(String? url) async {
|
||||||
|
if (url == null || url.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final resp = await http.get(Uri.parse(url));
|
||||||
|
if (resp.statusCode == 200) return resp.bodyBytes;
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// ignore_for_file: use_build_context_synchronously
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
ui.Rect? shareOriginForContext(BuildContext context) {
|
||||||
|
final renderObject = context.findRenderObject();
|
||||||
|
if (renderObject is! RenderBox || !renderObject.hasSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> captureWidgetPng(
|
||||||
|
GlobalKey key, {
|
||||||
|
double pixelRatio = 3.0,
|
||||||
|
}) async {
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
|
||||||
|
final context = key.currentContext;
|
||||||
|
if (context == null) {
|
||||||
|
throw StateError('share target is not mounted');
|
||||||
|
}
|
||||||
|
|
||||||
|
final renderObject = context.findRenderObject();
|
||||||
|
if (renderObject is! RenderRepaintBoundary) {
|
||||||
|
throw StateError('share target is not a repaint boundary');
|
||||||
|
}
|
||||||
|
|
||||||
|
final image = await renderObject.toImage(pixelRatio: pixelRatio);
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
if (byteData == null) {
|
||||||
|
throw StateError('failed to convert share image');
|
||||||
|
}
|
||||||
|
return byteData.buffer.asUint8List();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sharePngFromBoundary(
|
||||||
|
GlobalKey key, {
|
||||||
|
required String fileName,
|
||||||
|
String? text,
|
||||||
|
String? subject,
|
||||||
|
ui.Rect? sharePositionOrigin,
|
||||||
|
double pixelRatio = 3.0,
|
||||||
|
}) async {
|
||||||
|
final bytes = await captureWidgetPng(key, pixelRatio: pixelRatio);
|
||||||
|
final xFile = XFile.fromData(bytes, mimeType: 'image/png', name: fileName);
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
files: [xFile],
|
||||||
|
text: text,
|
||||||
|
subject: subject,
|
||||||
|
sharePositionOrigin: sharePositionOrigin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sharePlainText({
|
||||||
|
required String text,
|
||||||
|
String? subject,
|
||||||
|
ui.Rect? sharePositionOrigin,
|
||||||
|
}) async {
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
text: text,
|
||||||
|
subject: subject,
|
||||||
|
sharePositionOrigin: sharePositionOrigin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
||||||
|
|
||||||
/// 金值统一的网络图片入口。
|
|
||||||
///
|
|
||||||
/// 图片会自动使用 [CachedNetworkImage] 的磁盘缓存;业务页面不应直接创建
|
|
||||||
/// `Image.network`,以保证加载和失败状态的视觉及缓存策略一致。
|
|
||||||
class AppNetworkImage extends StatelessWidget {
|
|
||||||
const AppNetworkImage({
|
|
||||||
super.key,
|
|
||||||
required this.imageUrl,
|
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
this.fit = BoxFit.cover,
|
|
||||||
this.alignment = Alignment.center,
|
|
||||||
this.borderRadius,
|
|
||||||
this.cacheKey,
|
|
||||||
this.httpHeaders,
|
|
||||||
this.placeholder,
|
|
||||||
this.errorWidget,
|
|
||||||
this.semanticLabel,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String imageUrl;
|
|
||||||
final double? width;
|
|
||||||
final double? height;
|
|
||||||
final BoxFit fit;
|
|
||||||
final Alignment alignment;
|
|
||||||
final BorderRadius? borderRadius;
|
|
||||||
final String? cacheKey;
|
|
||||||
final Map<String, String>? httpHeaders;
|
|
||||||
final Widget? placeholder;
|
|
||||||
final Widget? errorWidget;
|
|
||||||
final String? semanticLabel;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final child = imageUrl.trim().isEmpty
|
|
||||||
? _ImageFallback(errorWidget: errorWidget)
|
|
||||||
: CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
cacheKey: cacheKey,
|
|
||||||
httpHeaders: httpHeaders,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: fit,
|
|
||||||
alignment: alignment,
|
|
||||||
fadeInDuration: const Duration(milliseconds: 150),
|
|
||||||
placeholder: (_, _) =>
|
|
||||||
placeholder ?? const _ImageLoadingPlaceholder(),
|
|
||||||
errorWidget: (_, _, _) => _ImageFallback(errorWidget: errorWidget),
|
|
||||||
);
|
|
||||||
|
|
||||||
final clipped = borderRadius == null
|
|
||||||
? child
|
|
||||||
: ClipRRect(borderRadius: borderRadius!, child: child);
|
|
||||||
if (semanticLabel == null) return clipped;
|
|
||||||
return Semantics(image: true, label: semanticLabel, child: clipped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ImageLoadingPlaceholder extends StatelessWidget {
|
|
||||||
const _ImageLoadingPlaceholder();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = ShadTheme.of(context).colorScheme;
|
|
||||||
return ColoredBox(
|
|
||||||
color: colors.muted,
|
|
||||||
child: Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colors.mutedForeground,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ImageFallback extends StatelessWidget {
|
|
||||||
const _ImageFallback({required this.errorWidget});
|
|
||||||
|
|
||||||
final Widget? errorWidget;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (errorWidget != null) return errorWidget!;
|
|
||||||
final colors = ShadTheme.of(context).colorScheme;
|
|
||||||
return ColoredBox(
|
|
||||||
color: colors.muted,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.image_not_supported_outlined,
|
|
||||||
color: colors.mutedForeground,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import 'package:easy_refresh/easy_refresh.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
typedef RefreshAction = Future<void> Function();
|
|
||||||
|
|
||||||
/// Shared pull-to-refresh and load-more wrapper for future list features.
|
|
||||||
class AppRefresh extends StatelessWidget {
|
|
||||||
const AppRefresh({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.onRefresh,
|
|
||||||
this.onLoad,
|
|
||||||
this.controller,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Widget child;
|
|
||||||
final RefreshAction? onRefresh;
|
|
||||||
final RefreshAction? onLoad;
|
|
||||||
final EasyRefreshController? controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => EasyRefresh(
|
|
||||||
controller: controller,
|
|
||||||
onRefresh: onRefresh,
|
|
||||||
onLoad: onLoad,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
import 'app_state_card.dart';
|
|
||||||
|
|
||||||
/// Shared loading, failure and empty-state contract for feature pages.
|
|
||||||
class AsyncStateView<T> extends StatelessWidget {
|
|
||||||
const AsyncStateView({
|
|
||||||
super.key,
|
|
||||||
required this.state,
|
|
||||||
required this.data,
|
|
||||||
this.isEmpty,
|
|
||||||
this.empty,
|
|
||||||
this.loading,
|
|
||||||
this.onRetry,
|
|
||||||
this.errorTitle = '加载失败',
|
|
||||||
this.errorSubtitle = '请检查网络后重试',
|
|
||||||
this.emptyIcon = Icons.inbox_outlined,
|
|
||||||
this.emptyTitle = '暂无内容',
|
|
||||||
this.emptySubtitle = '暂时没有可展示的数据',
|
|
||||||
});
|
|
||||||
|
|
||||||
final AsyncValue<T> state;
|
|
||||||
final Widget Function(T value) data;
|
|
||||||
final bool Function(T value)? isEmpty;
|
|
||||||
final Widget Function()? empty;
|
|
||||||
final Widget? loading;
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
final String errorTitle;
|
|
||||||
final String errorSubtitle;
|
|
||||||
final IconData emptyIcon;
|
|
||||||
final String emptyTitle;
|
|
||||||
final String emptySubtitle;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return state.when(
|
|
||||||
loading: () =>
|
|
||||||
loading ?? const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (_, _) => Center(
|
|
||||||
child: AppStateCard(
|
|
||||||
icon: Icons.error_outline,
|
|
||||||
title: errorTitle,
|
|
||||||
subtitle: errorSubtitle,
|
|
||||||
buttonLabel: onRetry == null ? null : '重试',
|
|
||||||
onButtonPressed: onRetry,
|
|
||||||
buttonStyle: AppStateButtonStyle.filled,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (value) => isEmpty?.call(value) == true
|
|
||||||
? empty?.call() ??
|
|
||||||
Center(
|
|
||||||
child: AppStateCard(
|
|
||||||
icon: emptyIcon,
|
|
||||||
title: emptyTitle,
|
|
||||||
subtitle: emptySubtitle,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: data(value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import 'package:easy_refresh/easy_refresh.dart';
|
|
||||||
|
|
||||||
/// Applies one Chinese copy set to every pull-to-refresh/list-load control.
|
|
||||||
void configureEasyRefreshChineseLocalization() {
|
|
||||||
EasyRefresh.defaultHeaderBuilder = () => const ClassicHeader(
|
|
||||||
dragText: '下拉刷新',
|
|
||||||
armedText: '松开刷新',
|
|
||||||
readyText: '刷新中...',
|
|
||||||
processingText: '刷新中...',
|
|
||||||
processedText: '刷新成功',
|
|
||||||
noMoreText: '没有更多了',
|
|
||||||
failedText: '刷新失败',
|
|
||||||
messageText: '上次更新于 %T',
|
|
||||||
);
|
|
||||||
EasyRefresh.defaultFooterBuilder = () => const ClassicFooter(
|
|
||||||
dragText: '上拉加载',
|
|
||||||
armedText: '松开加载更多',
|
|
||||||
readyText: '加载中...',
|
|
||||||
processingText: '加载中...',
|
|
||||||
processedText: '加载完成',
|
|
||||||
noMoreText: '没有更多了',
|
|
||||||
failedText: '加载失败',
|
|
||||||
messageText: '上次更新于 %T',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
class PrototypeFrame extends StatelessWidget {
|
||||||
|
const PrototypeFrame({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.top = 0,
|
||||||
|
this.bottom = 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final double top;
|
||||||
|
final double bottom;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(18, top, 18, bottom),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 760),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrototypeCard extends StatelessWidget {
|
||||||
|
const PrototypeCard({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding = const EdgeInsets.all(16),
|
||||||
|
this.margin,
|
||||||
|
this.radius = 14,
|
||||||
|
this.decoration,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final double radius;
|
||||||
|
final BoxDecoration? decoration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
margin: margin,
|
||||||
|
decoration: decoration ??
|
||||||
|
BoxDecoration(
|
||||||
|
color: cs.card,
|
||||||
|
border: Border.all(color: cs.border, width: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
),
|
||||||
|
child: Padding(padding: padding, child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrototypePill extends StatelessWidget {
|
||||||
|
const PrototypePill({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
this.selected = false,
|
||||||
|
this.leading,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final Widget? leading;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
final bg = selected ? cs.secondary : cs.background;
|
||||||
|
final fg = selected ? cs.secondaryForeground : cs.mutedForeground;
|
||||||
|
final borderColor = selected ? const Color(0xFFE3D6B4) : cs.border;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: borderColor, width: 0.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (leading != null) ...[leading!, const SizedBox(width: 4)],
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
color: fg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrototypeMetricChip extends StatelessWidget {
|
||||||
|
const PrototypeMetricChip({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.valueColor,
|
||||||
|
this.background,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color? valueColor;
|
||||||
|
final Color? background;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background ?? cs.background,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: cs.border, width: 0.5),
|
||||||
|
),
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: TextStyle(fontSize: 10, color: cs.mutedForeground),
|
||||||
|
children: [
|
||||||
|
TextSpan(text: '$label '),
|
||||||
|
TextSpan(
|
||||||
|
text: value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valueColor ?? cs.foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrototypeLineChart extends StatelessWidget {
|
||||||
|
const PrototypeLineChart({
|
||||||
|
super.key,
|
||||||
|
required this.values,
|
||||||
|
this.lineColor = const Color(0xFFB5862A),
|
||||||
|
this.fillColor = const Color(0xFFF7EFD9),
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<double> values;
|
||||||
|
final Color lineColor;
|
||||||
|
final Color fillColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _PrototypeLineChartPainter(
|
||||||
|
values: values,
|
||||||
|
lineColor: lineColor,
|
||||||
|
fillColor: fillColor,
|
||||||
|
),
|
||||||
|
size: Size.infinite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PrototypeLineChartPainter extends CustomPainter {
|
||||||
|
_PrototypeLineChartPainter({
|
||||||
|
required this.values,
|
||||||
|
required this.lineColor,
|
||||||
|
required this.fillColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<double> values;
|
||||||
|
final Color lineColor;
|
||||||
|
final Color fillColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (values.isEmpty || size.isEmpty) return;
|
||||||
|
final paintGrid = Paint()
|
||||||
|
..color = const Color(0xFFECEAE3)
|
||||||
|
..strokeWidth = 1;
|
||||||
|
final paintLine = Paint()
|
||||||
|
..color = lineColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.4
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
final paintFill = Paint()
|
||||||
|
..color = fillColor
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
const padTop = 10.0;
|
||||||
|
const padBottom = 10.0;
|
||||||
|
final plotHeight = size.height - padTop - padBottom;
|
||||||
|
final plotWidth = size.width;
|
||||||
|
final min = values.reduce((a, b) => a < b ? a : b);
|
||||||
|
final max = values.reduce((a, b) => a > b ? a : b);
|
||||||
|
final span = (max - min).abs() < 0.0001 ? 1.0 : max - min;
|
||||||
|
Offset pointFor(int index) {
|
||||||
|
final x = values.length == 1
|
||||||
|
? plotWidth / 2
|
||||||
|
: index / (values.length - 1) * plotWidth;
|
||||||
|
final y = padTop + (1 - (values[index] - min) / span) * plotHeight;
|
||||||
|
return Offset(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
final dy = padTop + plotHeight * i / 3;
|
||||||
|
canvas.drawLine(Offset(0, dy), Offset(size.width, dy), paintGrid);
|
||||||
|
}
|
||||||
|
|
||||||
|
final pts = List.generate(values.length, pointFor);
|
||||||
|
final path = Path()..moveTo(pts.first.dx, pts.first.dy);
|
||||||
|
for (final p in pts.skip(1)) {
|
||||||
|
path.lineTo(p.dx, p.dy);
|
||||||
|
}
|
||||||
|
final fillPath = Path()
|
||||||
|
..addPath(path, Offset.zero)
|
||||||
|
..lineTo(size.width, size.height)
|
||||||
|
..lineTo(0, size.height)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(fillPath, paintFill);
|
||||||
|
canvas.drawPath(path, paintLine);
|
||||||
|
final last = pts.last;
|
||||||
|
canvas.drawCircle(last, 3.8, Paint()..color = lineColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _PrototypeLineChartPainter oldDelegate) {
|
||||||
|
return oldDelegate.values != values ||
|
||||||
|
oldDelegate.lineColor != lineColor ||
|
||||||
|
oldDelegate.fillColor != fillColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrototypeSectionLabel extends StatelessWidget {
|
||||||
|
const PrototypeSectionLabel(this.label, {super.key, this.trailing});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: cs.foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrototypeDivider extends StatelessWidget {
|
||||||
|
const PrototypeDivider({super.key, this.margin = const EdgeInsets.symmetric(vertical: 10)});
|
||||||
|
|
||||||
|
final EdgeInsetsGeometry margin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
margin: margin,
|
||||||
|
height: 0.5,
|
||||||
|
color: cs.border,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
|
|
||||||
|
const ClassicFooter jinzhiClassicFooter = ClassicFooter(
|
||||||
|
height: 40,
|
||||||
|
idleIcon: const SizedBox.shrink(),
|
||||||
|
canLoadingIcon: const SizedBox.shrink(),
|
||||||
|
noMoreIcon: const SizedBox.shrink(),
|
||||||
|
failedIcon: const SizedBox.shrink(),
|
||||||
|
loadingIcon: const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
loadingText: '',
|
||||||
|
idleText: '',
|
||||||
|
canLoadingText: '',
|
||||||
|
noDataText: '',
|
||||||
|
failedText: '',
|
||||||
|
textStyle: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
);
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import '../../mall/application/mall_tab_config.dart';
|
|
||||||
import '../data/ads_api_client.dart';
|
|
||||||
import 'splash_ad_controller.dart';
|
|
||||||
|
|
||||||
final adConfigForegroundRefresherProvider = Provider(
|
|
||||||
(ref) => AdConfigForegroundRefresher(
|
|
||||||
() => ref.invalidate(featuredAdsProvider(homeBannerAdPlacement)),
|
|
||||||
() => ref.read(splashAdControllerProvider).refresh(),
|
|
||||||
() => ref.read(mallTabConfigProvider.notifier).refresh(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
class AdConfigForegroundRefresher {
|
|
||||||
AdConfigForegroundRefresher(this._banner, this._splash, this._mall);
|
|
||||||
final void Function() _banner;
|
|
||||||
final Future<void> Function() _splash, _mall;
|
|
||||||
Future<void>? _running;
|
|
||||||
Future<void> refresh() =>
|
|
||||||
_running ??= _refresh().whenComplete(() => _running = null);
|
|
||||||
Future<void> _refresh() async {
|
|
||||||
try {
|
|
||||||
_banner();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
await _splash();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
await _mall();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io' show File;
|
|
||||||
|
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../../common/storage/storage_service.dart';
|
|
||||||
import '../data/ads_api_client.dart';
|
|
||||||
|
|
||||||
class SplashAdEntry {
|
|
||||||
const SplashAdEntry(this.ad, this.dailyCap, this.assetPath, this.cachedAtMs);
|
|
||||||
final AdCreative ad;
|
|
||||||
final int dailyCap;
|
|
||||||
final String? assetPath;
|
|
||||||
final int cachedAtMs;
|
|
||||||
bool get valid =>
|
|
||||||
ad.isRenderable &&
|
|
||||||
DateTime.now().millisecondsSinceEpoch <
|
|
||||||
cachedAtMs + const Duration(hours: 24).inMilliseconds &&
|
|
||||||
(ad.endAtMs <= 0 || ad.endAtMs > DateTime.now().millisecondsSinceEpoch);
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'ad': ad.toJson(),
|
|
||||||
'dailyCap': dailyCap,
|
|
||||||
'assetPath': assetPath,
|
|
||||||
'cachedAtMs': cachedAtMs,
|
|
||||||
};
|
|
||||||
factory SplashAdEntry.fromJson(Map<String, dynamic> map) => SplashAdEntry(
|
|
||||||
AdCreative.fromJson(Map<String, dynamic>.from(map['ad'] as Map)),
|
|
||||||
(map['dailyCap'] as num?)?.toInt() ?? 0,
|
|
||||||
map['assetPath'] as String?,
|
|
||||||
(map['cachedAtMs'] as num?)?.toInt() ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SplashAdController {
|
|
||||||
SplashAdController(this._api);
|
|
||||||
final AdsApiClient _api;
|
|
||||||
static const _entryKey = 'ads.splash.entry';
|
|
||||||
static const _dailyKey = 'ads.splash.daily';
|
|
||||||
Future<SplashAdEntry?> coldLaunchDecision() async {
|
|
||||||
final raw = StorageService.to.preferences.getString(_entryKey);
|
|
||||||
if (raw == null) return null;
|
|
||||||
try {
|
|
||||||
final entry = SplashAdEntry.fromJson(
|
|
||||||
Map<String, dynamic>.from(jsonDecode(raw) as Map),
|
|
||||||
);
|
|
||||||
if (!entry.valid || !_underCap(entry) || !await _ready(entry)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
} catch (_) {
|
|
||||||
await clearCache();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _underCap(SplashAdEntry entry) {
|
|
||||||
final value = StorageService.to.preferences.getString(_dailyKey);
|
|
||||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
|
||||||
if (value == null) return true;
|
|
||||||
final map = Map<String, dynamic>.from(jsonDecode(value) as Map);
|
|
||||||
return map['date'] != today ||
|
|
||||||
entry.dailyCap <= 0 ||
|
|
||||||
((map['count'] as num?)?.toInt() ?? 0) < entry.dailyCap;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _ready(SplashAdEntry entry) async =>
|
|
||||||
entry.assetPath != null && await File(entry.assetPath!).exists();
|
|
||||||
Future<void> markShown(SplashAdEntry entry) async {
|
|
||||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
|
||||||
final raw = StorageService.to.preferences.getString(_dailyKey);
|
|
||||||
var count = 0;
|
|
||||||
if (raw != null) {
|
|
||||||
final m = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
|
||||||
if (m['date'] == today) count = (m['count'] as num?)?.toInt() ?? 0;
|
|
||||||
}
|
|
||||||
await StorageService.to.preferences.setString(
|
|
||||||
_dailyKey,
|
|
||||||
jsonEncode({'date': today, 'count': count + 1}),
|
|
||||||
);
|
|
||||||
await _report(entry.ad, 'ad_impression');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> refresh() async {
|
|
||||||
try {
|
|
||||||
final result = await _api.getAdResult(splashAdPlacement);
|
|
||||||
if (!result.enabled || result.ad == null) {
|
|
||||||
await _clearEntry();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final file = await DefaultCacheManager().getSingleFile(
|
|
||||||
result.ad!.imageUrl,
|
|
||||||
);
|
|
||||||
if (!await file.exists()) return;
|
|
||||||
await StorageService.to.preferences.setString(
|
|
||||||
_entryKey,
|
|
||||||
jsonEncode(
|
|
||||||
SplashAdEntry(
|
|
||||||
result.ad!,
|
|
||||||
result.dailyCap,
|
|
||||||
file.path,
|
|
||||||
DateTime.now().millisecondsSinceEpoch,
|
|
||||||
).toJson(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _report(AdCreative ad, String event, {String? clickId}) async {
|
|
||||||
try {
|
|
||||||
await _api.report(ad, event, clickId: clickId);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> skip(SplashAdEntry entry) => _report(entry.ad, 'ad_skip');
|
|
||||||
Future<void> clearCache() async {
|
|
||||||
await _clearEntry();
|
|
||||||
await StorageService.to.preferences.remove(_dailyKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearEntry() async {
|
|
||||||
await StorageService.to.preferences.remove(_entryKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final splashAdControllerProvider = Provider(
|
|
||||||
(ref) => SplashAdController(ref.watch(adsApiClientProvider)),
|
|
||||||
);
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import '../../../common/router/app_router.dart';
|
|
||||||
import '../data/ads_api_client.dart';
|
|
||||||
import '../presentation/splash_ad_dialog.dart';
|
|
||||||
import 'splash_ad_controller.dart';
|
|
||||||
|
|
||||||
final startupAdResolvedProvider = StateProvider<bool>((_) => false);
|
|
||||||
|
|
||||||
abstract final class StartupAdService {
|
|
||||||
static Future<void>? _running;
|
|
||||||
static Future<void> showIfAvailable(BuildContext context, WidgetRef ref) =>
|
|
||||||
_running ??= _show(context, ref);
|
|
||||||
static Future<void> _show(BuildContext context, WidgetRef ref) async {
|
|
||||||
final controller = ref.read(splashAdControllerProvider);
|
|
||||||
try {
|
|
||||||
final entry = await controller.coldLaunchDecision();
|
|
||||||
if (entry != null && context.mounted) {
|
|
||||||
final result = await showGeneralDialog<SplashAdResult>(
|
|
||||||
context: context,
|
|
||||||
useRootNavigator: true,
|
|
||||||
barrierDismissible: false,
|
|
||||||
barrierColor: Colors.black,
|
|
||||||
pageBuilder: (ctx, _, _) => SplashAdDialog(
|
|
||||||
entry: entry,
|
|
||||||
onShown: () => unawaited(controller.markShown(entry)),
|
|
||||||
onClick: () async {
|
|
||||||
final id = ref.read(adsApiClientProvider).newClickId();
|
|
||||||
final uri = entry.ad.clickUri(id);
|
|
||||||
if (uri != null) {
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(adsApiClientProvider)
|
|
||||||
.report(entry.ad, 'ad_click', clickId: id),
|
|
||||||
);
|
|
||||||
if (entry.ad.openMode == AdOpenMode.inApp) {
|
|
||||||
context.push(
|
|
||||||
'${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (result == SplashAdResult.skipped) unawaited(controller.skip(entry));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
ref.read(startupAdResolvedProvider.notifier).state = true;
|
|
||||||
unawaited(controller.refresh());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
import '../../../common/network/dio_provider.dart';
|
|
||||||
|
|
||||||
const splashAdPlacement = 'splash';
|
|
||||||
const homeBannerAdPlacement = 'home_banner';
|
|
||||||
|
|
||||||
enum AdMediaType { image, gif, video, unsupported }
|
|
||||||
|
|
||||||
enum AdOpenMode { external, inApp }
|
|
||||||
|
|
||||||
class AdCreative {
|
|
||||||
const AdCreative({
|
|
||||||
required this.adId,
|
|
||||||
required this.placement,
|
|
||||||
required this.targetApp,
|
|
||||||
required this.title,
|
|
||||||
required this.imageUrl,
|
|
||||||
required this.mediaType,
|
|
||||||
required this.h5Url,
|
|
||||||
required this.trackingId,
|
|
||||||
required this.countdownSeconds,
|
|
||||||
required this.endAtMs,
|
|
||||||
required this.trackedUrl,
|
|
||||||
required this.openMode,
|
|
||||||
});
|
|
||||||
final String adId,
|
|
||||||
placement,
|
|
||||||
targetApp,
|
|
||||||
title,
|
|
||||||
imageUrl,
|
|
||||||
h5Url,
|
|
||||||
trackingId,
|
|
||||||
trackedUrl;
|
|
||||||
final AdMediaType mediaType;
|
|
||||||
final int countdownSeconds, endAtMs;
|
|
||||||
final AdOpenMode openMode;
|
|
||||||
Uri? get mediaUri => _http(imageUrl);
|
|
||||||
Uri? get targetUri => _http(trackedUrl) ?? _http(h5Url);
|
|
||||||
bool get isRenderable =>
|
|
||||||
adId.isNotEmpty &&
|
|
||||||
mediaUri != null &&
|
|
||||||
targetUri != null &&
|
|
||||||
mediaType != AdMediaType.unsupported &&
|
|
||||||
(endAtMs <= 0 || endAtMs > DateTime.now().millisecondsSinceEpoch);
|
|
||||||
Uri? clickUri(String clickId) {
|
|
||||||
final uri = targetUri;
|
|
||||||
if (uri == null) return null;
|
|
||||||
return uri.replace(
|
|
||||||
queryParameters: {
|
|
||||||
...uri.queryParameters,
|
|
||||||
'ad_id': adId,
|
|
||||||
'placement': placement,
|
|
||||||
'tracking_id': trackingId,
|
|
||||||
'click_id': clickId,
|
|
||||||
'target_app': targetApp,
|
|
||||||
'hook_type': placement,
|
|
||||||
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'adId': adId,
|
|
||||||
'placement': placement,
|
|
||||||
'targetApp': targetApp,
|
|
||||||
'title': title,
|
|
||||||
'imageUrl': imageUrl,
|
|
||||||
'mediaType': mediaType.index,
|
|
||||||
'h5Url': h5Url,
|
|
||||||
'trackingId': trackingId,
|
|
||||||
'countdownSeconds': countdownSeconds,
|
|
||||||
'endAtMs': endAtMs,
|
|
||||||
'trackedUrl': trackedUrl,
|
|
||||||
'openMode': openMode.index,
|
|
||||||
};
|
|
||||||
factory AdCreative.fromJson(Map<String, dynamic> value) => AdCreative(
|
|
||||||
adId: '${value['adId'] ?? ''}',
|
|
||||||
placement: '${value['placement'] ?? ''}',
|
|
||||||
targetApp: '${value['targetApp'] ?? ''}',
|
|
||||||
title: '${value['title'] ?? ''}',
|
|
||||||
imageUrl: '${value['imageUrl'] ?? ''}',
|
|
||||||
mediaType:
|
|
||||||
AdMediaType.values[(value['mediaType'] as num?)?.toInt().clamp(
|
|
||||||
0,
|
|
||||||
AdMediaType.values.length - 1,
|
|
||||||
) ??
|
|
||||||
AdMediaType.unsupported.index],
|
|
||||||
h5Url: '${value['h5Url'] ?? ''}',
|
|
||||||
trackingId: '${value['trackingId'] ?? ''}',
|
|
||||||
countdownSeconds: (value['countdownSeconds'] as num?)?.toInt() ?? 0,
|
|
||||||
endAtMs: (value['endAtMs'] as num?)?.toInt() ?? 0,
|
|
||||||
trackedUrl: '${value['trackedUrl'] ?? ''}',
|
|
||||||
openMode: (value['openMode'] as num?)?.toInt() == AdOpenMode.inApp.index
|
|
||||||
? AdOpenMode.inApp
|
|
||||||
: AdOpenMode.external,
|
|
||||||
);
|
|
||||||
factory AdCreative.fromProto(ProtoMessage fields) => AdCreative(
|
|
||||||
adId: fields.string(1),
|
|
||||||
placement: fields.string(2),
|
|
||||||
targetApp: fields.string(3),
|
|
||||||
title: fields.string(4),
|
|
||||||
imageUrl: fields.string(6),
|
|
||||||
mediaType: switch (fields.integer(7)) {
|
|
||||||
1 => AdMediaType.image,
|
|
||||||
2 => AdMediaType.gif,
|
|
||||||
3 => AdMediaType.video,
|
|
||||||
_ => AdMediaType.unsupported,
|
|
||||||
},
|
|
||||||
h5Url: fields.string(9),
|
|
||||||
trackingId: fields.string(10),
|
|
||||||
countdownSeconds: fields.integer(11),
|
|
||||||
endAtMs: fields.integer(13),
|
|
||||||
trackedUrl: fields.string(14),
|
|
||||||
openMode: fields.integer(15) == 2 ? AdOpenMode.inApp : AdOpenMode.external,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class GetAdResult {
|
|
||||||
const GetAdResult(this.enabled, this.ad, this.dailyCap);
|
|
||||||
final bool enabled;
|
|
||||||
final AdCreative? ad;
|
|
||||||
final int dailyCap;
|
|
||||||
}
|
|
||||||
|
|
||||||
class FeaturedAdsState {
|
|
||||||
const FeaturedAdsState(this.enabled, this.ads, {this.trackingId});
|
|
||||||
final bool enabled;
|
|
||||||
final List<AdCreative> ads;
|
|
||||||
final String? trackingId;
|
|
||||||
bool get hasAds => enabled && ads.isNotEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AdsApiClient {
|
|
||||||
AdsApiClient(this._dio);
|
|
||||||
final Dio _dio;
|
|
||||||
Future<GetAdResult> getAdResult(String placement) async {
|
|
||||||
final response = await _dio.get<List<int>>(
|
|
||||||
'/api/v1/ads',
|
|
||||||
queryParameters: {'placement': placement},
|
|
||||||
options: protobufOptions(),
|
|
||||||
);
|
|
||||||
final root = ProtoMessage(response.data ?? const []);
|
|
||||||
final creative = root.message(2);
|
|
||||||
final ad = AdCreative.fromProto(creative);
|
|
||||||
return GetAdResult(
|
|
||||||
root.integer(1) != 0,
|
|
||||||
ad.isRenderable ? ad : null,
|
|
||||||
root.integer(3),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<FeaturedAdsState> listAds(String placement) async {
|
|
||||||
final response = await _dio.get<List<int>>(
|
|
||||||
'/api/v1/ads/list',
|
|
||||||
queryParameters: {'placement': placement},
|
|
||||||
options: protobufOptions(),
|
|
||||||
);
|
|
||||||
final root = ProtoMessage(response.data ?? const []);
|
|
||||||
final ads = root
|
|
||||||
.messages(2)
|
|
||||||
.map(AdCreative.fromProto)
|
|
||||||
.where((ad) => ad.isRenderable)
|
|
||||||
.toList(growable: false);
|
|
||||||
return FeaturedAdsState(
|
|
||||||
root.integer(1) != 0,
|
|
||||||
ads,
|
|
||||||
trackingId: root.string(3).isEmpty ? null : root.string(3),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> report(
|
|
||||||
AdCreative ad,
|
|
||||||
String event, {
|
|
||||||
String? clickId,
|
|
||||||
String? trackingId,
|
|
||||||
}) => _dio.post<List<int>>(
|
|
||||||
'/api/v1/ads/events',
|
|
||||||
data: _Writer()
|
|
||||||
.string(1, event)
|
|
||||||
.string(2, ad.placement)
|
|
||||||
.string(3, ad.adId)
|
|
||||||
.string(4, trackingId ?? ad.trackingId)
|
|
||||||
.string(5, clickId ?? '')
|
|
||||||
.integer(6, DateTime.now().millisecondsSinceEpoch)
|
|
||||||
.toBytes(),
|
|
||||||
options: protobufOptions(),
|
|
||||||
);
|
|
||||||
String newClickId() => const Uuid().v4();
|
|
||||||
}
|
|
||||||
|
|
||||||
final adsApiClientProvider = Provider(
|
|
||||||
(ref) => AdsApiClient(ref.watch(dioProvider)),
|
|
||||||
);
|
|
||||||
final featuredAdsProvider = FutureProvider.autoDispose
|
|
||||||
.family<FeaturedAdsState, String>(
|
|
||||||
(ref, placement) => ref.watch(adsApiClientProvider).listAds(placement),
|
|
||||||
);
|
|
||||||
|
|
||||||
Uri? _http(String raw) {
|
|
||||||
final uri = Uri.tryParse(raw.trim());
|
|
||||||
return uri != null &&
|
|
||||||
uri.host.isNotEmpty &&
|
|
||||||
(uri.scheme == 'https' || uri.scheme == 'http')
|
|
||||||
? uri
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProtoMessage {
|
|
||||||
ProtoMessage(this.bytes);
|
|
||||||
final List<int> bytes;
|
|
||||||
Map<int, List<Object>>? _cache;
|
|
||||||
Map<int, List<Object>> get _fields => _cache ??= _read();
|
|
||||||
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
|
|
||||||
String string(int tag) {
|
|
||||||
final value = _fields[tag]?.lastOrNull;
|
|
||||||
return value is List<int> ? String.fromCharCodes(value) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
ProtoMessage message(int tag) {
|
|
||||||
final value = _fields[tag]?.lastOrNull;
|
|
||||||
return ProtoMessage(value is List<int> ? value : const []);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<ProtoMessage> messages(int tag) => (_fields[tag] ?? const [])
|
|
||||||
.whereType<List<int>>()
|
|
||||||
.map(ProtoMessage.new)
|
|
||||||
.toList();
|
|
||||||
Map<int, List<Object>> _read() {
|
|
||||||
final out = <int, List<Object>>{};
|
|
||||||
var i = 0;
|
|
||||||
int v() {
|
|
||||||
var n = 0, s = 0;
|
|
||||||
while (i < bytes.length) {
|
|
||||||
final b = bytes[i++];
|
|
||||||
n |= (b & 127) << s;
|
|
||||||
if (b < 128) return n;
|
|
||||||
s += 7;
|
|
||||||
}
|
|
||||||
throw const FormatException('Invalid protobuf');
|
|
||||||
}
|
|
||||||
|
|
||||||
while (i < bytes.length) {
|
|
||||||
final key = v(), tag = key >> 3;
|
|
||||||
Object value;
|
|
||||||
if ((key & 7) == 0) {
|
|
||||||
value = v();
|
|
||||||
} else if ((key & 7) == 2) {
|
|
||||||
final l = v(), end = i + l;
|
|
||||||
if (end > bytes.length) throw const FormatException('Invalid protobuf');
|
|
||||||
value = bytes.sublist(i, end);
|
|
||||||
i = end;
|
|
||||||
} else {
|
|
||||||
throw const FormatException('Unsupported protobuf');
|
|
||||||
}
|
|
||||||
(out[tag] ??= []).add(value);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension on List<Object> {
|
|
||||||
Object? get lastOrNull => isEmpty ? null : last;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Writer {
|
|
||||||
final b = BytesBuilder(copy: false);
|
|
||||||
_Writer string(int t, String value) {
|
|
||||||
final d = value.codeUnits;
|
|
||||||
v((t << 3) | 2);
|
|
||||||
v(d.length);
|
|
||||||
b.add(d);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
_Writer integer(int t, int value) {
|
|
||||||
v(t << 3);
|
|
||||||
v(value);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
Uint8List toBytes() => b.toBytes();
|
|
||||||
void v(int n) {
|
|
||||||
while (n > 127) {
|
|
||||||
b.addByte((n & 127) | 128);
|
|
||||||
n >>= 7;
|
|
||||||
}
|
|
||||||
b.addByte(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io' show File;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
import '../data/ads_api_client.dart';
|
|
||||||
|
|
||||||
/// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。
|
|
||||||
class AdMedia extends StatefulWidget {
|
|
||||||
const AdMedia({
|
|
||||||
super.key,
|
|
||||||
required this.ad,
|
|
||||||
this.assetPath,
|
|
||||||
this.fit = BoxFit.cover,
|
|
||||||
});
|
|
||||||
final AdCreative ad;
|
|
||||||
final String? assetPath;
|
|
||||||
final BoxFit fit;
|
|
||||||
@override
|
|
||||||
State<AdMedia> createState() => _AdMediaState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AdMediaState extends State<AdMedia> {
|
|
||||||
VideoPlayerController? player;
|
|
||||||
bool ready = false, failed = false;
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant AdMedia old) {
|
|
||||||
super.didUpdateWidget(old);
|
|
||||||
if (old.ad.adId != widget.ad.adId || old.assetPath != widget.assetPath) {
|
|
||||||
unawaited(player?.dispose() ?? Future.value());
|
|
||||||
player = null;
|
|
||||||
ready = false;
|
|
||||||
failed = false;
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _load() {
|
|
||||||
if (widget.ad.mediaType != AdMediaType.video) return;
|
|
||||||
final uri = widget.ad.mediaUri;
|
|
||||||
if (uri == null) return;
|
|
||||||
final path = widget.assetPath;
|
|
||||||
final controller = !kIsWeb && path != null && path.isNotEmpty
|
|
||||||
? VideoPlayerController.file(File(path))
|
|
||||||
: VideoPlayerController.networkUrl(uri);
|
|
||||||
player = controller;
|
|
||||||
unawaited(
|
|
||||||
controller
|
|
||||||
.initialize()
|
|
||||||
.then((_) async {
|
|
||||||
await controller.setLooping(true);
|
|
||||||
await controller.setVolume(0);
|
|
||||||
await controller.play();
|
|
||||||
if (mounted) setState(() => ready = true);
|
|
||||||
})
|
|
||||||
.catchError((_) {
|
|
||||||
if (mounted) setState(() => failed = true);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
player?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (widget.ad.mediaType != AdMediaType.video) {
|
|
||||||
final path = widget.assetPath;
|
|
||||||
if (!kIsWeb && path != null && path.isNotEmpty) {
|
|
||||||
return Image.file(
|
|
||||||
File(path),
|
|
||||||
fit: widget.fit,
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
errorBuilder: (_, _, _) => _error(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Image.network(
|
|
||||||
widget.ad.imageUrl,
|
|
||||||
fit: widget.fit,
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
errorBuilder: (_, _, _) => _error(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final c = player;
|
|
||||||
if (failed) return _error();
|
|
||||||
if (c == null || !ready) {
|
|
||||||
return const ColoredBox(
|
|
||||||
color: Color(0xFF161616),
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return FittedBox(
|
|
||||||
fit: widget.fit,
|
|
||||||
clipBehavior: Clip.hardEdge,
|
|
||||||
child: SizedBox(
|
|
||||||
width: c.value.size.width,
|
|
||||||
height: c.value.size.height,
|
|
||||||
child: VideoPlayer(c),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _error() => const ColoredBox(
|
|
||||||
color: Color(0xFF161616),
|
|
||||||
child: Center(
|
|
||||||
child: Icon(Icons.broken_image_outlined, color: Colors.white54),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
import '../../../common/router/app_router.dart';
|
|
||||||
import '../data/ads_api_client.dart';
|
|
||||||
import 'ad_media.dart';
|
|
||||||
|
|
||||||
class HomeAdBanner extends ConsumerStatefulWidget {
|
|
||||||
const HomeAdBanner({super.key});
|
|
||||||
@override
|
|
||||||
ConsumerState<HomeAdBanner> createState() => _HomeAdBannerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HomeAdBannerState extends ConsumerState<HomeAdBanner> {
|
|
||||||
final pages = PageController();
|
|
||||||
final impressed = <String>{};
|
|
||||||
Timer? timer;
|
|
||||||
int index = 0;
|
|
||||||
int _length = 0;
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
timer?.cancel();
|
|
||||||
pages.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sync(List<AdCreative> ads) {
|
|
||||||
if (_length == ads.length) return;
|
|
||||||
_length = ads.length;
|
|
||||||
timer?.cancel();
|
|
||||||
if (ads.length > 1) {
|
|
||||||
timer = Timer.periodic(const Duration(seconds: 4), (_) {
|
|
||||||
if (pages.hasClients) {
|
|
||||||
pages.nextPage(
|
|
||||||
duration: const Duration(milliseconds: 320),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _impression(AdCreative ad, String? id) {
|
|
||||||
if (!impressed.add(ad.adId)) return;
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(adsApiClientProvider)
|
|
||||||
.report(ad, 'ad_impression', trackingId: id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _open(AdCreative ad, String? tracking) async {
|
|
||||||
final client = ref.read(adsApiClientProvider);
|
|
||||||
final id = client.newClickId();
|
|
||||||
final uri = ad.clickUri(id);
|
|
||||||
if (uri == null) return;
|
|
||||||
unawaited(client.report(ad, 'ad_click', clickId: id, trackingId: tracking));
|
|
||||||
if (!mounted) return;
|
|
||||||
if (ad.openMode == AdOpenMode.inApp) {
|
|
||||||
context.push(
|
|
||||||
'${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final async = ref.watch(featuredAdsProvider(homeBannerAdPlacement));
|
|
||||||
return async.maybeWhen(
|
|
||||||
data: (value) {
|
|
||||||
if (!value.hasAds) return const SizedBox.shrink();
|
|
||||||
final ads = value.ads;
|
|
||||||
_sync(ads);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
_impression(ads[index.clamp(0, ads.length - 1)], value.trackingId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
AspectRatio(
|
|
||||||
aspectRatio: 750 / 200,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: PageView.builder(
|
|
||||||
controller: pages,
|
|
||||||
onPageChanged: (page) {
|
|
||||||
setState(() => index = page % ads.length);
|
|
||||||
_impression(ads[index], value.trackingId);
|
|
||||||
},
|
|
||||||
itemBuilder: (_, page) {
|
|
||||||
final ad = ads[page % ads.length];
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => _open(ad, value.trackingId),
|
|
||||||
child: AdMedia(ad: ad),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (ads.length > 1)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: List.generate(
|
|
||||||
ads.length,
|
|
||||||
(i) => Container(
|
|
||||||
width: i == index ? 14 : 6,
|
|
||||||
height: 6,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: i == index
|
|
||||||
? Theme.of(context).colorScheme.primary
|
|
||||||
: Theme.of(context).dividerColor,
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
orElse: () => const SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../application/splash_ad_controller.dart';
|
|
||||||
import 'ad_media.dart';
|
|
||||||
|
|
||||||
enum SplashAdResult { clicked, skipped, completed }
|
|
||||||
|
|
||||||
class SplashAdDialog extends StatefulWidget {
|
|
||||||
const SplashAdDialog({
|
|
||||||
super.key,
|
|
||||||
required this.entry,
|
|
||||||
required this.onClick,
|
|
||||||
required this.onShown,
|
|
||||||
});
|
|
||||||
final SplashAdEntry entry;
|
|
||||||
final Future<void> Function() onClick;
|
|
||||||
final VoidCallback onShown;
|
|
||||||
@override
|
|
||||||
State<SplashAdDialog> createState() => _SplashAdDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SplashAdDialogState extends State<SplashAdDialog> {
|
|
||||||
Timer? timer;
|
|
||||||
late int remain;
|
|
||||||
bool opening = false;
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
remain = widget.entry.ad.countdownSeconds.clamp(1, 5);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
widget.onShown();
|
|
||||||
});
|
|
||||||
timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (remain <= 1) {
|
|
||||||
Navigator.of(context).pop(SplashAdResult.completed);
|
|
||||||
} else if (mounted) {
|
|
||||||
setState(() => remain--);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
timer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> open() async {
|
|
||||||
if (opening) return;
|
|
||||||
opening = true;
|
|
||||||
timer?.cancel();
|
|
||||||
await widget.onClick();
|
|
||||||
if (mounted) Navigator.of(context).pop(SplashAdResult.clicked);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Material(
|
|
||||||
color: Colors.black,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: open,
|
|
||||||
child: AdMedia(
|
|
||||||
ad: widget.entry.ad,
|
|
||||||
assetPath: widget.entry.assetPath,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: MediaQuery.paddingOf(context).top + 16,
|
|
||||||
right: 18,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped),
|
|
||||||
child: Text('跳过 ${remain}s'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -49,6 +49,56 @@ class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateHolding(GoldAssetHolding holding) {
|
||||||
|
final index = _holdings.indexWhere((item) => item.id == holding.id);
|
||||||
|
if (index == -1) return;
|
||||||
|
_holdings[index] = holding;
|
||||||
|
state = _buildSummary(
|
||||||
|
holdings: _holdings,
|
||||||
|
spotPriceFor: _spotPriceFor,
|
||||||
|
changeFor: _changeFor,
|
||||||
|
recycleDiscount: recycleDiscount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeHolding(String id) {
|
||||||
|
_holdings.removeWhere((item) => item.id == id);
|
||||||
|
state = _buildSummary(
|
||||||
|
holdings: _holdings,
|
||||||
|
spotPriceFor: _spotPriceFor,
|
||||||
|
changeFor: _changeFor,
|
||||||
|
recycleDiscount: recycleDiscount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void markHoldingSold(String id) {
|
||||||
|
final index = _holdings.indexWhere((item) => item.id == id);
|
||||||
|
if (index == -1) return;
|
||||||
|
final holding = _holdings[index];
|
||||||
|
_holdings[index] = GoldAssetHolding(
|
||||||
|
id: holding.id,
|
||||||
|
name: holding.name,
|
||||||
|
metal: holding.metal,
|
||||||
|
category: holding.category,
|
||||||
|
purity: holding.purity,
|
||||||
|
purityLabel: holding.purityLabel,
|
||||||
|
weightGram: holding.weightGram,
|
||||||
|
costAmount: holding.costAmount,
|
||||||
|
purchaseDate: holding.purchaseDate,
|
||||||
|
channel: holding.channel,
|
||||||
|
note: holding.note,
|
||||||
|
createdAt: holding.createdAt,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
status: HoldingStatus.sold,
|
||||||
|
);
|
||||||
|
state = _buildSummary(
|
||||||
|
holdings: _holdings,
|
||||||
|
spotPriceFor: _spotPriceFor,
|
||||||
|
changeFor: _changeFor,
|
||||||
|
recycleDiscount: recycleDiscount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static PortfolioSummary _buildSummary({
|
static PortfolioSummary _buildSummary({
|
||||||
required List<GoldAssetHolding> holdings,
|
required List<GoldAssetHolding> holdings,
|
||||||
required double Function(MetalType metal) spotPriceFor,
|
required double Function(MetalType metal) spotPriceFor,
|
||||||
|
|||||||
@@ -1,5 +1,58 @@
|
|||||||
import '../../../common/domain/metal_type.dart';
|
import '../../../common/domain/metal_type.dart';
|
||||||
|
|
||||||
|
class AssetImage {
|
||||||
|
const AssetImage({
|
||||||
|
required this.localId,
|
||||||
|
required this.localPath,
|
||||||
|
required this.createdAt,
|
||||||
|
this.remoteUrl,
|
||||||
|
this.thumbnailUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String localId;
|
||||||
|
final String localPath;
|
||||||
|
final String? remoteUrl;
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
AssetImage copyWith({
|
||||||
|
String? localId,
|
||||||
|
String? localPath,
|
||||||
|
String? remoteUrl,
|
||||||
|
String? thumbnailUrl,
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) {
|
||||||
|
return AssetImage(
|
||||||
|
localId: localId ?? this.localId,
|
||||||
|
localPath: localPath ?? this.localPath,
|
||||||
|
remoteUrl: remoteUrl ?? this.remoteUrl,
|
||||||
|
thumbnailUrl: thumbnailUrl ?? this.thumbnailUrl,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'localId': localId,
|
||||||
|
'localPath': localPath,
|
||||||
|
'remoteUrl': remoteUrl,
|
||||||
|
'thumbnailUrl': thumbnailUrl,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AssetImage.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AssetImage(
|
||||||
|
localId: json['localId'] as String? ?? '',
|
||||||
|
localPath: json['localPath'] as String? ?? '',
|
||||||
|
remoteUrl: json['remoteUrl'] as String?,
|
||||||
|
thumbnailUrl: json['thumbnailUrl'] as String?,
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum AssetCategory {
|
enum AssetCategory {
|
||||||
bar('金条'),
|
bar('金条'),
|
||||||
necklace('项链'),
|
necklace('项链'),
|
||||||
@@ -28,6 +81,7 @@ class GoldAssetHolding {
|
|||||||
required this.weightGram,
|
required this.weightGram,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
|
this.images = const [],
|
||||||
this.costAmount,
|
this.costAmount,
|
||||||
this.purchaseDate,
|
this.purchaseDate,
|
||||||
this.channel,
|
this.channel,
|
||||||
@@ -42,6 +96,7 @@ class GoldAssetHolding {
|
|||||||
final double purity;
|
final double purity;
|
||||||
final String purityLabel;
|
final String purityLabel;
|
||||||
final double weightGram;
|
final double weightGram;
|
||||||
|
final List<AssetImage> images;
|
||||||
final double? costAmount;
|
final double? costAmount;
|
||||||
final DateTime? purchaseDate;
|
final DateTime? purchaseDate;
|
||||||
final String? channel;
|
final String? channel;
|
||||||
@@ -50,7 +105,92 @@ class GoldAssetHolding {
|
|||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
final HoldingStatus status;
|
final HoldingStatus status;
|
||||||
|
|
||||||
|
GoldAssetHolding copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
MetalType? metal,
|
||||||
|
AssetCategory? category,
|
||||||
|
double? purity,
|
||||||
|
String? purityLabel,
|
||||||
|
double? weightGram,
|
||||||
|
List<AssetImage>? images,
|
||||||
|
double? costAmount,
|
||||||
|
DateTime? purchaseDate,
|
||||||
|
String? channel,
|
||||||
|
String? note,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
HoldingStatus? status,
|
||||||
|
}) {
|
||||||
|
return GoldAssetHolding(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
metal: metal ?? this.metal,
|
||||||
|
category: category ?? this.category,
|
||||||
|
purity: purity ?? this.purity,
|
||||||
|
purityLabel: purityLabel ?? this.purityLabel,
|
||||||
|
weightGram: weightGram ?? this.weightGram,
|
||||||
|
images: images ?? this.images,
|
||||||
|
costAmount: costAmount ?? this.costAmount,
|
||||||
|
purchaseDate: purchaseDate ?? this.purchaseDate,
|
||||||
|
channel: channel ?? this.channel,
|
||||||
|
note: note ?? this.note,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
status: status ?? this.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
|
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'metalId': metal.id,
|
||||||
|
'categoryId': category.name,
|
||||||
|
'purity': purity,
|
||||||
|
'purityLabel': purityLabel,
|
||||||
|
'weightGram': weightGram,
|
||||||
|
'images': images.map((item) => item.toJson()).toList(growable: false),
|
||||||
|
'costAmount': costAmount,
|
||||||
|
'purchaseDate': purchaseDate?.toIso8601String(),
|
||||||
|
'channel': channel,
|
||||||
|
'note': note,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
'status': status.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory GoldAssetHolding.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawImages = json['images'];
|
||||||
|
return GoldAssetHolding(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
metal: _metalFromId(json['metalId'] as String?),
|
||||||
|
category: _categoryFromId(json['categoryId'] as String?),
|
||||||
|
purity: (json['purity'] as num?)?.toDouble() ?? 1,
|
||||||
|
purityLabel: json['purityLabel'] as String? ?? '',
|
||||||
|
weightGram: (json['weightGram'] as num?)?.toDouble() ?? 0,
|
||||||
|
images: rawImages is List
|
||||||
|
? [
|
||||||
|
for (final raw in rawImages)
|
||||||
|
if (raw is Map)
|
||||||
|
AssetImage.fromJson(Map<String, dynamic>.from(raw)),
|
||||||
|
]
|
||||||
|
: const [],
|
||||||
|
costAmount: (json['costAmount'] as num?)?.toDouble(),
|
||||||
|
purchaseDate: DateTime.tryParse(json['purchaseDate'] as String? ?? ''),
|
||||||
|
channel: json['channel'] as String?,
|
||||||
|
note: json['note'] as String?,
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
status: _statusFromName(json['status'] as String?),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HoldingValuation {
|
class HoldingValuation {
|
||||||
@@ -100,3 +240,26 @@ class PortfolioSummary {
|
|||||||
final List<MetalBreakdown> breakdowns;
|
final List<MetalBreakdown> breakdowns;
|
||||||
final List<HoldingValuation> holdings;
|
final List<HoldingValuation> holdings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MetalType _metalFromId(String? id) {
|
||||||
|
return switch (id) {
|
||||||
|
'gold' => MetalType.gold,
|
||||||
|
'platinum' => MetalType.platinum,
|
||||||
|
'silver' => MetalType.silver,
|
||||||
|
_ => MetalType.gold,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetCategory _categoryFromId(String? id) {
|
||||||
|
return AssetCategory.values.firstWhere(
|
||||||
|
(item) => item.name == id,
|
||||||
|
orElse: () => AssetCategory.bar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HoldingStatus _statusFromName(String? value) {
|
||||||
|
return HoldingStatus.values.firstWhere(
|
||||||
|
(item) => item.name == value,
|
||||||
|
orElse: () => HoldingStatus.active,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'asset_form_page.dart';
|
||||||
|
|
||||||
|
class AssetAddPage extends StatelessWidget {
|
||||||
|
const AssetAddPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const AssetFormPage.create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../application/asset_portfolio_controller.dart';
|
||||||
|
import 'asset_form_page.dart';
|
||||||
|
|
||||||
|
class AssetEditPage extends ConsumerWidget {
|
||||||
|
const AssetEditPage({super.key, required this.id});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final holding = ref
|
||||||
|
.watch(assetPortfolioControllerProvider)
|
||||||
|
.holdings
|
||||||
|
.map((item) => item.holding)
|
||||||
|
.firstWhere((item) => item.id == id);
|
||||||
|
return AssetFormPage.edit(holding: holding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart' hide AssetImage;
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:photo_view/photo_view_gallery.dart';
|
||||||
|
|
||||||
|
import '../data/asset_models.dart';
|
||||||
|
|
||||||
|
class AssetImagePreviewPage extends StatefulWidget {
|
||||||
|
const AssetImagePreviewPage({
|
||||||
|
super.key,
|
||||||
|
required this.images,
|
||||||
|
required this.initialIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<AssetImage> images;
|
||||||
|
final int initialIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AssetImagePreviewPage> createState() => _AssetImagePreviewPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetImagePreviewPageState extends State<AssetImagePreviewPage> {
|
||||||
|
late final PageController _pageController;
|
||||||
|
late int _currentIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_currentIndex = widget.initialIndex.clamp(0, widget.images.length - 1);
|
||||||
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
PhotoViewGallery.builder(
|
||||||
|
pageController: _pageController,
|
||||||
|
itemCount: widget.images.length,
|
||||||
|
builder: (context, index) {
|
||||||
|
final image = widget.images[index];
|
||||||
|
return PhotoViewGalleryPageOptions(
|
||||||
|
imageProvider: FileImage(File(image.localPath)),
|
||||||
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
maxScale: PhotoViewComputedScale.covered * 2.2,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPageChanged: (index) => setState(() {
|
||||||
|
_currentIndex = index;
|
||||||
|
}),
|
||||||
|
backgroundDecoration: const BoxDecoration(color: Colors.black),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 24,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${_currentIndex + 1}/${widget.images.length}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart' hide AssetImage;
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import '../../../common/domain/money.dart';
|
||||||
|
import '../../../common/router/app_router.dart';
|
||||||
|
import '../../../common/widgets/prototype_ui.dart';
|
||||||
|
import '../../profile/application/price_color_theme.dart';
|
||||||
|
import '../../profile/application/profile_settings_controller.dart';
|
||||||
|
import '../application/asset_portfolio_controller.dart';
|
||||||
|
import '../data/asset_models.dart';
|
||||||
|
import 'asset_image_preview_page.dart';
|
||||||
|
|
||||||
|
class HoldingDetailPage extends ConsumerWidget {
|
||||||
|
const HoldingDetailPage({super.key, this.id});
|
||||||
|
|
||||||
|
final String? id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final summary = ref.watch(assetPortfolioControllerProvider);
|
||||||
|
final holding = summary.holdings
|
||||||
|
.map((item) => item.holding)
|
||||||
|
.firstWhere(
|
||||||
|
(item) => id == null || item.id == id,
|
||||||
|
orElse: () => summary.holdings.first.holding,
|
||||||
|
);
|
||||||
|
final valuation = summary.holdings.firstWhere(
|
||||||
|
(item) => item.holding.id == holding.id,
|
||||||
|
);
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
final palette = priceColorPalette(
|
||||||
|
ref.watch(profileSettingsControllerProvider).priceColorMode,
|
||||||
|
);
|
||||||
|
final changeUp = valuation.todayChange >= 0;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFFBFAF7),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFFFBFAF7),
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.chevron_left, size: 20),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
holding.name,
|
||||||
|
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _showActionsSheet(context, ref, holding),
|
||||||
|
icon: const Icon(Icons.more_horiz, size: 22),
|
||||||
|
color: const Color(0xFF8A8780),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 28),
|
||||||
|
children: [
|
||||||
|
PrototypeFrame(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
PrototypeCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: holding.metal.color.withValues(
|
||||||
|
alpha: 0.15,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.circle_outlined,
|
||||||
|
color: holding.metal.color,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'${holding.purityLabel} · ${holding.weightGram.toStringAsFixed(1)}g',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: cs.mutedForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'¥',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
formatCny(
|
||||||
|
valuation.materialValue,
|
||||||
|
).replaceFirst('¥', ''),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 42,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -1,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ChangeChip(
|
||||||
|
amount: valuation.todayChange,
|
||||||
|
positive: changeUp,
|
||||||
|
upColor: Color(palette.up),
|
||||||
|
downColor: Color(palette.down),
|
||||||
|
upBackground: Color(palette.upBackground),
|
||||||
|
downBackground: Color(palette.downBackground),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'今日',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: cs.mutedForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => context.push(
|
||||||
|
'${AppRoutes.marketKline}?metal=${holding.metal.id}&period=24h',
|
||||||
|
),
|
||||||
|
child: PrototypeCard(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 92,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: PrototypeLineChart(
|
||||||
|
values: const [
|
||||||
|
18,
|
||||||
|
20,
|
||||||
|
21,
|
||||||
|
23,
|
||||||
|
22,
|
||||||
|
25,
|
||||||
|
29,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
33,
|
||||||
|
35,
|
||||||
|
37,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PrototypeCard(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_InfoRow(
|
||||||
|
label: '材料价值(现货)',
|
||||||
|
value: formatCny(valuation.materialValue),
|
||||||
|
),
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_InfoRow(
|
||||||
|
label: '回收参考价',
|
||||||
|
value: formatCny(valuation.materialValue * 0.97),
|
||||||
|
),
|
||||||
|
if (holding.costAmount != null) ...[
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_InfoRow(
|
||||||
|
label: '买入成本',
|
||||||
|
value: formatCny(holding.costAmount!),
|
||||||
|
),
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_InfoRow(
|
||||||
|
label: '盈亏',
|
||||||
|
value: _signedCny(
|
||||||
|
valuation.materialValue - holding.costAmount!,
|
||||||
|
),
|
||||||
|
valueColor:
|
||||||
|
valuation.materialValue - holding.costAmount! >=
|
||||||
|
0
|
||||||
|
? Color(palette.up)
|
||||||
|
: Color(palette.down),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PrototypeCard(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_InfoRow(
|
||||||
|
label: '金属 / 纯度',
|
||||||
|
value:
|
||||||
|
'${holding.metal.label} · ${holding.purityLabel}',
|
||||||
|
),
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_InfoRow(
|
||||||
|
label: '克重',
|
||||||
|
value: '${holding.weightGram.toStringAsFixed(1)} g',
|
||||||
|
),
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_InfoRow(
|
||||||
|
label: '购买日期',
|
||||||
|
value: _dateLabel(holding.purchaseDate),
|
||||||
|
),
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_InfoRow(label: '购买渠道', value: holding.channel ?? '未填'),
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_InfoRow(
|
||||||
|
label: '备注',
|
||||||
|
value: holding.note == null ||
|
||||||
|
holding.note!.trim().isEmpty
|
||||||
|
? '未填'
|
||||||
|
: holding.note!.trim(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildImageSection(context, holding),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => context.push(
|
||||||
|
'${AppRoutes.assetEdit}?id=${holding.id}',
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Color(0xFFE3D6B4)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
foregroundColor: const Color(0xFF9A7A2A),
|
||||||
|
),
|
||||||
|
child: const Text('编辑'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => _markSold(context, ref, holding.id),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Color(0xFFE6E1D8)),
|
||||||
|
foregroundColor: const Color(0xFF8A8780),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('标记卖出 / 转赠'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showActionsSheet(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
GoldAssetHolding holding,
|
||||||
|
) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 38,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE3DFD4),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ActionMenuTile(
|
||||||
|
icon: Icons.edit_outlined,
|
||||||
|
label: '编辑持仓',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
context.push('${AppRoutes.assetEdit}?id=${holding.id}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_ActionMenuTile(
|
||||||
|
icon: Icons.share_outlined,
|
||||||
|
label: '分享这张的估值卡',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
context.push(AppRoutes.profileShare);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const PrototypeDivider(margin: EdgeInsets.zero),
|
||||||
|
_ActionMenuTile(
|
||||||
|
icon: Icons.sell_outlined,
|
||||||
|
label: '标记卖出 / 转赠',
|
||||||
|
labelColor: const Color(0xFFC0392B),
|
||||||
|
iconColor: const Color(0xFFC0392B),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_markSold(context, ref, holding.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markSold(BuildContext context, WidgetRef ref, String id) {
|
||||||
|
ref.read(assetPortfolioControllerProvider.notifier).markHoldingSold(id);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已标记卖出 / 转赠')));
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageSection(
|
||||||
|
BuildContext context,
|
||||||
|
GoldAssetHolding holding,
|
||||||
|
) {
|
||||||
|
final images = holding.images;
|
||||||
|
return PrototypeCard(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'凭证照片',
|
||||||
|
style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (images.isEmpty)
|
||||||
|
const Text(
|
||||||
|
'暂无凭证照片,可在添加资产时上传发票、证书或实物照片。',
|
||||||
|
style: TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < images.length; index++)
|
||||||
|
_buildDetailImageTile(
|
||||||
|
context,
|
||||||
|
holding,
|
||||||
|
images[index],
|
||||||
|
index,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailImageTile(
|
||||||
|
BuildContext context,
|
||||||
|
GoldAssetHolding holding,
|
||||||
|
AssetImage image,
|
||||||
|
int index,
|
||||||
|
) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _openImagePreview(context, holding.images, index),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.file(
|
||||||
|
File(image.localPath),
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openImagePreview(
|
||||||
|
BuildContext context,
|
||||||
|
List<AssetImage> images,
|
||||||
|
int initialIndex,
|
||||||
|
) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => AssetImagePreviewPage(
|
||||||
|
images: images,
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionMenuTile extends StatelessWidget {
|
||||||
|
const _ActionMenuTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.iconColor,
|
||||||
|
this.labelColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Color? iconColor;
|
||||||
|
final Color? labelColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: iconColor ?? const Color(0xFF8A8780)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: labelColor ?? cs.foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoRow extends StatelessWidget {
|
||||||
|
const _InfoRow({required this.label, required this.value, this.valueColor});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color? valueColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(fontSize: 13, color: cs.mutedForeground),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valueColor ?? cs.foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChangeChip extends StatelessWidget {
|
||||||
|
const _ChangeChip({
|
||||||
|
required this.amount,
|
||||||
|
required this.positive,
|
||||||
|
required this.upColor,
|
||||||
|
required this.downColor,
|
||||||
|
required this.upBackground,
|
||||||
|
required this.downBackground,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double amount;
|
||||||
|
final bool positive;
|
||||||
|
final Color upColor;
|
||||||
|
final Color downColor;
|
||||||
|
final Color upBackground;
|
||||||
|
final Color downBackground;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: positive ? upBackground : downBackground,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${positive ? '↑' : '↓'}${formatCny(amount.abs())} · ${positive ? '+' : '-'}${amount.abs().toStringAsFixed(2)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: positive ? upColor : downColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FieldRow extends StatelessWidget {
|
||||||
|
const _FieldRow({required this.label, required this.child});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFECEAE3), width: 0.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 13, color: Color(0xFF8A8780)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dateLabel(DateTime? value) {
|
||||||
|
if (value == null) return '未填';
|
||||||
|
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _signedCny(double value) {
|
||||||
|
final sign = value >= 0 ? '+' : '-';
|
||||||
|
return '$sign${formatCny(value.abs())}';
|
||||||
|
}
|
||||||
@@ -1,25 +1,20 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../../common/network/auth_interceptor.dart';
|
import '../../../common/network/auth_interceptor.dart';
|
||||||
import '../../../common/storage/storage_keys.dart';
|
import '../../../common/storage/storage_keys.dart';
|
||||||
import '../../../common/storage/storage_service.dart';
|
import '../../../common/storage/storage_service.dart';
|
||||||
import '../../../common/network/dio_provider.dart';
|
|
||||||
import '../data/auth_models.dart';
|
import '../data/auth_models.dart';
|
||||||
import '../data/one_key_login_api.dart';
|
|
||||||
|
|
||||||
final authProvider =
|
final authProvider =
|
||||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
||||||
(ref) => AuthController(ref.read(dioProvider)),
|
(ref) => AuthController(),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||||
AuthController(this._dio) : super(const AsyncLoading()) {
|
AuthController() : super(const AsyncLoading()) {
|
||||||
_bootstrap();
|
_bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
Future<void> _bootstrap() async {
|
Future<void> _bootstrap() async {
|
||||||
final token = await loadJwt();
|
final token = await loadJwt();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
@@ -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 {
|
Future<void> logout() async {
|
||||||
await clearJwt();
|
await clearJwt();
|
||||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||