17 Commits
Author SHA1 Message Date
jingyun 38a96109bc fix:UI 2026-06-23 15:05:03 +08:00
jingyun 428969245d fix:金条详情和编辑,添加一致 2026-06-23 14:42:13 +08:00
jingyun 2e10f01496 fix:免责声明 2026-06-23 11:07:12 +08:00
jingyun 7d571e22eb fix:换platform_metadata库 2026-06-23 11:06:58 +08:00
jingyun 088bb61411 fix:多余的屏蔽 2026-06-23 10:42:29 +08:00
jingyun aeb7ebd642 feat:分享功能 2026-06-23 10:42:14 +08:00
jingyun d59c3212b8 fix:日历选择交互 2026-06-23 10:18:36 +08:00
jingyun 8cf919283b fix:增加图片选择和拍照功能 2026-06-23 10:07:35 +08:00
jingyun 75ba309e8c fix:我的和设置列表 2026-06-22 16:54:55 +08:00
jingyun 3513fbac0f fix:隐藏修改多余 2026-06-22 15:33:38 +08:00
jingyun ab99010306 fix:持仓详情编辑,资讯下拉加载更多 2026-06-22 11:36:09 +08:00
jingyun 08023aff82 fix:h5地址 2026-06-18 18:54:30 +08:00
jingyun 0b4b113de8 fix:折算设置 2026-06-18 18:44:23 +08:00
jingyun 975f115859 fix:折扣设置页 2026-06-18 18:22:49 +08:00
jingyun 2d7523f901 fix:UI对齐demo 2026-06-18 18:08:29 +08:00
jingyun f42ad566e0 fix:靠原型不齐页面 2026-06-18 16:53:08 +08:00
jingyun 35eed6b5b8 fix:jinzhi包名和签名 2026-06-18 15:13:24 +08:00
238 changed files with 11480 additions and 7209 deletions

No files matched your search

-7
View File
@@ -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
+5 -33
View File
@@ -1,18 +1,9 @@
.PHONY: runner run test lint apk ipa build-web rename_release_apk jinzhi_shorebird_android jinzhi_shorebird_ios jinzhi_shorebird_android_patch jinzhi_shorebird_ios_patch .PHONY: runner run test lint apk ipa build-web
WEB_API_URL ?= https://jinzhi.api.local WEB_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
+5 -37
View File
@@ -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",
Binary file not shown.
+6 -6
View File
@@ -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>
+10 -19
View File
@@ -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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="52dp"
android:height="60dp"
android:viewportWidth="104"
android:viewportHeight="120">
<path
android:fillColor="#F7F7F7"
android:pathData="M31,48L15,60L31,72L36,67L27,61L43,61L43,53L27,53L36,53Z" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 B

@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="52dp"
android:height="60dp"
android:viewportWidth="104"
android:viewportHeight="120">
<path
android:fillColor="#252529"
android:pathData="M31,48L15,60L31,72L36,67L27,61L43,61L43,53L27,53L36,53Z" />
</vector>
-18
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
storePassword=jinzhi123
keyPassword=jinzhi123
keyAlias=jinzhi
storeFile=files/jinzhi.jks
+6
View File
@@ -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.
+20
View File
@@ -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
+4 -5
View File
@@ -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
+27 -66
View File
@@ -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
+20 -23
View File
@@ -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}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

@@ -1,8 +0,0 @@
{
"images" : [
{ "filename" : "jinzhi_onetap_back_day.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "jinzhi_onetap_back_day@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "jinzhi_onetap_back_day@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 B

@@ -1,8 +0,0 @@
{
"images" : [
{ "filename" : "jinzhi_onetap_back_night.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "jinzhi_onetap_back_night@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "jinzhi_onetap_back_night@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 B

@@ -1,8 +0,0 @@
{
"images" : [
{ "filename" : "jinzhi_onetap_button.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "jinzhi_onetap_button@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "jinzhi_onetap_button@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

@@ -1,8 +0,0 @@
{
"images" : [
{ "filename" : "jinzhi_onetap_button_pressed.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "jinzhi_onetap_button_pressed@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "jinzhi_onetap_button_pressed@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

@@ -1 +0,0 @@
{"images":[{"filename":"jinzhi_onetap_checked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

@@ -1 +0,0 @@
{"images":[{"filename":"jinzhi_onetap_unchecked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 B

+58 -67
View File
@@ -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>
+31 -75
View File
@@ -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,
); );
} }
} }
+4 -15
View File
@@ -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();
} }
+1 -1
View File
@@ -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);
} }
+1 -1
View File
@@ -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
View File
@@ -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;
});
+8 -14
View File
@@ -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';
} }
+1 -10
View File
@@ -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));
-9
View File
@@ -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();
}
-86
View File
@@ -1,86 +0,0 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:umeng_common_sdk/umeng_common_sdk.dart';
import 'package:umeng_push_sdk/umeng_push_sdk.dart';
import '../config/umeng_config.dart';
import '../privacy/privacy_gate.dart';
import '../services/device_header_service.dart';
import '../storage/storage_keys.dart';
import '../storage/storage_service.dart';
abstract final class UmengPushService {
static final _tokens = StreamController<String>.broadcast();
static Future<void>? _starting;
static bool _started = false;
static void Function(String payload)? _onNotificationOpen;
static String? _pendingNotificationOpen;
static Stream<String> get onDeviceToken => _tokens.stream;
static void configureNotificationOpen(
void Function(String payload) callback,
) {
_onNotificationOpen = callback;
final pending = _pendingNotificationOpen;
if (pending != null) {
_pendingNotificationOpen = null;
callback(pending);
}
}
static Future<void> startIfAllowed() {
if (kIsWeb ||
_started ||
requiresAndroidPrivacyConsent(
isWeb: kIsWeb,
platform: defaultTargetPlatform,
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
)) {
return Future.value();
}
return _starting ??= _start().whenComplete(() => _starting = null);
}
static Future<void> _start() async {
await DeviceHeaderService.to.ensureInitialized();
await UmengCommonSdk.initCommon(
UmengConfig.androidAppKey,
UmengConfig.iosAppKey,
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test',
UmengConfig.androidPushSecret,
);
UmengPushSdk.setTokenCallback(_emit);
UmengPushSdk.setNotificationCallback((_) {}, (payload) {
final callback = _onNotificationOpen;
if (callback == null) {
_pendingNotificationOpen = payload;
} else {
callback(payload);
}
});
await UmengPushSdk.register(
UmengConfig.iosAppKey,
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test',
);
if (Platform.isAndroid) await UmengPushSdk.setPushEnable(true);
_started = true;
await refreshDeviceToken();
}
static Future<void> refreshDeviceToken() async {
if (!_started || kIsWeb) return;
_emit(await UmengPushSdk.getRegisteredId());
}
static void emitCachedToken() =>
_emit(StorageService.to.getString(storageDeviceToken));
static void _emit(String? token) {
if (token == null || token.isEmpty) return;
_tokens.add(token);
unawaited(StorageService.to.setString(storageDeviceToken, token));
}
}
+170 -55
View File
@@ -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),
);
},
);
}
-43
View File
@@ -1,43 +0,0 @@
import 'dart:convert';
import 'package:go_router/go_router.dart';
import 'app_router.dart';
/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。
abstract final class DeepLinkService {
static void handle(String raw) {
final target = _route(raw);
if (target != null) rootNavigatorKey.currentContext?.go(target);
}
static String? _route(String raw) {
var source = raw.trim();
try {
final json = jsonDecode(source);
if (json is Map) {
source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}';
}
} catch (_) {}
final uri = Uri.tryParse(source);
if (uri == null) return null;
if (uri.scheme == 'jinzhi') {
final path = uri.path.isEmpty ? '/assets' : uri.path;
return _allowed(path)
? '$path${uri.hasQuery ? '?${uri.query}' : ''}'
: null;
}
if ((uri.scheme == 'https' || uri.scheme == 'http') && _allowed(uri.path)) {
return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}';
}
return null;
}
static bool _allowed(String path) => const {
'/assets',
'/market',
'/news',
'/profile',
'/settings',
}.contains(path);
}
-28
View File
@@ -1,28 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class RouteErrorPage extends StatelessWidget {
const RouteErrorPage({super.key, required this.error});
final Object? error;
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.explore_off_outlined, size: 40),
const SizedBox(height: 14),
const Text('页面不存在或已失效'),
const SizedBox(height: 12),
FilledButton(
onPressed: () => context.go('/assets'),
child: const Text('回到首页'),
),
],
),
),
),
);
}
+100 -271
View File
@@ -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');
}
}
+24 -21
View File
@@ -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;
}
-6
View File
@@ -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;未写入则为空、注入时被过滤。
-4
View File
@@ -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);
} }
-82
View File
@@ -1,82 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
import 'package:flutter/foundation.dart';
import 'package:umeng_common_sdk/umeng_common_sdk.dart';
import 'package:umeng_verify_sdk/umeng_verify_sdk.dart';
import '../config/umeng_config.dart';
import '../services/device_header_service.dart';
import '../storage/storage_keys.dart';
import '../storage/storage_service.dart';
/// 合规门禁后的友盟公共初始化入口;运营商认证只可由显式登录动作触发。
abstract final class UmengService {
static Future<void>? _initializing;
static bool _ready = false;
static Future<void>? _oneKeyInitializing;
static bool _oneKeyReady = false;
static Future<void> initIfAllowed() {
if (_ready || kIsWeb) return Future.value();
if (Platform.isAndroid &&
!StorageService.to.getBool(storagePrivacyAgreed)) {
return Future.value();
}
return _initializing ??= _init().whenComplete(() => _initializing = null);
}
static Future<void> _init() async {
await DeviceHeaderService.to.ensureInitialized();
// Keep the carrier-login launch path independent of UMeng Common's
// network initialization. Waiting for this future is what left the login
// overlay spinning forever in the base project; U-Verify is configured
// immediately below by initOneKeyLoginIfAllowed, as in Yanting.
unawaited(
UmengCommonSdk.initCommon(
UmengConfig.androidAppKey,
UmengConfig.iosAppKey,
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'default',
UmengConfig.androidPushSecret,
).catchError((Object _) {}),
);
_ready = true;
}
/// 研听最新合规策略:禁止启动、隐私同意、推送注册等路径预取运营商认证。
/// 该方法仅允许在用户明确点按登录后由 [OneKeyLoginService] 调用。
static Future<void> initOneKeyLoginIfAllowed() async {
if (_oneKeyReady || kIsWeb) return;
if (Platform.isAndroid &&
!StorageService.to.getBool(storagePrivacyAgreed)) {
return;
}
await initIfAllowed();
return _oneKeyInitializing ??= _initOneKey().whenComplete(
() => _oneKeyInitializing = null,
);
}
static Future<void> _initOneKey() async {
if (Platform.isAndroid) UmengVerifySdk.register_android();
await UmengVerifySdk.setVerifySDKInfo(
UmengConfig.androidLoginSecret,
UmengConfig.iosLoginSecret,
);
_oneKeyReady = true;
}
static Future<void> requestTrackingAuthorizationIfNeeded() async {
if (kIsWeb || !Platform.isIOS) return;
try {
final status =
await AppTrackingTransparency.requestTrackingAuthorization();
if (status != TrackingStatus.authorized) return;
final idfa = await AppTrackingTransparency.getAdvertisingIdentifier();
await DeviceHeaderService.to.saveIosIdfa(idfa);
} catch (_) {
// ATT 拒绝/不可用不能阻断启动。
}
}
}
+20
View File
@@ -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;
}
+13
View File
@@ -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);
}
}
+119
View File
@@ -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;
}
}
+74
View File
@@ -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,
),
);
}
-104
View File
@@ -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,
),
),
);
}
}
-28
View File
@@ -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,
);
}
-62
View File
@@ -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();
}
}
+292
View File
@@ -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,
);
}
}
+21
View File
@@ -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());
}
}
}
-294
View File
@@ -1,294 +0,0 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../../common/network/dio_provider.dart';
const splashAdPlacement = 'splash';
const homeBannerAdPlacement = 'home_banner';
enum AdMediaType { image, gif, video, unsupported }
enum AdOpenMode { external, inApp }
class AdCreative {
const AdCreative({
required this.adId,
required this.placement,
required this.targetApp,
required this.title,
required this.imageUrl,
required this.mediaType,
required this.h5Url,
required this.trackingId,
required this.countdownSeconds,
required this.endAtMs,
required this.trackedUrl,
required this.openMode,
});
final String adId,
placement,
targetApp,
title,
imageUrl,
h5Url,
trackingId,
trackedUrl;
final AdMediaType mediaType;
final int countdownSeconds, endAtMs;
final AdOpenMode openMode;
Uri? get mediaUri => _http(imageUrl);
Uri? get targetUri => _http(trackedUrl) ?? _http(h5Url);
bool get isRenderable =>
adId.isNotEmpty &&
mediaUri != null &&
targetUri != null &&
mediaType != AdMediaType.unsupported &&
(endAtMs <= 0 || endAtMs > DateTime.now().millisecondsSinceEpoch);
Uri? clickUri(String clickId) {
final uri = targetUri;
if (uri == null) return null;
return uri.replace(
queryParameters: {
...uri.queryParameters,
'ad_id': adId,
'placement': placement,
'tracking_id': trackingId,
'click_id': clickId,
'target_app': targetApp,
'hook_type': placement,
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
},
);
}
Map<String, dynamic> toJson() => {
'adId': adId,
'placement': placement,
'targetApp': targetApp,
'title': title,
'imageUrl': imageUrl,
'mediaType': mediaType.index,
'h5Url': h5Url,
'trackingId': trackingId,
'countdownSeconds': countdownSeconds,
'endAtMs': endAtMs,
'trackedUrl': trackedUrl,
'openMode': openMode.index,
};
factory AdCreative.fromJson(Map<String, dynamic> value) => AdCreative(
adId: '${value['adId'] ?? ''}',
placement: '${value['placement'] ?? ''}',
targetApp: '${value['targetApp'] ?? ''}',
title: '${value['title'] ?? ''}',
imageUrl: '${value['imageUrl'] ?? ''}',
mediaType:
AdMediaType.values[(value['mediaType'] as num?)?.toInt().clamp(
0,
AdMediaType.values.length - 1,
) ??
AdMediaType.unsupported.index],
h5Url: '${value['h5Url'] ?? ''}',
trackingId: '${value['trackingId'] ?? ''}',
countdownSeconds: (value['countdownSeconds'] as num?)?.toInt() ?? 0,
endAtMs: (value['endAtMs'] as num?)?.toInt() ?? 0,
trackedUrl: '${value['trackedUrl'] ?? ''}',
openMode: (value['openMode'] as num?)?.toInt() == AdOpenMode.inApp.index
? AdOpenMode.inApp
: AdOpenMode.external,
);
factory AdCreative.fromProto(ProtoMessage fields) => AdCreative(
adId: fields.string(1),
placement: fields.string(2),
targetApp: fields.string(3),
title: fields.string(4),
imageUrl: fields.string(6),
mediaType: switch (fields.integer(7)) {
1 => AdMediaType.image,
2 => AdMediaType.gif,
3 => AdMediaType.video,
_ => AdMediaType.unsupported,
},
h5Url: fields.string(9),
trackingId: fields.string(10),
countdownSeconds: fields.integer(11),
endAtMs: fields.integer(13),
trackedUrl: fields.string(14),
openMode: fields.integer(15) == 2 ? AdOpenMode.inApp : AdOpenMode.external,
);
}
class GetAdResult {
const GetAdResult(this.enabled, this.ad, this.dailyCap);
final bool enabled;
final AdCreative? ad;
final int dailyCap;
}
class FeaturedAdsState {
const FeaturedAdsState(this.enabled, this.ads, {this.trackingId});
final bool enabled;
final List<AdCreative> ads;
final String? trackingId;
bool get hasAds => enabled && ads.isNotEmpty;
}
class AdsApiClient {
AdsApiClient(this._dio);
final Dio _dio;
Future<GetAdResult> getAdResult(String placement) async {
final response = await _dio.get<List<int>>(
'/api/v1/ads',
queryParameters: {'placement': placement},
options: protobufOptions(),
);
final root = ProtoMessage(response.data ?? const []);
final creative = root.message(2);
final ad = AdCreative.fromProto(creative);
return GetAdResult(
root.integer(1) != 0,
ad.isRenderable ? ad : null,
root.integer(3),
);
}
Future<FeaturedAdsState> listAds(String placement) async {
final response = await _dio.get<List<int>>(
'/api/v1/ads/list',
queryParameters: {'placement': placement},
options: protobufOptions(),
);
final root = ProtoMessage(response.data ?? const []);
final ads = root
.messages(2)
.map(AdCreative.fromProto)
.where((ad) => ad.isRenderable)
.toList(growable: false);
return FeaturedAdsState(
root.integer(1) != 0,
ads,
trackingId: root.string(3).isEmpty ? null : root.string(3),
);
}
Future<void> report(
AdCreative ad,
String event, {
String? clickId,
String? trackingId,
}) => _dio.post<List<int>>(
'/api/v1/ads/events',
data: _Writer()
.string(1, event)
.string(2, ad.placement)
.string(3, ad.adId)
.string(4, trackingId ?? ad.trackingId)
.string(5, clickId ?? '')
.integer(6, DateTime.now().millisecondsSinceEpoch)
.toBytes(),
options: protobufOptions(),
);
String newClickId() => const Uuid().v4();
}
final adsApiClientProvider = Provider(
(ref) => AdsApiClient(ref.watch(dioProvider)),
);
final featuredAdsProvider = FutureProvider.autoDispose
.family<FeaturedAdsState, String>(
(ref, placement) => ref.watch(adsApiClientProvider).listAds(placement),
);
Uri? _http(String raw) {
final uri = Uri.tryParse(raw.trim());
return uri != null &&
uri.host.isNotEmpty &&
(uri.scheme == 'https' || uri.scheme == 'http')
? uri
: null;
}
class ProtoMessage {
ProtoMessage(this.bytes);
final List<int> bytes;
Map<int, List<Object>>? _cache;
Map<int, List<Object>> get _fields => _cache ??= _read();
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
String string(int tag) {
final value = _fields[tag]?.lastOrNull;
return value is List<int> ? String.fromCharCodes(value) : '';
}
ProtoMessage message(int tag) {
final value = _fields[tag]?.lastOrNull;
return ProtoMessage(value is List<int> ? value : const []);
}
List<ProtoMessage> messages(int tag) => (_fields[tag] ?? const [])
.whereType<List<int>>()
.map(ProtoMessage.new)
.toList();
Map<int, List<Object>> _read() {
final out = <int, List<Object>>{};
var i = 0;
int v() {
var n = 0, s = 0;
while (i < bytes.length) {
final b = bytes[i++];
n |= (b & 127) << s;
if (b < 128) return n;
s += 7;
}
throw const FormatException('Invalid protobuf');
}
while (i < bytes.length) {
final key = v(), tag = key >> 3;
Object value;
if ((key & 7) == 0) {
value = v();
} else if ((key & 7) == 2) {
final l = v(), end = i + l;
if (end > bytes.length) throw const FormatException('Invalid protobuf');
value = bytes.sublist(i, end);
i = end;
} else {
throw const FormatException('Unsupported protobuf');
}
(out[tag] ??= []).add(value);
}
return out;
}
}
extension on List<Object> {
Object? get lastOrNull => isEmpty ? null : last;
}
class _Writer {
final b = BytesBuilder(copy: false);
_Writer string(int t, String value) {
final d = value.codeUnits;
v((t << 3) | 2);
v(d.length);
b.add(d);
return this;
}
_Writer integer(int t, int value) {
v(t << 3);
v(value);
return this;
}
Uint8List toBytes() => b.toBytes();
void v(int n) {
while (n > 127) {
b.addByte((n & 127) | 128);
n >>= 7;
}
b.addByte(n);
}
}
-122
View File
@@ -1,122 +0,0 @@
import 'dart:async';
import 'dart:io' show File;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../data/ads_api_client.dart';
/// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。
class AdMedia extends StatefulWidget {
const AdMedia({
super.key,
required this.ad,
this.assetPath,
this.fit = BoxFit.cover,
});
final AdCreative ad;
final String? assetPath;
final BoxFit fit;
@override
State<AdMedia> createState() => _AdMediaState();
}
class _AdMediaState extends State<AdMedia> {
VideoPlayerController? player;
bool ready = false, failed = false;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(covariant AdMedia old) {
super.didUpdateWidget(old);
if (old.ad.adId != widget.ad.adId || old.assetPath != widget.assetPath) {
unawaited(player?.dispose() ?? Future.value());
player = null;
ready = false;
failed = false;
_load();
}
}
void _load() {
if (widget.ad.mediaType != AdMediaType.video) return;
final uri = widget.ad.mediaUri;
if (uri == null) return;
final path = widget.assetPath;
final controller = !kIsWeb && path != null && path.isNotEmpty
? VideoPlayerController.file(File(path))
: VideoPlayerController.networkUrl(uri);
player = controller;
unawaited(
controller
.initialize()
.then((_) async {
await controller.setLooping(true);
await controller.setVolume(0);
await controller.play();
if (mounted) setState(() => ready = true);
})
.catchError((_) {
if (mounted) setState(() => failed = true);
}),
);
}
@override
void dispose() {
player?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.ad.mediaType != AdMediaType.video) {
final path = widget.assetPath;
if (!kIsWeb && path != null && path.isNotEmpty) {
return Image.file(
File(path),
fit: widget.fit,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, _, _) => _error(),
);
}
return Image.network(
widget.ad.imageUrl,
fit: widget.fit,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, _, _) => _error(),
);
}
final c = player;
if (failed) return _error();
if (c == null || !ready) {
return const ColoredBox(
color: Color(0xFF161616),
child: Center(child: CircularProgressIndicator()),
);
}
return FittedBox(
fit: widget.fit,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: c.value.size.width,
height: c.value.size.height,
child: VideoPlayer(c),
),
);
}
Widget _error() => const ColoredBox(
color: Color(0xFF161616),
child: Center(
child: Icon(Icons.broken_image_outlined, color: Colors.white54),
),
);
}
@@ -1,134 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../common/router/app_router.dart';
import '../data/ads_api_client.dart';
import 'ad_media.dart';
class HomeAdBanner extends ConsumerStatefulWidget {
const HomeAdBanner({super.key});
@override
ConsumerState<HomeAdBanner> createState() => _HomeAdBannerState();
}
class _HomeAdBannerState extends ConsumerState<HomeAdBanner> {
final pages = PageController();
final impressed = <String>{};
Timer? timer;
int index = 0;
int _length = 0;
@override
void dispose() {
timer?.cancel();
pages.dispose();
super.dispose();
}
void _sync(List<AdCreative> ads) {
if (_length == ads.length) return;
_length = ads.length;
timer?.cancel();
if (ads.length > 1) {
timer = Timer.periodic(const Duration(seconds: 4), (_) {
if (pages.hasClients) {
pages.nextPage(
duration: const Duration(milliseconds: 320),
curve: Curves.easeOut,
);
}
});
}
}
void _impression(AdCreative ad, String? id) {
if (!impressed.add(ad.adId)) return;
unawaited(
ref
.read(adsApiClientProvider)
.report(ad, 'ad_impression', trackingId: id),
);
}
Future<void> _open(AdCreative ad, String? tracking) async {
final client = ref.read(adsApiClientProvider);
final id = client.newClickId();
final uri = ad.clickUri(id);
if (uri == null) return;
unawaited(client.report(ad, 'ad_click', clickId: id, trackingId: tracking));
if (!mounted) return;
if (ad.openMode == AdOpenMode.inApp) {
context.push(
'${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}',
);
} else {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
final async = ref.watch(featuredAdsProvider(homeBannerAdPlacement));
return async.maybeWhen(
data: (value) {
if (!value.hasAds) return const SizedBox.shrink();
final ads = value.ads;
_sync(ads);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_impression(ads[index.clamp(0, ads.length - 1)], value.trackingId);
}
});
return Column(
children: [
AspectRatio(
aspectRatio: 750 / 200,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: PageView.builder(
controller: pages,
onPageChanged: (page) {
setState(() => index = page % ads.length);
_impression(ads[index], value.trackingId);
},
itemBuilder: (_, page) {
final ad = ads[page % ads.length];
return GestureDetector(
onTap: () => _open(ad, value.trackingId),
child: AdMedia(ad: ad),
);
},
),
),
),
if (ads.length > 1)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
ads.length,
(i) => Container(
width: i == index ? 14 : 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: i == index
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(3),
),
),
),
),
),
],
);
},
orElse: () => const SizedBox.shrink(),
);
}
}
@@ -1,82 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../application/splash_ad_controller.dart';
import 'ad_media.dart';
enum SplashAdResult { clicked, skipped, completed }
class SplashAdDialog extends StatefulWidget {
const SplashAdDialog({
super.key,
required this.entry,
required this.onClick,
required this.onShown,
});
final SplashAdEntry entry;
final Future<void> Function() onClick;
final VoidCallback onShown;
@override
State<SplashAdDialog> createState() => _SplashAdDialogState();
}
class _SplashAdDialogState extends State<SplashAdDialog> {
Timer? timer;
late int remain;
bool opening = false;
@override
void initState() {
super.initState();
remain = widget.entry.ad.countdownSeconds.clamp(1, 5);
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onShown();
});
timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (remain <= 1) {
Navigator.of(context).pop(SplashAdResult.completed);
} else if (mounted) {
setState(() => remain--);
}
});
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
Future<void> open() async {
if (opening) return;
opening = true;
timer?.cancel();
await widget.onClick();
if (mounted) Navigator.of(context).pop(SplashAdResult.clicked);
}
@override
Widget build(BuildContext context) => Material(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: open,
child: AdMedia(
ad: widget.entry.ad,
assetPath: widget.entry.assetPath,
),
),
),
Positioned(
top: MediaQuery.paddingOf(context).top + 16,
right: 18,
child: FilledButton(
onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped),
child: Text('跳过 ${remain}s'),
),
),
],
),
);
}
@@ -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,
+163
View File
@@ -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);
}
}
File diff suppressed because it is too large. Load diff
@@ -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),
),
),
),
),
],
),
),
);
}
}
File diff suppressed because it is too large. Load diff
@@ -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();
}
}
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff. Show more