commit 364fc837b36c5969b182108ea03a070d370a68e4
Author: jingyun <>
Date: Thu Jun 18 15:02:28 2026 +0800
feat:参照研听的基础工程
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1b0a90b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,47 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+# build_runner 生成物,构建前由 `make runner` 重新生成,不纳入版本库
+**/*.g.dart
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..c3664dd
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,36 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "9f455d2486bcb28cad87b062475f42edc959f636"
+ channel: "[user-branch]"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
+ base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
+ - platform: android
+ create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
+ base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
+ - platform: ios
+ create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
+ base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
+ - platform: web
+ create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
+ base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ed81351
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,8 @@
+# AGENTS.md - 金值 Flutter 客户端
+
+当前阶段:工程骨架可运行。
+
+- 业务按金值 PRD 与交互原型重新实现。
+- 首期数据使用 mock repository,不接真实报价源。
+- 复用工程基础能力:Riverpod、go_router、Dio 空壳、StorageService、主题与通用组件。
+- 不迁移旧业务页面和旧接口模型。
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..4a44c96
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,11 @@
+# 金值 Flutter 客户端
+
+金值是贵金属资产管家。当前代码库处于第一阶段,只保留可运行工程骨架和 4 个空白 Tab。
+
+开发原则:
+
+- 使用 Flutter 原生 Widget 实现 PRD 和 HTML 原型,不嵌 WebView。
+- 状态管理沿用 Riverpod。
+- 路由沿用 go_router。
+- 首期数据走本地 mock repository。
+- 不复用旧业务页面、旧业务接口模型和旧业务命名。
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4569704
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,27 @@
+.PHONY: runner run test lint apk ipa build-web
+
+WEB_API_URL ?= https://jinzhi.api.local
+WEB_BASE_HREF ?= /flutter/
+GITVERSION := $(shell git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)
+COMMON_DART_DEFINES := --dart-define=GITVERSION=$(GITVERSION)
+
+runner:
+ dart run build_runner build --delete-conflicting-outputs
+
+run:
+ flutter run $(COMMON_DART_DEFINES) --dart-define=JINZHI_API_BASE_URL=http://localhost:8088
+
+test:
+ flutter test
+
+lint:
+ flutter analyze
+
+apk:
+ flutter build apk $(COMMON_DART_DEFINES) --release --target-platform android-arm64
+
+ipa:
+ flutter build ipa $(COMMON_DART_DEFINES) --release
+
+build-web:
+ API_BASE_URL=$(WEB_API_URL) BASE_HREF=$(WEB_BASE_HREF) bash scripts/build_web.sh
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bf7d94c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# jinzhi_flutter_app
+
+金值 - 贵金属资产管家。
+
+第一阶段目标:基于既有 Flutter 工程骨架完成可运行空壳,保留主题、路由、状态管理、网络、存储和通用组件能力,业务从金值 PRD 重新实现。
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..e45d43c
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,11 @@
+include: package:flutter_lints/flutter.yaml
+analyzer:
+ exclude:
+ - "**/*.g.dart"
+ - "lib/models/pb/**"
+ - "packages/**"
+ plugins:
+ - custom_lint
+linter:
+ rules:
+ prefer_const_constructors: true
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..45e3900
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+#key.properties
+#**/*.keystore
+#**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..ce70da5
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,79 @@
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+val androidSigningPropertiesFile = rootProject.file("key.properties")
+val androidSigningProperties = Properties()
+if (!androidSigningPropertiesFile.isFile) {
+ throw GradleException("Missing Android signing properties: $androidSigningPropertiesFile")
+}
+androidSigningPropertiesFile.inputStream().use { androidSigningProperties.load(it) }
+
+fun requireSigningProperty(name: String): String {
+ val value = androidSigningProperties.getProperty(name)?.trim()
+ if (value.isNullOrBlank()) {
+ throw GradleException("Missing Android signing property: $name")
+ }
+ return value
+}
+
+android {
+ namespace = "com.jinzhi.app"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ signingConfigs {
+ create("release") {
+ keyAlias = requireSigningProperty("keyAlias")
+ keyPassword = requireSigningProperty("keyPassword")
+ storeFile = file(requireSigningProperty("storeFile"))
+ storePassword = requireSigningProperty("storePassword")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_11.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.jinzhi.app"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+
+ val umengChannel = System.getenv("UMENG_CHANNEL") ?: ""
+ manifestPlaceholders.putAll(
+ mutableMapOf(
+ "UMENG_CHANNEL" to umengChannel,
+ )
+ )
+ }
+
+ buildTypes {
+ release {
+ signingConfig = signingConfigs.getByName("release")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/files/jinzhi.jks b/android/app/files/jinzhi.jks
new file mode 100644
index 0000000..22af8aa
Binary files /dev/null and b/android/app/files/jinzhi.jks differ
diff --git a/android/app/files/yanting.jks b/android/app/files/yanting.jks
new file mode 100644
index 0000000..37bf2ae
Binary files /dev/null and b/android/app/files/yanting.jks differ
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..a60226e
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project-specific release rules here.
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c9894ec
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..d7bfa71
Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ
diff --git a/android/app/src/main/kotlin/com/jinzhi/app/MainActivity.kt b/android/app/src/main/kotlin/com/jinzhi/app/MainActivity.kt
new file mode 100644
index 0000000..d7d0fe6
--- /dev/null
+++ b/android/app/src/main/kotlin/com/jinzhi/app/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.jinzhi.app
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png
new file mode 100644
index 0000000..5093f59
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification.png b/android/app/src/main/res/drawable-hdpi/ic_notification.png
new file mode 100644
index 0000000..1534ef8
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_notification.png differ
diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png
new file mode 100644
index 0000000..5093f59
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png
new file mode 100644
index 0000000..46e07f9
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification.png b/android/app/src/main/res/drawable-mdpi/ic_notification.png
new file mode 100644
index 0000000..16618c3
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_notification.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png
new file mode 100644
index 0000000..46e07f9
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png
new file mode 100644
index 0000000..5093f59
Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-hdpi/splash.png b/android/app/src/main/res/drawable-night-hdpi/splash.png
new file mode 100644
index 0000000..5093f59
Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png
new file mode 100644
index 0000000..46e07f9
Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-mdpi/splash.png b/android/app/src/main/res/drawable-night-mdpi/splash.png
new file mode 100644
index 0000000..46e07f9
Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png
new file mode 100644
index 0000000..8e1c00e
Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ
diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png
new file mode 100644
index 0000000..456eaa1
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xhdpi/splash.png b/android/app/src/main/res/drawable-night-xhdpi/splash.png
new file mode 100644
index 0000000..456eaa1
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png
new file mode 100644
index 0000000..5b77eba
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxhdpi/splash.png
new file mode 100644
index 0000000..5b77eba
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png
new file mode 100644
index 0000000..b65478b
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png
new file mode 100644
index 0000000..b65478b
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png
new file mode 100644
index 0000000..8e1c00e
Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ
diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-night/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png
new file mode 100644
index 0000000..8e21404
Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png
new file mode 100644
index 0000000..456eaa1
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-xhdpi/ic_notification.png
new file mode 100644
index 0000000..c6a7145
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_notification.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png
new file mode 100644
index 0000000..456eaa1
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png
new file mode 100644
index 0000000..5b77eba
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png
new file mode 100644
index 0000000..b5bee59
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png
new file mode 100644
index 0000000..5b77eba
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png
new file mode 100644
index 0000000..b65478b
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
new file mode 100644
index 0000000..993ab66
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png
new file mode 100644
index 0000000..b65478b
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png
new file mode 100644
index 0000000..8e21404
Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..e7b046d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..e7b046d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a871ffa
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000..2cfa113
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..e53b692
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..7f19578
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..2a95881
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..9792efa
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000..ccf8049
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..1ac078a
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..5434a98
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..a13ce71
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..f3f2623
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..bca57d5
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..9b7fee2
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..d50fecf
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..6ea422e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..0f140c6
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..dcd5da5
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..b66d2d9
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..effb57c
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..09d5242
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4a80d8f
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..d8f3085
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..cf2d951
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..5e2e721
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..9222e27
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
new file mode 100644
index 0000000..f47fefc
--- /dev/null
+++ b/android/app/src/main/res/values-night-v31/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..3c4a1fe
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
new file mode 100644
index 0000000..78315d3
--- /dev/null
+++ b/android/app/src/main/res/values-v31/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..f8056e5
--- /dev/null
+++ b/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #95E300
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..847e1be
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..f018a61
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ac3b479
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
diff --git a/android/key.properties b/android/key.properties
new file mode 100644
index 0000000..fd3a3f1
--- /dev/null
+++ b/android/key.properties
@@ -0,0 +1,4 @@
+storePassword=jinzhi123
+keyPassword=jinzhi123
+keyAlias=jinzhi
+storeFile=files/jinzhi.jks
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..fb605bc
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.9.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+}
+
+include(":app")
diff --git a/assets/env/.env b/assets/env/.env
new file mode 100644
index 0000000..678724e
--- /dev/null
+++ b/assets/env/.env
@@ -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.
\ No newline at end of file
diff --git a/assets/icons_launcher/ic_background.png b/assets/icons_launcher/ic_background.png
new file mode 100644
index 0000000..a1de39b
Binary files /dev/null and b/assets/icons_launcher/ic_background.png differ
diff --git a/assets/icons_launcher/ic_black_white.png b/assets/icons_launcher/ic_black_white.png
new file mode 100644
index 0000000..fbbaff3
Binary files /dev/null and b/assets/icons_launcher/ic_black_white.png differ
diff --git a/assets/icons_launcher/ic_foreground.png b/assets/icons_launcher/ic_foreground.png
new file mode 100644
index 0000000..a075860
Binary files /dev/null and b/assets/icons_launcher/ic_foreground.png differ
diff --git a/assets/icons_launcher/ic_logo_radius.png b/assets/icons_launcher/ic_logo_radius.png
new file mode 100644
index 0000000..93870b7
Binary files /dev/null and b/assets/icons_launcher/ic_logo_radius.png differ
diff --git a/assets/icons_launcher/ic_logo_rectangle_dark.png b/assets/icons_launcher/ic_logo_rectangle_dark.png
new file mode 100644
index 0000000..6082c6f
Binary files /dev/null and b/assets/icons_launcher/ic_logo_rectangle_dark.png differ
diff --git a/assets/icons_launcher/ic_logo_rectangle_light.png b/assets/icons_launcher/ic_logo_rectangle_light.png
new file mode 100644
index 0000000..1b000d6
Binary files /dev/null and b/assets/icons_launcher/ic_logo_rectangle_light.png differ
diff --git a/assets/icons_launcher/ic_logo_rectangle_tinted.png b/assets/icons_launcher/ic_logo_rectangle_tinted.png
new file mode 100644
index 0000000..25a6b45
Binary files /dev/null and b/assets/icons_launcher/ic_logo_rectangle_tinted.png differ
diff --git a/assets/icons_launcher/ic_logo_round.png b/assets/icons_launcher/ic_logo_round.png
new file mode 100644
index 0000000..45e5797
Binary files /dev/null and b/assets/icons_launcher/ic_logo_round.png differ
diff --git a/assets/launch/launch-bg.mp4 b/assets/launch/launch-bg.mp4
new file mode 100644
index 0000000..6f3e29c
Binary files /dev/null and b/assets/launch/launch-bg.mp4 differ
diff --git a/assets/launch/launch-poster.jpg b/assets/launch/launch-poster.jpg
new file mode 100644
index 0000000..c8ecc4c
Binary files /dev/null and b/assets/launch/launch-poster.jpg differ
diff --git a/assets/launch/mark-grid-lime.png b/assets/launch/mark-grid-lime.png
new file mode 100644
index 0000000..9eb0918
Binary files /dev/null and b/assets/launch/mark-grid-lime.png differ
diff --git a/assets/launch/mark-star-lime.png b/assets/launch/mark-star-lime.png
new file mode 100644
index 0000000..70fba16
Binary files /dev/null and b/assets/launch/mark-star-lime.png differ
diff --git a/assets/splash/splash.png b/assets/splash/splash.png
new file mode 100644
index 0000000..ebc570c
Binary files /dev/null and b/assets/splash/splash.png differ
diff --git a/assets/splash/splash_dark.png b/assets/splash/splash_dark.png
new file mode 100644
index 0000000..ebc570c
Binary files /dev/null and b/assets/splash/splash_dark.png differ
diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml
new file mode 100644
index 0000000..b421a70
--- /dev/null
+++ b/flutter_native_splash.yaml
@@ -0,0 +1,16 @@
+# dart run flutter_native_splash:create
+# 颜色 = lime_theme 背景色;图 = 定稿 lime mark(浅深共用)
+flutter_native_splash:
+ color: "#FFFFFF"
+ image: assets/splash/splash.png
+ color_dark: "#0F0F0F"
+ image_dark: assets/splash/splash_dark.png
+ android: true
+ ios: true
+ web: false
+ fullscreen: true
+ android_12:
+ icon_background_color: "#FFFFFF"
+ icon_background_color_dark: "#0F0F0F"
+ image: assets/splash/splash.png
+ image_dark: assets/splash/splash_dark.png
diff --git a/icons_launcher.yaml b/icons_launcher.yaml
new file mode 100644
index 0000000..abe28c4
--- /dev/null
+++ b/icons_launcher.yaml
@@ -0,0 +1,17 @@
+# dart run icons_launcher:create
+# 第一阶段沿用模板图标资源占位,后续替换为金值正式图标。
+icons_launcher:
+ platforms:
+ android:
+ enable: true
+ image_path: "assets/icons_launcher/ic_logo_radius.png"
+ notification_image: "assets/icons_launcher/ic_foreground.png"
+ adaptive_background_image: "assets/icons_launcher/ic_background.png"
+ adaptive_foreground_image: "assets/icons_launcher/ic_foreground.png"
+ adaptive_round_image: "assets/icons_launcher/ic_logo_round.png"
+ adaptive_monochrome_image: "assets/icons_launcher/ic_black_white.png"
+ ios:
+ enable: true
+ image_path: "assets/icons_launcher/ic_logo_rectangle_light.png"
+ dark_path: "assets/icons_launcher/ic_logo_rectangle_dark.png"
+ tinted_path: "assets/icons_launcher/ic_logo_rectangle_tinted.png"
diff --git a/ios/.gitignore b/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..1dc6cf7
--- /dev/null
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+ MinimumOSVersion
+ 13.0
+
+
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..ec97fc6
--- /dev/null
+++ b/ios/Flutter/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..c4855bf
--- /dev/null
+++ b/ios/Flutter/Release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"
diff --git a/ios/Podfile b/ios/Podfile
new file mode 100644
index 0000000..620e46e
--- /dev/null
+++ b/ios/Podfile
@@ -0,0 +1,43 @@
+# Uncomment this line to define a global platform for your project
+# platform :ios, '13.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ end
+end
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
new file mode 100644
index 0000000..e60e3ec
--- /dev/null
+++ b/ios/Podfile.lock
@@ -0,0 +1,86 @@
+PODS:
+ - device_info_plus (0.0.1):
+ - Flutter
+ - Flutter (1.0.0)
+ - flutter_native_splash (2.4.3):
+ - Flutter
+ - flutter_paid (0.0.1):
+ - Flutter
+ - flutter_secure_storage_darwin (10.0.0):
+ - Flutter
+ - FlutterMacOS
+ - fluttertoast (0.0.2):
+ - Flutter
+ - package_info_plus (0.4.5):
+ - Flutter
+ - path_provider_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - platform_metadata (0.0.1):
+ - Flutter
+ - shared_preferences_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - url_launcher_ios (0.0.1):
+ - Flutter
+ - video_player_avfoundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+
+DEPENDENCIES:
+ - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
+ - Flutter (from `Flutter`)
+ - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
+ - flutter_paid (from `.symlinks/plugins/flutter_paid/ios`)
+ - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
+ - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
+ - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
+ - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
+ - platform_metadata (from `.symlinks/plugins/platform_metadata/ios`)
+ - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+ - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
+ - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
+
+EXTERNAL SOURCES:
+ device_info_plus:
+ :path: ".symlinks/plugins/device_info_plus/ios"
+ Flutter:
+ :path: Flutter
+ flutter_native_splash:
+ :path: ".symlinks/plugins/flutter_native_splash/ios"
+ flutter_paid:
+ :path: ".symlinks/plugins/flutter_paid/ios"
+ flutter_secure_storage_darwin:
+ :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
+ fluttertoast:
+ :path: ".symlinks/plugins/fluttertoast/ios"
+ package_info_plus:
+ :path: ".symlinks/plugins/package_info_plus/ios"
+ path_provider_foundation:
+ :path: ".symlinks/plugins/path_provider_foundation/darwin"
+ platform_metadata:
+ :path: ".symlinks/plugins/platform_metadata/ios"
+ shared_preferences_foundation:
+ :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+ url_launcher_ios:
+ :path: ".symlinks/plugins/url_launcher_ios/ios"
+ video_player_avfoundation:
+ :path: ".symlinks/plugins/video_player_avfoundation/darwin"
+
+SPEC CHECKSUMS:
+ device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
+ Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+ flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
+ flutter_paid: 6055d15f439071fefb849e3cebc279915ed70db5
+ flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
+ fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
+ package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
+ path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+ platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023
+ shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+ url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
+ video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
+
+PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+
+COCOAPODS: 1.16.2
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..99b7492
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,743 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 0B9CC9A25C666A7F563BF428 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DED7782CEF273A95BF887953 /* Pods_RunnerTests.framework */; };
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 7F35478B9E4F74858F78DFCF /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C52A653951E0B42558DD134A /* Pods_Runner.framework */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 175F3E0C34D5EC8425FD7D8D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 337040C53995A39C4FE52239 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 3BDBEE268A9AE01E4F41D2D9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
+ 738C53128F0E1E92C051CFBA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 8EB9610D6597FC7A482D1606 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ C52A653951E0B42558DD134A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ DED7782CEF273A95BF887953 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ F25346648819B28CECD62B80 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 855EADD30C26D2B7D0DEDE22 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 0B9CC9A25C666A7F563BF428 /* Pods_RunnerTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 7F35478B9E4F74858F78DFCF /* Pods_Runner.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 6F2D6E664FE70BE6967B2B7C /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ C52A653951E0B42558DD134A /* Pods_Runner.framework */,
+ DED7782CEF273A95BF887953 /* Pods_RunnerTests.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ BC3E02F563A8EBB9C52CB127 /* Pods */,
+ 6F2D6E664FE70BE6967B2B7C /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ BC3E02F563A8EBB9C52CB127 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 337040C53995A39C4FE52239 /* Pods-Runner.debug.xcconfig */,
+ 3BDBEE268A9AE01E4F41D2D9 /* Pods-Runner.release.xcconfig */,
+ F25346648819B28CECD62B80 /* Pods-Runner.profile.xcconfig */,
+ 8EB9610D6597FC7A482D1606 /* Pods-RunnerTests.debug.xcconfig */,
+ 738C53128F0E1E92C051CFBA /* Pods-RunnerTests.release.xcconfig */,
+ 175F3E0C34D5EC8425FD7D8D /* Pods-RunnerTests.profile.xcconfig */,
+ );
+ path = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 74491DF2E9EC3BEB038FC2A3 /* [CP] Check Pods Manifest.lock */,
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ 855EADD30C26D2B7D0DEDE22 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 18DB9BA14C321BAE6259714B /* [CP] Check Pods Manifest.lock */,
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ 38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 18DB9BA14C321BAE6259714B /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ 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;
+ };
+ 38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ inputPaths = (
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 74491DF2E9EC3BEB038FC2A3 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ 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;
+ };
+ 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 */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = F38D68G8T6;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 8EB9610D6597FC7A482D1606 /* Pods-RunnerTests.debug.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 738C53128F0E1E92C051CFBA /* Pods-RunnerTests.release.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 175F3E0C34D5EC8425FD7D8D /* Pods-RunnerTests.profile.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = F38D68G8T6;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = F38D68G8T6;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..e3773d4
--- /dev/null
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..21a3cc1
--- /dev/null
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..6266644
--- /dev/null
+++ b/ios/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..4b1f270
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,536 @@
+{
+ "images": [
+ {
+ "filename": "Icon-App-20x20@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "20x20",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-20x20@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "20x20",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-29x29@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "29x29",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-29x29@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "29x29",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-38x38@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "38x38",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-38x38@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "38x38",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-40x40@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "40x40",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-40x40@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "40x40",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-60x60@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "60x60",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-60x60@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "60x60",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-64x64@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "64x64",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-64x64@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "64x64",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-68x68@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "68x68",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-76x76@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "76x76",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-83.5x83.5@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "83.5x83.5",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-1024x1024@1x.png",
+ "idiom": "universal",
+ "scale": "1x",
+ "size": "1024x1024",
+ "platform": "ios"
+ },
+ {
+ "filename": "Icon-App-Dark-20x20@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "20x20",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-20x20@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "20x20",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-29x29@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "29x29",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-29x29@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "29x29",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-38x38@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "38x38",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-38x38@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "38x38",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-40x40@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "40x40",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-40x40@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "40x40",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-60x60@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "60x60",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-60x60@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "60x60",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-64x64@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "64x64",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-64x64@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "64x64",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-68x68@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "68x68",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-76x76@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "76x76",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-83.5x83.5@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "83.5x83.5",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Dark-1024x1024@1x.png",
+ "idiom": "universal",
+ "scale": "1x",
+ "size": "1024x1024",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-20x20@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "20x20",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-20x20@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "20x20",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-29x29@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "29x29",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-29x29@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "29x29",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-38x38@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "38x38",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-38x38@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "38x38",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-40x40@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "40x40",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-40x40@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "40x40",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-60x60@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "60x60",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-60x60@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "60x60",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-64x64@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "64x64",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-64x64@3x.png",
+ "idiom": "universal",
+ "scale": "3x",
+ "size": "64x64",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-68x68@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "68x68",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-76x76@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "76x76",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-83.5x83.5@2x.png",
+ "idiom": "universal",
+ "scale": "2x",
+ "size": "83.5x83.5",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ },
+ {
+ "filename": "Icon-App-Tinted-1024x1024@1x.png",
+ "idiom": "universal",
+ "scale": "1x",
+ "size": "1024x1024",
+ "platform": "ios",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ]
+ }
+ ],
+ "info": {
+ "author": "icons_launcher",
+ "version": 1
+ }
+}
\ No newline at end of file
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..571048c
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..a0c25b3
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..c33b77c
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..1886f9f
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..84a18c9
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..27f7043
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..e09ba49
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png
new file mode 100644
index 0000000..92294f5
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png
new file mode 100644
index 0000000..21cf895
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..be32638
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..592e198
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..c0a1b56
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..c0a1b56
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..bce7ce8
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png
new file mode 100644
index 0000000..7336ed1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png
new file mode 100644
index 0000000..56e37e8
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png
new file mode 100644
index 0000000..863bb52
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..fa9e418
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..cbfa9d5
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000..1de0163
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-1024x1024@1x.png
new file mode 100644
index 0000000..b5515a7
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@2x.png
new file mode 100644
index 0000000..115b01c
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@3x.png
new file mode 100644
index 0000000..a4dca00
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@2x.png
new file mode 100644
index 0000000..f1277bd
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@3x.png
new file mode 100644
index 0000000..a9577a3
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@2x.png
new file mode 100644
index 0000000..1305d60
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@3x.png
new file mode 100644
index 0000000..773dfb3
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-38x38@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@2x.png
new file mode 100644
index 0000000..f5cac04
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@3x.png
new file mode 100644
index 0000000..d91b4bd
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@2x.png
new file mode 100644
index 0000000..d91b4bd
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@3x.png
new file mode 100644
index 0000000..23e58b2
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@2x.png
new file mode 100644
index 0000000..9729981
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@3x.png
new file mode 100644
index 0000000..c146868
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-64x64@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-68x68@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-68x68@2x.png
new file mode 100644
index 0000000..221db58
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-68x68@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-76x76@2x.png
new file mode 100644
index 0000000..8b7e3ec
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-83.5x83.5@2x.png
new file mode 100644
index 0000000..a96f9a2
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Dark-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-1024x1024@1x.png
new file mode 100644
index 0000000..8d16807
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-20x20@2x.png
new file mode 100644
index 0000000..260ca6e
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-20x20@3x.png
new file mode 100644
index 0000000..628357e
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-29x29@2x.png
new file mode 100644
index 0000000..367932d
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-29x29@3x.png
new file mode 100644
index 0000000..953f92a
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-38x38@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-38x38@2x.png
new file mode 100644
index 0000000..2ba5327
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-38x38@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-38x38@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-38x38@3x.png
new file mode 100644
index 0000000..6af16e3
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-38x38@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-40x40@2x.png
new file mode 100644
index 0000000..bb1e95e
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-40x40@3x.png
new file mode 100644
index 0000000..6fdea67
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-60x60@2x.png
new file mode 100644
index 0000000..6fdea67
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-60x60@3x.png
new file mode 100644
index 0000000..d9079c0
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-64x64@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-64x64@2x.png
new file mode 100644
index 0000000..4ec32cb
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-64x64@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-64x64@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-64x64@3x.png
new file mode 100644
index 0000000..4895fd1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-64x64@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-68x68@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-68x68@2x.png
new file mode 100644
index 0000000..84ddc4f
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-68x68@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-76x76@2x.png
new file mode 100644
index 0000000..675526e
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-83.5x83.5@2x.png
new file mode 100644
index 0000000..33e8708
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-Tinted-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
new file mode 100644
index 0000000..8bb185b
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
@@ -0,0 +1,22 @@
+{
+ "images" : [
+ {
+ "filename" : "background.png",
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "darkbackground.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png
new file mode 100644
index 0000000..8e21404
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png
new file mode 100644
index 0000000..8e1c00e
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..f3387d4
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,56 @@
+{
+ "images" : [
+ {
+ "filename" : "LaunchImage.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "LaunchImageDark.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "LaunchImage@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "LaunchImageDark@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "LaunchImage@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "LaunchImageDark@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..46e07f9
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..456eaa1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..5b77eba
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png
new file mode 100644
index 0000000..46e07f9
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png
new file mode 100644
index 0000000..456eaa1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png
new file mode 100644
index 0000000..5b77eba
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..afbfd28
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
new file mode 100644
index 0000000..00af064
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,60 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ 金值
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ 金值
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsArbitraryLoadsInWebContent
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIStatusBarHidden
+
+ UIViewControllerBasedStatusBarAppearance
+
+
+
diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/lib/app/app.dart b/lib/app/app.dart
new file mode 100644
index 0000000..b0b3cdb
--- /dev/null
+++ b/lib/app/app.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+import 'package:google_fonts/google_fonts.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../common/router/app_router.dart';
+import '../common/theme/jinzhi_theme.dart';
+import '../common/theme/spacing.dart';
+import '../common/theme/theme_controller.dart';
+
+class JinzhiApp extends ConsumerWidget {
+ const JinzhiApp({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final router = ref.watch(routerProvider);
+ final mode = ref.watch(themeModeControllerProvider);
+ // DM Sans 作主字体(拉丁/数字),中文走系统级联(iOS 苹方 / Android 厂商
+ // 中文字体)——主流做法,不显式点名、不内置字体。
+ final dmSansFamily = GoogleFonts.dmSans().fontFamily;
+ final textTheme = ShadTextTheme(family: dmSansFamily);
+ return 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,
+ );
+ }
+}
diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart
new file mode 100644
index 0000000..cba7b64
--- /dev/null
+++ b/lib/app/bootstrap.dart
@@ -0,0 +1,27 @@
+import 'dart:io' show Platform;
+
+import 'package:flutter/foundation.dart' show kIsWeb;
+import 'package:flutter/widgets.dart';
+
+import '../common/services/device_header_service.dart';
+import '../common/storage/storage_service.dart';
+import 'app.dart';
+
+Future bootstrap() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await StorageService.instance;
+ if (!kIsWeb) {
+ if (Platform.isAndroid) {
+ // Before consent this only initializes app version and channel. The
+ // service resumes device identity initialization after consent.
+ await DeviceHeaderService.to.ensureInitialized();
+ } else {
+ await DeviceHeaderService.to.ensureInitialized();
+ }
+ }
+ // if (!kIsWeb) {
+ // await ShorebirdService.instance.initCurrentPatch();
+ // ShorebirdService.instance.checkForUpdateInBackground();
+ // }
+ return const JinzhiApp();
+}
diff --git a/lib/common/config/app_config.dart b/lib/common/config/app_config.dart
new file mode 100644
index 0000000..118476e
--- /dev/null
+++ b/lib/common/config/app_config.dart
@@ -0,0 +1,11 @@
+class AppConfig {
+ const AppConfig({required this.apiBaseUrl});
+ final String apiBaseUrl;
+ factory AppConfig.fromEnvironment() {
+ const url = String.fromEnvironment(
+ 'JINZHI_API_BASE_URL',
+ defaultValue: 'https://jinzhi.api.local',
+ );
+ return const AppConfig(apiBaseUrl: url);
+ }
+}
diff --git a/lib/common/config/app_h5_urls.dart b/lib/common/config/app_h5_urls.dart
new file mode 100644
index 0000000..4dbb80c
--- /dev/null
+++ b/lib/common/config/app_h5_urls.dart
@@ -0,0 +1,37 @@
+class AppH5Urls {
+ const AppH5Urls._();
+
+ static const baseUrl = String.fromEnvironment(
+ 'JINZHI_H5_BASE_URL',
+ defaultValue: 'https://jinzhi.h5.local',
+ );
+
+ static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
+ static String get userProtocolUrl => '$baseUrl/beginner/user_agreement.html';
+}
+
+class AppH5UrlsHelper {
+ const AppH5UrlsHelper._();
+
+ static String buildUrl(String url, [Map? params]) {
+ if (params == null || params.isEmpty) return url;
+ final uri = Uri.parse(url);
+ final merged = {
+ ...uri.queryParameters,
+ ...params.map((key, value) => MapEntry(key, value.toString())),
+ };
+ return uri.replace(queryParameters: merged).toString();
+ }
+
+ static String buildUrlWithNight(String url, bool isDark) {
+ if (!isDark) return url;
+ return buildUrl(url, {'isNight': '1'});
+ }
+
+ static String withCacheBuster(String url) {
+ final uri = Uri.parse(url);
+ final params = Map.from(uri.queryParameters);
+ params['_cb'] = DateTime.now().microsecondsSinceEpoch.toString();
+ return uri.replace(queryParameters: params).toString();
+ }
+}
diff --git a/lib/common/config/shorebird_service.dart b/lib/common/config/shorebird_service.dart
new file mode 100644
index 0000000..06120f2
--- /dev/null
+++ b/lib/common/config/shorebird_service.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/foundation.dart';
+import 'package:shorebird_code_push/shorebird_code_push.dart';
+
+class ShorebirdService {
+ ShorebirdService._();
+
+ static final ShorebirdService instance = ShorebirdService._();
+ final ShorebirdUpdater _updater = ShorebirdUpdater();
+ Patch? currentPatch;
+
+ bool get isAvailable => _updater.isAvailable;
+
+ Future readCurrentPatch() => _updater.readCurrentPatch();
+
+ Future initCurrentPatch() async {
+ try {
+ currentPatch = await _updater.readCurrentPatch();
+ } catch (error) {
+ debugPrint('Error reading current patch: $error');
+ }
+ }
+
+ Future checkForUpdate({
+ UpdateTrack track = UpdateTrack.stable,
+ }) {
+ return _updater.checkForUpdate(track: track);
+ }
+
+ Future downloadUpdate({UpdateTrack track = UpdateTrack.stable}) {
+ return _updater.update(track: track).then((_) async {
+ await initCurrentPatch();
+ });
+ }
+
+ Future checkForUpdateInBackground({
+ UpdateTrack track = UpdateTrack.stable,
+ }) async {
+ if (!isAvailable) return;
+ try {
+ await _updater.checkForUpdate(track: track);
+ } catch (error) {
+ debugPrint('Shorebird background check failed: $error');
+ }
+ }
+}
diff --git a/lib/common/config/umeng_config.dart b/lib/common/config/umeng_config.dart
new file mode 100644
index 0000000..35b1618
--- /dev/null
+++ b/lib/common/config/umeng_config.dart
@@ -0,0 +1,25 @@
+// lib/common/config/umeng_config.dart
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+
+class UmengConfig {
+ // Keys loaded from .env file
+ static String _androidAppKey =
+ '6a27b1f46f259537c7b616be'; // Default placeholder
+ static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
+ static String _androidPushSecret = 'xxx'; // Default placeholder
+
+ static Future load() async {
+ await dotenv.load(
+ fileName: "assets/env/.env",
+ ); // Load from packaged asset path
+
+ _androidAppKey = dotenv.env['UMENG_ANDROID_APP_KEY'] ?? _androidAppKey;
+ _iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey;
+ _androidPushSecret =
+ dotenv.env['UMENG_ANDROID_PUSH_SECRET'] ?? _androidPushSecret;
+ }
+
+ static String get androidAppKey => _androidAppKey;
+ static String get iosAppKey => _iosAppKey;
+ static String get androidPushSecret => _androidPushSecret;
+}
diff --git a/lib/common/domain/metal_type.dart b/lib/common/domain/metal_type.dart
new file mode 100644
index 0000000..1b76625
--- /dev/null
+++ b/lib/common/domain/metal_type.dart
@@ -0,0 +1,14 @@
+import 'package:flutter/material.dart';
+
+enum MetalType {
+ gold('gold', '黄金', '金', Color(0xFFB5862A)),
+ platinum('platinum', '铂金', '铂', Color(0xFF7D8792)),
+ silver('silver', '白银', '银', Color(0xFF9DA3AA));
+
+ const MetalType(this.id, this.label, this.shortLabel, this.color);
+
+ final String id;
+ final String label;
+ final String shortLabel;
+ final Color color;
+}
diff --git a/lib/common/domain/money.dart b/lib/common/domain/money.dart
new file mode 100644
index 0000000..27e6768
--- /dev/null
+++ b/lib/common/domain/money.dart
@@ -0,0 +1,11 @@
+String formatCny(num value) {
+ final fixed = value.toStringAsFixed(2);
+ final parts = fixed.split('.');
+ final whole = parts.first.replaceAllMapped(
+ RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
+ (match) => '${match[1]},',
+ );
+ return '¥$whole.${parts.last}';
+}
+
+String formatGram(num value) => '${value.toStringAsFixed(2)}g';
diff --git a/lib/common/network/api_error_message.dart b/lib/common/network/api_error_message.dart
new file mode 100644
index 0000000..65d342d
--- /dev/null
+++ b/lib/common/network/api_error_message.dart
@@ -0,0 +1,20 @@
+import 'package:dio/dio.dart';
+
+String? kratosReason(Object e) {
+ if (e is! DioException) return null;
+ final body = e.response?.data;
+ if (body is Map && body['reason'] is String) return body['reason'] as String;
+ return null;
+}
+
+String kratosDisplayMessage(Object e, {required String fallback}) {
+ if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
+ if (e is DioException) {
+ final body = e.response?.data;
+ if (body is Map && body['message'] is String) {
+ final m = body['message'] as String;
+ if (m.isNotEmpty) return m;
+ }
+ }
+ return fallback;
+}
diff --git a/lib/common/network/auth_interceptor.dart b/lib/common/network/auth_interceptor.dart
new file mode 100644
index 0000000..d3793c4
--- /dev/null
+++ b/lib/common/network/auth_interceptor.dart
@@ -0,0 +1,28 @@
+import 'package:dio/dio.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import '../services/device_header_service.dart';
+
+const _kJwtKey = 'jwt_token';
+const _storage = FlutterSecureStorage();
+
+Future saveJwt(String token) =>
+ _storage.write(key: _kJwtKey, value: token);
+Future clearJwt() => _storage.delete(key: _kJwtKey);
+Future loadJwt() => _storage.read(key: _kJwtKey);
+
+class AuthInterceptor extends Interceptor {
+ @override
+ Future onRequest(
+ RequestOptions options,
+ RequestInterceptorHandler handler,
+ ) async {
+ await DeviceHeaderService.to.ensureInitialized();
+ options.headers.addAll(DeviceHeaderService.to.commonHeaders());
+ options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
+ final jwt = await loadJwt();
+ if (jwt != null && jwt.isNotEmpty) {
+ options.headers['Authorization'] = 'Bearer $jwt';
+ }
+ handler.next(options);
+ }
+}
diff --git a/lib/common/network/dio_provider.dart b/lib/common/network/dio_provider.dart
new file mode 100644
index 0000000..295b525
--- /dev/null
+++ b/lib/common/network/dio_provider.dart
@@ -0,0 +1,41 @@
+import 'package:dio/dio.dart';
+import 'package:fancy_dio_inspector/fancy_dio_inspector.dart';
+import 'package:flutter/foundation.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+import '../config/app_config.dart';
+import 'auth_interceptor.dart';
+import 'kratos_error_interceptor.dart';
+
+part 'dio_provider.g.dart';
+
+const _protobufContentType = 'application/x-protobuf';
+
+@Riverpod(keepAlive: true)
+AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
+
+@Riverpod(keepAlive: true)
+Dio dio(Ref ref) {
+ final config = ref.watch(appConfigProvider);
+ final d = Dio(
+ BaseOptions(
+ baseUrl: config.apiBaseUrl,
+ connectTimeout: const Duration(seconds: 15),
+ receiveTimeout: const Duration(seconds: 30),
+ responseType: ResponseType.bytes,
+ contentType: _protobufContentType,
+ headers: const {
+ 'Accept': _protobufContentType,
+ 'Content-Type': _protobufContentType,
+ },
+ ),
+ );
+ d.interceptors.add(AuthInterceptor());
+ d.interceptors.add(KratosErrorInterceptor());
+ if (!kReleaseMode) {
+ d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
+ }
+ d.interceptors.add(FancyDioInterceptor());
+ return d;
+}
diff --git a/lib/common/network/kratos_error_interceptor.dart b/lib/common/network/kratos_error_interceptor.dart
new file mode 100644
index 0000000..6be0acd
--- /dev/null
+++ b/lib/common/network/kratos_error_interceptor.dart
@@ -0,0 +1,8 @@
+import 'package:dio/dio.dart';
+
+class KratosErrorInterceptor extends Interceptor {
+ @override
+ void onError(DioException err, ErrorInterceptorHandler handler) {
+ handler.next(err);
+ }
+}
diff --git a/lib/common/router/app_router.dart b/lib/common/router/app_router.dart
new file mode 100644
index 0000000..d9f927a
--- /dev/null
+++ b/lib/common/router/app_router.dart
@@ -0,0 +1,115 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../features/assets/presentation/assets_page.dart';
+import '../../features/auth/presentation/delete_account_page.dart';
+import '../../features/auth/presentation/login_page.dart';
+import '../../features/debug/presentation/debug_info_page.dart';
+import '../../features/debug/presentation/debug_page.dart';
+import '../../features/debug/presentation/debug_shorebird_page.dart';
+import '../../features/debug/presentation/fancy_dio_inspector_page.dart';
+import '../../features/debug/presentation/typography_test_page.dart';
+import '../../features/market/presentation/market_page.dart';
+import '../../features/news/presentation/news_page.dart';
+import '../../features/profile/presentation/profile_page.dart';
+import '../../features/settings/presentation/settings_page.dart';
+import '../../features/settings/presentation/privacy_consent_page.dart';
+import '../services/device_header_service.dart';
+import 'tab_shell.dart';
+
+abstract final class AppRoutes {
+ static const assets = '/assets';
+ static const market = '/market';
+ static const news = '/news';
+ static const profile = '/profile';
+ static const login = '/login';
+ static const settings = '/settings';
+ static const deleteAccount = '/delete-account';
+ static const deleteAccountCompleted = '/delete-account-completed';
+ static const debug = '/debug';
+ static const debugInfo = '/debug/info';
+ static const debugShorebird = '/debug/shorebird';
+ static const debugTypography = '/debug/typography';
+ static const fancyDioInspector = '/debug/fancy_dio_inspector';
+ static const privacyConsent = '/privacy-consent';
+}
+
+final routerProvider = Provider((ref) {
+ final startupListenable = ValueNotifier(0);
+ return GoRouter(
+ initialLocation: AppRoutes.assets,
+ refreshListenable: startupListenable,
+ redirect: (context, state) {
+ final loc = state.matchedLocation;
+ final onConsent = loc == AppRoutes.privacyConsent;
+ final showStartup =
+ DeviceHeaderService.to.privacyConsentRequired ||
+ DeviceHeaderService.to.iosStartupRequired;
+ if (showStartup) {
+ if (!onConsent) return AppRoutes.privacyConsent;
+ } else if (onConsent) {
+ return AppRoutes.assets;
+ }
+ return null;
+ },
+ routes: [
+ ShellRoute(
+ builder: (context, state, child) =>
+ TabShell(location: state.matchedLocation, child: child),
+ routes: [
+ GoRoute(
+ path: AppRoutes.assets,
+ builder: (ctx, s) => const AssetsPage(),
+ ),
+ GoRoute(
+ path: AppRoutes.market,
+ builder: (ctx, s) => const MarketPage(),
+ ),
+ GoRoute(path: AppRoutes.news, builder: (ctx, s) => const NewsPage()),
+ GoRoute(
+ path: AppRoutes.profile,
+ builder: (ctx, s) => const ProfilePage(),
+ ),
+ ],
+ ),
+ GoRoute(path: AppRoutes.debug, builder: (_, s) => const DebugPage()),
+ GoRoute(
+ path: AppRoutes.login,
+ builder: (_, s) => LoginPage(next: s.uri.queryParameters['next']),
+ ),
+ GoRoute(
+ path: AppRoutes.settings,
+ builder: (_, s) => const SettingsPage(),
+ ),
+ GoRoute(
+ path: AppRoutes.deleteAccount,
+ builder: (_, s) => const DeleteAccountPage(),
+ ),
+ GoRoute(
+ path: AppRoutes.deleteAccountCompleted,
+ builder: (_, s) => const DeleteAccountSuccessPage(),
+ ),
+ GoRoute(
+ path: AppRoutes.debugInfo,
+ builder: (_, s) => const DebugInfoPage(),
+ ),
+ GoRoute(
+ path: AppRoutes.debugShorebird,
+ builder: (_, s) => const DebugShorebirdPage(),
+ ),
+ GoRoute(
+ path: AppRoutes.debugTypography,
+ builder: (_, s) => const TypographyTestPage(),
+ ),
+ GoRoute(
+ path: AppRoutes.fancyDioInspector,
+ builder: (_, s) => const FancyDioInspectorPage(),
+ ),
+ GoRoute(
+ path: AppRoutes.privacyConsent,
+ builder: (_, s) => const PrivacyConsentPage(),
+ ),
+ ],
+ );
+});
diff --git a/lib/common/router/tab_origin.dart b/lib/common/router/tab_origin.dart
new file mode 100644
index 0000000..7086d7d
--- /dev/null
+++ b/lib/common/router/tab_origin.dart
@@ -0,0 +1,7 @@
+String tabFallbackForPath(String path) {
+ if (path.startsWith('/assets')) return '/assets';
+ if (path.startsWith('/market')) return '/market';
+ if (path.startsWith('/news')) return '/news';
+ if (path.startsWith('/profile')) return '/profile';
+ return '/assets';
+}
diff --git a/lib/common/router/tab_shell.dart b/lib/common/router/tab_shell.dart
new file mode 100644
index 0000000..8eb1f5c
--- /dev/null
+++ b/lib/common/router/tab_shell.dart
@@ -0,0 +1,255 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../theme/app_icons.dart';
+import '../widgets/adaptive.dart';
+import 'app_router.dart';
+
+class TabShell extends StatefulWidget {
+ const TabShell({super.key, required this.child, required this.location});
+
+ final Widget child;
+ final String location;
+
+ @override
+ State createState() => _TabShellState();
+}
+
+class _TabShellState extends State {
+ static const double _compactHeaderThreshold = 34;
+
+ bool _showCompactHeader = false;
+
+ static const _tabs = ['/assets', '/market', '/news', '/profile'];
+
+ int get _index =>
+ _tabs.indexWhere((t) => widget.location.startsWith(t)).clamp(0, 3);
+
+ @override
+ void didUpdateWidget(covariant TabShell oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.location != widget.location && _showCompactHeader) {
+ _showCompactHeader = false;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final header = _headerForPath(widget.location);
+ final canPop = GoRouter.of(context).canPop();
+
+ return Scaffold(
+ backgroundColor: cs.background,
+ resizeToAvoidBottomInset: false,
+ body: Stack(
+ children: [
+ Scaffold(
+ backgroundColor: cs.background,
+ resizeToAvoidBottomInset: false,
+ appBar: AppBar(
+ backgroundColor: cs.background,
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ centerTitle: true,
+ toolbarHeight: 32,
+ leading: canPop
+ ? IconButton(
+ onPressed: () => context.pop(),
+ icon: const Icon(Icons.chevron_left, size: 20),
+ )
+ : null,
+ title: AnimatedOpacity(
+ opacity: _showCompactHeader ? 1 : 0,
+ duration: const Duration(milliseconds: 160),
+ curve: Curves.easeOut,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ header.title,
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ height: 1.1,
+ letterSpacing: 0,
+ ),
+ ),
+ if (header.subtitle != null) ...[
+ const SizedBox(height: 2),
+ Text(
+ header.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(
+ onNotification: _handleScrollNotification,
+ child: Stack(children: [Positioned.fill(child: widget.child)]),
+ ),
+ ),
+ bottomNavigationBar: SafeArea(
+ top: false,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ DecoratedBox(
+ decoration: BoxDecoration(
+ color: cs.background,
+ border: Border(
+ top: BorderSide(color: cs.border, width: 1),
+ ),
+ ),
+ child: SizedBox(
+ height: 62,
+ // 平板:tab 行限宽 600 居中(demo .nav .tabs)
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: Adaptive.tabsMaxWidth,
+ ),
+ child: Row(
+ children: [
+ for (var i = 0; i < _tabs.length; i++)
+ Expanded(
+ child: _TabButton(
+ icon: i == _index
+ ? _filledIcons[i]
+ : _outlineIcons[i],
+ label: _labels[i],
+ selected: i == _index,
+ onTap: () => context.go(_tabs[i]),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ 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('请求'),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ bool _handleScrollNotification(ScrollNotification notification) {
+ if (notification.metrics.axis != Axis.vertical) {
+ return false;
+ }
+ final next = notification.metrics.pixels > _compactHeaderThreshold;
+ if (next != _showCompactHeader && mounted) {
+ setState(() => _showCompactHeader = next);
+ }
+ return false;
+ }
+}
+
+class _TabButton extends StatelessWidget {
+ const _TabButton({
+ required this.icon,
+ required this.label,
+ required this.selected,
+ required this.onTap,
+ });
+
+ final IconData icon;
+ final String label;
+ final bool selected;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final color = selected ? cs.foreground : cs.mutedForeground;
+ return InkWell(
+ onTap: onTap,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(icon, size: 22, color: color),
+ const SizedBox(height: 4),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
+ color: color,
+ letterSpacing: 0,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+const _filledIcons = [
+ AppIcons.assetsFill,
+ AppIcons.marketFill,
+ AppIcons.newsFill,
+ AppIcons.meFill,
+];
+
+const _outlineIcons = [
+ AppIcons.assetsLine,
+ AppIcons.marketLine,
+ AppIcons.newsLine,
+ AppIcons.meLine,
+];
+
+const _labels = ['资产', '行情', '资讯', '我的'];
+
+_ShellHeader _headerForPath(String path) {
+ return switch (path) {
+ '/market' => const _ShellHeader('行情', ''),
+ '/news' => const _ShellHeader('资讯', ''),
+ '/profile' => const _ShellHeader('我的'),
+ _ => const _ShellHeader('金值', ''),
+ };
+}
+
+class _ShellHeader {
+ const _ShellHeader(this.title, [this.subtitle]);
+
+ final String title;
+ final String? subtitle;
+}
diff --git a/lib/common/services/device_header_service.dart b/lib/common/services/device_header_service.dart
new file mode 100644
index 0000000..65002e9
--- /dev/null
+++ b/lib/common/services/device_header_service.dart
@@ -0,0 +1,274 @@
+import 'dart:io' show Platform;
+
+import 'package:android_id/android_id.dart';
+import 'package:android_oaid/android_oaid.dart';
+import 'package:device_info_plus/device_info_plus.dart';
+import 'package:flutter/foundation.dart' show kIsWeb;
+import 'package:flutter_paid/flutter_paid.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:platform_metadata/platform_metadata.dart';
+import 'package:uuid/uuid.dart';
+
+import '../storage/storage_keys.dart';
+import '../storage/storage_service.dart';
+
+/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
+/// AuthInterceptor 每个请求注入 commonHeaders() + platformIdentityHeaders(),
+/// token 另由拦截器注入。空值 header 一律过滤。
+///
+/// 注:`User-Agent` 这里不表示浏览器 UA,而是沿用后端请求头契约中的平台标识,
+/// 值同:iOS/Android/Web/Other。
+///
+/// 广告/设备标识(OAID/IMEI/MAC/IDFA/Paid/DeviceToken)由各自 SDK/ATT 流程
+/// 写入 StorageService,这里读出注入;Android 标识仅在用户同意隐私后采集。
+class DeviceHeaderService {
+ DeviceHeaderService._();
+ static final DeviceHeaderService to = DeviceHeaderService._();
+ static const _storage = FlutterSecureStorage();
+ static const _kDeviceIdKey = 'device_id';
+ // static const _platformMetadataChannel = MethodChannel('platform_metadata');
+
+ // 渠道:优先编译期 --dart-define=UMENG_CHANNEL=xxx;Android 其次读 manifest,
+ static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL');
+
+ String _deviceId = '';
+ String _version = '';
+ String _build = '';
+ String _deviceModel = '';
+ String _userChannel = '';
+ String _iosIdfv = '';
+ String _iosPaid = '';
+ String _androidId = '';
+ String _androidOaid = '';
+ String _androidImei = '';
+ String _androidMac = '';
+ Future? _baseInitialization;
+ Future? _identityInitialization;
+
+ Future ensureInitialized() async {
+ _baseInitialization ??= _initializeBase();
+ await _baseInitialization;
+
+ // Align with tradingagents-flutter-app: Android only initializes device
+ // information after the user has accepted the privacy policy.
+ if (!kIsWeb &&
+ Platform.isAndroid &&
+ !StorageService.to.getBool(storagePrivacyAgreed)) {
+ return;
+ }
+
+ _identityInitialization ??= _initializeIdentity();
+ await _identityInitialization;
+ }
+
+ Future _initializeBase() async {
+ try {
+ final info = await PackageInfo.fromPlatform();
+ _version = info.version;
+ _build = info.buildNumber;
+ } catch (_) {}
+ _userChannel = await _resolveUserChannel();
+ }
+
+ Future _initializeIdentity() async {
+ _deviceId = await _resolveDeviceId();
+ _androidOaid = StorageService.to.getString(storageAndroidOaid) ?? '';
+ _androidImei = StorageService.to.getString(storageAndroidImei) ?? '';
+ _androidMac = StorageService.to.getString(storageAndroidMac) ?? '';
+ _androidId = StorageService.to.getString(storageAndroidId) ?? '';
+ _iosPaid = StorageService.to.getString(storageIosPaid) ?? '';
+ await _resolvePlatformIdentity();
+ }
+
+ /// X-Device-Id:iOS 优先 IDFV(per-vendor 稳定),否则 UUIDv4;
+ /// 首次生成后写入 secure_storage,后续复用。
+ Future _resolveDeviceId() async {
+ final stored = await _storage.read(key: _kDeviceIdKey);
+ if (stored != null && stored.isNotEmpty) return stored;
+ var id = '';
+ if (!kIsWeb && Platform.isIOS) {
+ id = await _resolveIdfv();
+ }
+ if (id.isEmpty) id = const Uuid().v4();
+ await _storage.write(key: _kDeviceIdKey, value: id);
+ return id;
+ }
+
+ Future _resolveIdfv() async {
+ try {
+ final idfv = (await DeviceInfoPlugin().iosInfo).identifierForVendor;
+ return idfv ?? '';
+ } catch (_) {
+ return '';
+ }
+ }
+
+ /// 设备型号 + 平台身份标识。Android 标识需用户同意隐私后才采集。
+ Future _resolvePlatformIdentity() async {
+ if (kIsWeb) return;
+ try {
+ final plugin = DeviceInfoPlugin();
+ if (Platform.isAndroid) {
+ _deviceModel = (await plugin.androidInfo).model;
+ await _collectAndroidIdIfAgreed();
+ await _collectAndroidOaidIfAgreed();
+ await _collectAndroidImeiIfAgreed();
+ await _collectAndroidMacIfAgreed();
+ } else if (Platform.isIOS) {
+ _deviceModel = (await plugin.iosInfo).utsname.machine;
+ _iosIdfv = await _resolveIdfv();
+ await _collectIosPaidIfMissing();
+ }
+ } catch (_) {}
+ }
+
+ Future _collectAndroidIdIfAgreed() async {
+ if (_androidId.isNotEmpty) return;
+ if (!StorageService.to.getBool(storagePrivacyAgreed)) return; // 隐私同意后才采集
+ try {
+ _androidId = await const AndroidId().getId() ?? '';
+ if (_androidId.isNotEmpty) {
+ await StorageService.to.setString(storageAndroidId, _androidId);
+ }
+ } catch (_) {}
+ }
+
+ Future _collectAndroidOaidIfAgreed() async {
+ if (_androidOaid.isNotEmpty) return;
+ if (!StorageService.to.getBool(storagePrivacyAgreed)) return;
+ try {
+ _androidOaid = await const AndroidOaidId().getOaid() ?? '';
+ if (_androidOaid.isNotEmpty) {
+ await StorageService.to.setString(storageAndroidOaid, _androidOaid);
+ }
+ } catch (_) {}
+ }
+
+ Future _collectAndroidImeiIfAgreed() async {
+ if (_androidImei.isNotEmpty) return;
+ if (!StorageService.to.getBool(storagePrivacyAgreed)) return;
+ try {
+ _androidImei = await const AndroidId().getImei() ?? '';
+ if (_androidImei.isNotEmpty) {
+ await StorageService.to.setString(storageAndroidImei, _androidImei);
+ }
+ } catch (_) {}
+ }
+
+ Future _collectAndroidMacIfAgreed() async {
+ if (_androidMac.isNotEmpty) return;
+ if (!StorageService.to.getBool(storagePrivacyAgreed)) return;
+ try {
+ _androidMac = await const AndroidId().getMaAddress() ?? '';
+ if (_androidMac.isNotEmpty) {
+ await StorageService.to.setString(storageAndroidMac, _androidMac);
+ }
+ } catch (_) {}
+ }
+
+ Future _collectIosPaidIfMissing() async {
+ if (_iosPaid.isNotEmpty) return;
+ try {
+ _iosPaid = await FlutterPaid().paid() ?? '';
+ if (_iosPaid.isNotEmpty) {
+ await StorageService.to.setString(storageIosPaid, _iosPaid);
+ }
+ } catch (_) {}
+ }
+
+ /// 用户同意隐私协议后调用。设备信息由随后调用的 ensureInitialized 补采。
+ Future markPrivacyAgreed() async {
+ await StorageService.to.setBool(storagePrivacyAgreed, true);
+ }
+
+ /// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备
+ /// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
+ bool get privacyConsentRequired =>
+ !kIsWeb &&
+ Platform.isAndroid &&
+ !StorageService.to.getBool(storagePrivacyAgreed);
+
+ /// iOS 首次启动页:仅在首次安装后展示一次。
+ bool get iosStartupRequired =>
+ !kIsWeb &&
+ Platform.isIOS &&
+ !StorageService.to.getBool(storageIosStartupSeen);
+
+ Future markIosStartupSeen() async {
+ if (kIsWeb || !Platform.isIOS) return;
+ await StorageService.to.setBool(storageIosStartupSeen, true);
+ }
+
+ String get deviceId => _deviceId;
+
+ // User-Platform:平台标识(替代研值的 User-Agent)。
+ String _platform() {
+ if (kIsWeb) return 'Web';
+ if (Platform.isIOS) return 'iOS';
+ if (Platform.isAndroid) return 'Android';
+ return 'Other';
+ }
+
+ Future _resolveUserChannel() async {
+ if (_umengChannel.isNotEmpty) return _umengChannel;
+
+ if (!kIsWeb && Platform.isAndroid) {
+ try {
+ final manifestChannel = await PlatformMetadata.getMetaDataValue(
+ 'UMENG_CHANNEL',
+ );
+ if (manifestChannel != null && manifestChannel.isNotEmpty) {
+ return manifestChannel;
+ }
+ } catch (_) {}
+ }
+ if (kIsWeb) return 'web';
+ if (Platform.isIOS) return 'ios';
+ if (Platform.isAndroid) return 'android';
+ return 'other';
+ }
+
+ String _s(String key) => StorageService.to.getString(key) ?? '';
+
+ /// 公共 header(不含 token)。11
+ Map commonHeaders() => _compact({
+ 'X-Device-Id': _deviceId,
+ 'User-Version': _version,
+ 'User-BuildID': _build,
+ 'User-Platform': _platform(),
+ 'User-Channel': _userChannel,
+ 'User-DeviceType': _deviceModel,
+ 'User-DeviceToken': privacyConsentRequired ? '' : _s(storageDeviceToken),
+ });
+
+ /// 平台身份 / 广告标识 header(按平台)。
+ Map platformIdentityHeaders() {
+ if (kIsWeb) return const {};
+ if (Platform.isAndroid) {
+ if (!StorageService.to.getBool(storagePrivacyAgreed)) return const {};
+ return _compact({
+ 'User-Android-ID': _androidId,
+ 'User-Android-OAID': _androidOaid,
+ 'User-Android-IMEI': _androidImei,
+ 'User-Android-Mac': _androidMac,
+ });
+ }
+ if (Platform.isIOS) {
+ return _compact({
+ 'User-iOS-IDFV': _iosIdfv,
+ 'User-iOS-IDFA': _s(storageIosIdfa),
+ 'User-iOS-Paid': _iosPaid,
+ });
+ }
+ return const {};
+ }
+}
+
+Map _compact(Map headers) {
+ final out = {};
+ headers.forEach((k, v) {
+ if (v.trim().isNotEmpty) out[k] = v;
+ });
+ return out;
+}
diff --git a/lib/common/storage/storage_keys.dart b/lib/common/storage/storage_keys.dart
new file mode 100644
index 0000000..4f48447
--- /dev/null
+++ b/lib/common/storage/storage_keys.dart
@@ -0,0 +1,19 @@
+const storageThemeMode = 'theme_mode';
+const storagePrivacyAgreed = 'privacy_agreed'; // Android 首启隐私同意(设备采集门控)
+const storageIosStartupSeen = 'ios_startup_seen'; // iOS 首次启动页已展示
+const storageJinzhiHoldings = 'jinzhi_holdings';
+const storageJinzhiAmountHidden = 'jinzhi_amount_hidden';
+const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
+const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
+const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
+const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
+
+// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
+// header;未写入则为空、注入时被过滤。
+const storageDeviceToken = 'device_token'; // 推送 token
+const storageAndroidId = 'android_id';
+const storageAndroidOaid = 'android_oaid';
+const storageAndroidImei = 'android_imei';
+const storageAndroidMac = 'android_mac';
+const storageIosIdfa = 'ios_idfa';
+const storageIosPaid = 'ios_paid';
diff --git a/lib/common/storage/storage_service.dart b/lib/common/storage/storage_service.dart
new file mode 100644
index 0000000..c7390e5
--- /dev/null
+++ b/lib/common/storage/storage_service.dart
@@ -0,0 +1,20 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+class StorageService {
+ StorageService._(this._prefs);
+ final SharedPreferences _prefs;
+ static StorageService? _instance;
+ static StorageService get to => _instance!;
+
+ static Future get instance async {
+ if (_instance != null) return _instance!;
+ final prefs = await SharedPreferences.getInstance();
+ _instance = StorageService._(prefs);
+ return _instance!;
+ }
+
+ bool getBool(String key, {bool def = false}) => _prefs.getBool(key) ?? def;
+ Future setBool(String key, bool v) => _prefs.setBool(key, v);
+ String? getString(String key) => _prefs.getString(key);
+ Future setString(String key, String v) => _prefs.setString(key, v);
+}
diff --git a/lib/common/theme/app_icons.dart b/lib/common/theme/app_icons.dart
new file mode 100644
index 0000000..8e604e1
--- /dev/null
+++ b/lib/common/theme/app_icons.dart
@@ -0,0 +1,15 @@
+import 'package:flutter_remix/flutter_remix.dart';
+
+// flutter_remix 0.0.3: icon class is FlutterRemix
+class AppIcons {
+ static const assetsFill = FlutterRemix.wallet_3_fill;
+ static const assetsLine = FlutterRemix.wallet_3_line;
+ static const marketFill = FlutterRemix.line_chart_fill;
+ static const marketLine = FlutterRemix.line_chart_line;
+ static const newsFill = FlutterRemix.article_fill;
+ static const newsLine = FlutterRemix.article_line;
+ static const meFill = FlutterRemix.user_3_fill;
+ static const meLine = FlutterRemix.user_3_line;
+ static const arrowRight = FlutterRemix.arrow_right_s_line;
+ static const arrowLeft = FlutterRemix.arrow_left_s_line;
+}
diff --git a/lib/common/theme/app_text.dart b/lib/common/theme/app_text.dart
new file mode 100644
index 0000000..01bc406
--- /dev/null
+++ b/lib/common/theme/app_text.dart
@@ -0,0 +1,57 @@
+import 'package:flutter/widgets.dart';
+
+/// 字号刻度常量。
+/// 颜色在用处通过 ShadTheme.of(context).colorScheme 取,此处只定义尺寸/粗细/行高。
+abstract class AppText {
+ /// App 大标题: 34px / w800 / lh 1.15
+ static const TextStyle appTitle = TextStyle(
+ fontSize: 34,
+ fontWeight: FontWeight.w800,
+ height: 1.15,
+ );
+
+ /// 区块标题: 22px / w700
+ static const TextStyle sectionTitle = TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ );
+
+ /// 卡片标题: 19px / w620 / lh 1.4
+ static const TextStyle cardTitle = TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w600, // 最接近 w620
+ height: 1.4,
+ );
+
+ /// 列表标题: 16.5px / w600
+ static const TextStyle listTitle = TextStyle(
+ fontSize: 16.5,
+ fontWeight: FontWeight.w600,
+ );
+
+ /// 正文: 15px / w400 / lh 1.6
+ static const TextStyle body = TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w400,
+ height: 1.6,
+ );
+
+ /// meta 信息: 13px / w400
+ static const TextStyle meta = TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w400,
+ );
+
+ /// chip 筛选: 15px / w500
+ static const TextStyle chip = TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w500,
+ );
+
+ /// badge 标签: 12px / w500
+ static const TextStyle badge = TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w500,
+ height: 1.5,
+ );
+}
diff --git a/lib/common/theme/jinzhi_theme.dart b/lib/common/theme/jinzhi_theme.dart
new file mode 100644
index 0000000..ef6f592
--- /dev/null
+++ b/lib/common/theme/jinzhi_theme.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/widgets.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+const jinzhiLightScheme = ShadColorScheme(
+ background: Color(0xFFFBFAF7),
+ foreground: Color(0xFF1A1A17),
+ card: Color(0xFFFFFFFF),
+ cardForeground: Color(0xFF1A1A17),
+ popover: Color(0xFFFFFFFF),
+ popoverForeground: Color(0xFF1A1A17),
+ primary: Color(0xFFB5862A),
+ primaryForeground: Color(0xFFFFFFFF),
+ secondary: Color(0xFFF6ECD6),
+ secondaryForeground: Color(0xFF7A5B12),
+ muted: Color(0xFFF1EFE9),
+ mutedForeground: Color(0xFF8A8780),
+ accent: Color(0xFFF6ECD6),
+ accentForeground: Color(0xFF7A5B12),
+ destructive: Color(0xFFC0392B),
+ destructiveForeground: Color(0xFFFFFFFF),
+ border: Color(0xFFECEAE3),
+ input: Color(0xFFECEAE3),
+ ring: Color(0xFFB5862A),
+ selection: Color(0xFF1A1A17),
+);
+
+/// Hero 卡描边 = demo `--brand-soft-border`(比 brand-soft 填充更深一档的绿)。
+/// ShadColorScheme 无对应 slot,单列于此(色值仍集中在 theme,widget 不写 hex);
+/// widget 按 brightness 取值。light=oklch(0.9 0.07 128)、dark=oklch(0.42 0.07 131)。
+const jinzhiBrandSoftBorderLight = Color(0xFFE3D6B4);
+const jinzhiBrandSoftBorderDark = Color(0xFF5E4A22);
+
+Color brandSoftBorder(Brightness b) => b == Brightness.dark
+ ? jinzhiBrandSoftBorderDark
+ : jinzhiBrandSoftBorderLight;
+
+/// Hero(brand-soft 绿底)上的副/弱文本色。
+/// 规范:次级文本是**中性 muted**,不挪用语义色(accentForeground 是链接/强调专用)。
+/// 浅绿底上灰 muted(#8E8E93) 对比不足,故用 foreground 降透明的**中性深灰**
+/// (叠在绿底上自然带轻微绿调、和谐且达 AA);深色底仍用灰 muted(已够对比)。
+Color brandSoftMutedForeground(Brightness b, ShadColorScheme cs) =>
+ b == Brightness.dark
+ ? cs.mutedForeground
+ : cs.foreground.withValues(alpha: 0.7);
+
+/// 危险警示卡 soft-tint(demo `.da-warn`,与 brand-soft 同语言的 destructive 变体)。
+const jinzhiDestructiveSoftLight = Color(0xFFFBEDEA);
+const jinzhiDestructiveSoftBorderLight = Color(0xFFE8C7BF);
+const jinzhiDestructiveSoftDark = Color(0x1AEF4444);
+const jinzhiDestructiveSoftBorderDark = Color(0x52EF4444);
+
+Color destructiveSoft(Brightness b) => b == Brightness.dark
+ ? jinzhiDestructiveSoftDark
+ : jinzhiDestructiveSoftLight;
+
+Color destructiveSoftBorder(Brightness b) => b == Brightness.dark
+ ? jinzhiDestructiveSoftBorderDark
+ : jinzhiDestructiveSoftBorderLight;
+
+const jinzhiDarkScheme = ShadColorScheme(
+ background: Color(0xFF0F0F0F),
+ foreground: Color(0xFFF0F0F0),
+ card: Color(0xFF1A1A1A),
+ cardForeground: Color(0xFFF0F0F0),
+ popover: Color(0xFF1A1A1A),
+ popoverForeground: Color(0xFFF0F0F0),
+ primary: Color(0xFFD9B45D),
+ primaryForeground: Color(0xFF1A1A17),
+ secondary: Color(0xFF27272A),
+ secondaryForeground: Color(0xFFF0F0F0),
+ muted: Color(0xFF262626),
+ mutedForeground: Color(0xFFA1A1AA),
+ accent: Color(0xFF3A2A0D),
+ accentForeground: Color(0xFFD9B45D),
+ destructive: Color(0xFFEF4444),
+ destructiveForeground: Color(0xFFFFFFFF),
+ border: Color(0x1FFFFFFF),
+ input: Color(0x26FFFFFF),
+ ring: Color(0xFFD9B45D),
+ selection: Color(0xFFF0F0F0),
+);
diff --git a/lib/common/theme/spacing.dart b/lib/common/theme/spacing.dart
new file mode 100644
index 0000000..e6dfa52
--- /dev/null
+++ b/lib/common/theme/spacing.dart
@@ -0,0 +1,10 @@
+class Spacing {
+ static const page = 20.0;
+ static const cardPadding = 18.0;
+ static const cardGap = 14.0;
+ static const sectionGap = 30.0;
+ static const radiusBase = 7.2; // 0.45rem --radius
+ static const radiusBadge = 3.2; // --radius-sm (badge / tag)
+ static const radiusBtn = 5.2; // --radius-md (button / input)
+ static const radiusCard = 11.2; // --radius-xl (card / hero 容器)
+}
diff --git a/lib/common/theme/theme_controller.dart b/lib/common/theme/theme_controller.dart
new file mode 100644
index 0000000..86c39ea
--- /dev/null
+++ b/lib/common/theme/theme_controller.dart
@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import '../storage/storage_service.dart';
+import '../storage/storage_keys.dart';
+
+part 'theme_controller.g.dart';
+
+@riverpod
+class ThemeModeController extends _$ThemeModeController {
+ @override
+ ThemeMode build() {
+ final v = StorageService.to.getString(storageThemeMode);
+ return switch (v) {
+ 'light' => ThemeMode.light,
+ 'dark' => ThemeMode.dark,
+ _ => ThemeMode.system,
+ };
+ }
+
+ Future set(ThemeMode mode) async {
+ state = mode;
+ await StorageService.to.setString(storageThemeMode, mode.name);
+ }
+}
diff --git a/lib/common/util/external_link.dart b/lib/common/util/external_link.dart
new file mode 100644
index 0000000..0939999
--- /dev/null
+++ b/lib/common/util/external_link.dart
@@ -0,0 +1,36 @@
+import 'package:flutter/material.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../widgets/app_toast.dart';
+import '../widgets/outbound_service_sheet.dart';
+
+Future openExternalUrl(
+ BuildContext context,
+ String url, {
+ String title = '即将打开外部服务',
+ String description = '你将离开金值,进入第三方服务页面。外部内容由对应服务提供。\n价格与资讯仅供参考,不构成投资建议。',
+ String fallbackToast = '链接即将上线',
+}) async {
+ if (url.isEmpty) {
+ toastInfo(msg: fallbackToast);
+ return;
+ }
+
+ final uri = Uri.tryParse(url);
+ if (uri == null || !uri.hasScheme) {
+ toastInfo(msg: fallbackToast);
+ return;
+ }
+
+ final ok = await showOutboundServiceSheet(
+ context,
+ title: title,
+ description: description,
+ );
+ if (ok != true || !context.mounted) return;
+
+ final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
+ if (!launched && context.mounted) {
+ toastInfo(msg: fallbackToast);
+ }
+}
diff --git a/lib/common/utils/cjk_spacing.dart b/lib/common/utils/cjk_spacing.dart
new file mode 100644
index 0000000..c85a687
--- /dev/null
+++ b/lib/common/utils/cjk_spacing.dart
@@ -0,0 +1,53 @@
+/// 中西文混排自动加间隙(W3C clreq §3.2.2)。
+///
+/// 规范要求:横排时汉字与西文字母/数字之间留约 1/4 em 间隙。CSS 有
+/// `text-autospace`,Flutter 没有对应能力,因此在文本层插入
+/// U+2009 THIN SPACE(约 1/5 em,视觉接近规范建议,且远比全角空格克制)。
+///
+/// 规则(与 pangu 习惯一致):
+/// - 汉字 ↔ 字母/数字 相邻 → 插 thin space(「下降2.2%」→「下降 2.2%」);
+/// - 西文 run 末尾的 % ‰ 视作 run 的一部分(「2.2%的降幅」→「2.2% 的降幅」);
+/// - 全角标点(,。:等)自带空隙,不加;
+/// - markdown 版本额外处理强调/行内码边界(「至**138美元**」→「至 **138美元**」),
+/// 并跳过 fenced code block;表格管道符、链接 URL 天然不受影响。
+library;
+
+const String _thin = '\u2009'; // THIN SPACE
+
+// CJK 统一表意文字(基本区 + 扩展 A + 兼容区)
+const String _han = r'㐀-䶿一-鿿豈-';
+// 西文边界字符(字母/数字);西文侧在右边界额外允许 % ‰ 收尾
+const String _west = r'A-Za-z0-9';
+
+final RegExp _hanWest = RegExp('([$_han])([$_west])');
+final RegExp _westHan = RegExp('([$_west%‰])([$_han])');
+// 强调/行内码标记夹在边界中间:汉**西 / 西**汉
+final RegExp _hanMarkWest = RegExp('([$_han])(\\*{1,3}|`)([$_west])');
+final RegExp _westMarkHan = RegExp('([$_west%‰])(\\*{1,3}|`)([$_han])');
+
+/// 纯文本版:用于标题、副题、简介等 Text 控件。
+String cjkAutoSpace(String text) {
+ var s = text;
+ s = s.replaceAllMapped(_hanWest, (m) => '${m[1]}$_thin${m[2]}');
+ s = s.replaceAllMapped(_westHan, (m) => '${m[1]}$_thin${m[2]}');
+ return s;
+}
+
+/// markdown 版:跳过 fenced code block,并处理强调/行内码边界。
+String cjkMarkdownAutoSpace(String md) {
+ final lines = md.split('\n');
+ var inFence = false;
+ for (var i = 0; i < lines.length; i++) {
+ final t = lines[i].trimLeft();
+ if (t.startsWith('```') || t.startsWith('~~~')) {
+ inFence = !inFence;
+ continue;
+ }
+ if (inFence) continue;
+ var s = lines[i];
+ s = s.replaceAllMapped(_hanMarkWest, (m) => '${m[1]}$_thin${m[2]}${m[3]}');
+ s = s.replaceAllMapped(_westMarkHan, (m) => '${m[1]}${m[2]}$_thin${m[3]}');
+ lines[i] = cjkAutoSpace(s);
+ }
+ return lines.join('\n');
+}
diff --git a/lib/common/widgets/adaptive.dart b/lib/common/widgets/adaptive.dart
new file mode 100644
index 0000000..97c7f36
--- /dev/null
+++ b/lib/common/widgets/adaptive.dart
@@ -0,0 +1,86 @@
+import 'dart:math';
+
+import 'package:flutter/widgets.dart';
+
+import '../theme/spacing.dart';
+
+/// iPad 响应式布局:
+/// 宽 ≥ [breakpoint] 进入平板布局——屏边距 36、内容列统一限宽 920 居中
+/// (`padding: max(36, (宽-920)/2)`)、卡片列表两列(列距 18、行内等高)、
+/// 底部 tab 行限宽 600 居中。手机布局完全不变。
+abstract class Adaptive {
+ /// 平板断点(iPhone 最宽 ~440,iPad 11″ 竖屏 834 起)
+ static const double breakpoint = 700;
+
+ /// 平板屏边距(demo `--pad-screen: 36px`)
+ static const double padTablet = 36;
+
+ /// 内容列限宽(demo 所有页统一 920 居中)
+ static const double contentMaxWidth = 920;
+
+ /// 两列列表的列间距(demo `column-gap: 18px`)
+ static const double columnGap = 18;
+
+ /// 底部 tab 行限宽(demo `.nav .tabs{max-width:600px}`)
+ static const double tabsMaxWidth = 600;
+
+ static bool isTablet(BuildContext context) =>
+ MediaQuery.sizeOf(context).width >= breakpoint;
+
+ /// 屏水平边距:手机 [Spacing.page];平板 `max(36, (宽-920)/2)`,
+ /// 即内容超 920 时左右对称留白实现居中限宽。
+ static double hPad(BuildContext context) {
+ final w = MediaQuery.sizeOf(context).width;
+ if (w < breakpoint) return Spacing.page;
+ return max(padTablet, (w - contentMaxWidth) / 2);
+ }
+
+ static EdgeInsets screenPadding(
+ BuildContext context, {
+ double top = 0,
+ double bottom = 0,
+ }) {
+ final h = hPad(context);
+ return EdgeInsets.fromLTRB(h, top, h, bottom);
+ }
+}
+
+/// 卡片列表自适应 sliver:手机单列(卡间距 [Spacing.cardGap]);
+/// 平板两列——卡片两两成行、行内等高(IntrinsicHeight + stretch,
+/// 对应 demo CSS grid `1fr 1fr` 的等高行行为)。
+SliverList adaptiveCardSliver(
+ BuildContext context, {
+ required int itemCount,
+ required Widget Function(BuildContext, int) itemBuilder,
+ double rowGap = Spacing.cardGap,
+}) {
+ if (!Adaptive.isTablet(context)) {
+ return SliverList.separated(
+ itemCount: itemCount,
+ separatorBuilder: (_, _) => SizedBox(height: rowGap),
+ itemBuilder: itemBuilder,
+ );
+ }
+ final rows = (itemCount + 1) ~/ 2;
+ return SliverList.separated(
+ itemCount: rows,
+ separatorBuilder: (_, _) => SizedBox(height: rowGap),
+ itemBuilder: (c, r) {
+ final rightIndex = r * 2 + 1;
+ return IntrinsicHeight(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(child: itemBuilder(c, r * 2)),
+ const SizedBox(width: Adaptive.columnGap),
+ Expanded(
+ child: rightIndex < itemCount
+ ? itemBuilder(c, rightIndex)
+ : const SizedBox(),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+}
diff --git a/lib/common/widgets/app_state_card.dart b/lib/common/widgets/app_state_card.dart
new file mode 100644
index 0000000..26f980c
--- /dev/null
+++ b/lib/common/widgets/app_state_card.dart
@@ -0,0 +1,112 @@
+import 'package:flutter/material.dart';
+import '../theme/app_text.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+enum AppStateButtonStyle { filled, outline }
+
+class AppStateCard extends StatelessWidget {
+ const AppStateCard({
+ super.key,
+ required this.icon,
+ required this.title,
+ required this.subtitle,
+ this.buttonLabel,
+ this.onButtonPressed,
+ this.buttonStyle = AppStateButtonStyle.outline,
+ });
+
+ final IconData icon;
+ final String title;
+ final String subtitle;
+ final String? buttonLabel;
+ final VoidCallback? onButtonPressed;
+ final AppStateButtonStyle buttonStyle;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final hasAction = buttonLabel != null && onButtonPressed != null;
+
+ return ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 320),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Container(
+ width: 104,
+ height: 104,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.secondary,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(icon, size: 46, color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 22),
+ Text(
+ title,
+ textAlign: TextAlign.center,
+ style: AppText.cardTitle.copyWith(
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ subtitle,
+ textAlign: TextAlign.center,
+ style: AppText.body.copyWith(
+ fontSize: 15,
+ color: cs.mutedForeground,
+ height: 1.5,
+ ),
+ ),
+ if (hasAction) ...[
+ const SizedBox(height: 20),
+ SizedBox(
+ height: 42,
+ child: buttonStyle == AppStateButtonStyle.filled
+ ? FilledButton(
+ onPressed: onButtonPressed,
+ style: FilledButton.styleFrom(
+ backgroundColor: cs.foreground,
+ foregroundColor: cs.background,
+ elevation: 0,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ textStyle: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ child: Text(buttonLabel!),
+ )
+ : OutlinedButton(
+ onPressed: onButtonPressed,
+ style: OutlinedButton.styleFrom(
+ foregroundColor: cs.foreground,
+ side: BorderSide(color: cs.border),
+ backgroundColor: cs.background,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ textStyle: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ child: Text(buttonLabel!),
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/common/widgets/app_toast.dart b/lib/common/widgets/app_toast.dart
new file mode 100644
index 0000000..b9ab864
--- /dev/null
+++ b/lib/common/widgets/app_toast.dart
@@ -0,0 +1,26 @@
+import 'package:flutter/material.dart';
+import 'package:fluttertoast/fluttertoast.dart';
+
+// void showAppToast(BuildContext context, String message) {
+// ScaffoldMessenger.of(context)
+// ..hideCurrentSnackBar()
+// ..showSnackBar(
+// SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
+// );
+// }
+
+Future toastInfo({
+ required String msg,
+ Color backgroundColor = const Color(0xCC111111),
+ Color textColor = Colors.white,
+}) {
+ return Fluttertoast.showToast(
+ msg: msg,
+ toastLength: Toast.LENGTH_SHORT,
+ gravity: ToastGravity.BOTTOM,
+ timeInSecForIosWeb: 1,
+ backgroundColor: backgroundColor,
+ textColor: textColor,
+ fontSize: 16,
+ );
+}
diff --git a/lib/common/widgets/circle_back_button.dart b/lib/common/widgets/circle_back_button.dart
new file mode 100644
index 0000000..94b3d30
--- /dev/null
+++ b/lib/common/widgets/circle_back_button.dart
@@ -0,0 +1,54 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_remix/flutter_remix.dart';
+import 'package:go_router/go_router.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+/// 圆形返回钮(对齐 demo `.back`:38×38 圆 + 1px 描边 + chevron)。
+/// 左对齐到 12px(demo `.dhead` padding-left),用作各页 AppBar 的 leading。
+class CircleBackButton extends StatelessWidget {
+ const CircleBackButton({
+ super.key,
+ this.onTap,
+ this.fallbackLocation = '/assets',
+ });
+
+ final VoidCallback? onTap;
+ final String fallbackLocation;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Align(
+ alignment: Alignment.centerLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(left: 12),
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap:
+ onTap ??
+ () {
+ if (context.canPop()) {
+ context.pop();
+ } else {
+ context.go(fallbackLocation);
+ }
+ },
+ child: Container(
+ width: 38,
+ height: 38,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(color: cs.border, width: 1),
+ ),
+ child: Icon(
+ FlutterRemix.arrow_left_s_line,
+ size: 22,
+ color: cs.foreground,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/common/widgets/detail_kit.dart b/lib/common/widgets/detail_kit.dart
new file mode 100644
index 0000000..b1e8c1b
--- /dev/null
+++ b/lib/common/widgets/detail_kit.dart
@@ -0,0 +1,254 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_remix/flutter_remix.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../theme/app_text.dart';
+import '../theme/spacing.dart';
+
+// ─────────────────────────────────────────────────────────────────────────
+// 详情/主页通用卡片语言。
+// ─────────────────────────────────────────────────────────────────────────
+
+/// 白底描边卡片(demo `.mcard`)。
+class DetailCard extends StatelessWidget {
+ const DetailCard({super.key, required this.child, this.color});
+ final Widget child;
+
+ /// 卡片底色,默认白 `card`。
+ final Color? color;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(Spacing.cardPadding),
+ decoration: BoxDecoration(
+ color: color ?? cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(Spacing.radiusCard),
+ ),
+ child: child,
+ );
+ }
+}
+
+/// 卡片头部:编号圆角块(或图标)+ 标题(demo `.mh > .mnum + h2`)。
+class DetailSectionHeader extends StatelessWidget {
+ const DetailSectionHeader({
+ super.key,
+ this.number,
+ this.icon,
+ required this.title,
+ });
+ final int? number;
+ final IconData? icon;
+ final String title;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Row(
+ children: [
+ Container(
+ width: 26,
+ height: 26,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.accent,
+ borderRadius: BorderRadius.circular(7),
+ ),
+ child: number != null
+ ? Text(
+ '$number',
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: cs.accentForeground,
+ ),
+ )
+ : Icon(icon, size: 15, color: cs.accentForeground),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ title,
+ style: AppText.listTitle.copyWith(
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// 徽标(demo `.badge` secondary / outline / 实底)。
+class DetailBadge extends StatelessWidget {
+ const DetailBadge(
+ this.label, {
+ super.key,
+ this.bg,
+ this.fg,
+ this.outline = false,
+ this.icon,
+ });
+ final String label;
+ final Color? bg;
+ final Color? fg;
+ final bool outline;
+ final IconData? icon;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
+ decoration: BoxDecoration(
+ color: outline ? Colors.transparent : (bg ?? cs.secondary),
+ borderRadius: BorderRadius.circular(Spacing.radiusBadge),
+ // 实底也带 1px 透明描边,与 outline 徽标等高等宽
+ border: outline
+ ? Border.all(color: cs.border)
+ : Border.all(color: Colors.transparent),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (icon != null) ...[
+ Icon(
+ icon,
+ size: 10,
+ color: outline ? cs.mutedForeground : (fg ?? cs.foreground),
+ ),
+ const SizedBox(width: 3),
+ ],
+ Text(
+ label,
+ style: AppText.badge.copyWith(
+ color: outline ? cs.mutedForeground : (fg ?? cs.foreground),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// 键值行(demo `.crow > .cl + .cv`)。value 可选链接样式 + 右上箭头,点击回调。
+class DetailKeyValueRow extends StatelessWidget {
+ const DetailKeyValueRow({
+ super.key,
+ required this.label,
+ required this.value,
+ this.link = false,
+ this.onTap,
+ });
+ final String label;
+ final String value;
+ final bool link;
+ final VoidCallback? onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final valueWidget = Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Flexible(
+ child: Text(
+ value,
+ textAlign: TextAlign.right,
+ style: AppText.meta.copyWith(
+ fontSize: 13.5,
+ // 链接用深绿 accentForeground(lime 只做底色,不做文字色)
+ color: link ? cs.accentForeground : cs.foreground,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ if (link) ...[
+ const SizedBox(width: 3),
+ Icon(
+ FlutterRemix.external_link_line,
+ size: 13,
+ color: cs.accentForeground,
+ ),
+ ],
+ ],
+ );
+ return Container(
+ padding: const EdgeInsets.symmetric(vertical: 11),
+ decoration: BoxDecoration(
+ border: Border(bottom: BorderSide(color: cs.border)),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: AppText.meta.copyWith(
+ fontSize: 13.5,
+ color: cs.mutedForeground,
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Align(
+ alignment: Alignment.centerRight,
+ child: link && onTap != null
+ ? GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: valueWidget,
+ )
+ : valueWidget,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// 合规提示条(demo `.disc`:⚠ 图标 + 灰字)。
+class DetailDisclaimer extends StatelessWidget {
+ const DetailDisclaimer({super.key, required this.text});
+ final String text;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+ decoration: BoxDecoration(
+ color: cs.secondary,
+ borderRadius: BorderRadius.circular(Spacing.radiusBase),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(
+ FlutterRemix.error_warning_line,
+ size: 15,
+ color: cs.mutedForeground,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ text,
+ style: AppText.meta.copyWith(
+ fontSize: 12,
+ color: cs.mutedForeground,
+ height: 1.5,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/common/widgets/login_required_dialog.dart b/lib/common/widgets/login_required_dialog.dart
new file mode 100644
index 0000000..3c746ce
--- /dev/null
+++ b/lib/common/widgets/login_required_dialog.dart
@@ -0,0 +1,121 @@
+import 'package:flutter/material.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../theme/app_text.dart';
+import '../theme/spacing.dart';
+
+Future showLoginRequiredDialog(
+ BuildContext context, {
+ required String title,
+ required String description,
+ String cancelLabel = '取消',
+ String confirmLabel = '去登录',
+}) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ builder: (ctx) => Container(
+ width: double.infinity,
+ decoration: BoxDecoration(
+ color: cs.background,
+ borderRadius: const BorderRadius.only(
+ topLeft: Radius.circular(Spacing.radiusCard),
+ topRight: Radius.circular(Spacing.radiusCard),
+ ),
+ border: Border.all(color: cs.border),
+ ),
+ padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
+ child: SafeArea(
+ top: false,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ description,
+ style: AppText.body.copyWith(
+ fontSize: 13,
+ height: 1.6,
+ color: cs.mutedForeground,
+ ),
+ ),
+ const SizedBox(height: 22),
+ _LoginSheetButton(
+ label: confirmLabel,
+ primary: true,
+ onTap: () => Navigator.of(ctx).pop(true),
+ ),
+ const SizedBox(height: 12),
+ _LoginSheetButton(
+ label: cancelLabel,
+ primary: false,
+ onTap: () => Navigator.of(ctx).pop(false),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+}
+
+class _LoginSheetButton extends StatelessWidget {
+ const _LoginSheetButton({
+ required this.label,
+ required this.primary,
+ required this.onTap,
+ });
+
+ final String label;
+ final bool primary;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: primary ? cs.primary : cs.secondary,
+ borderRadius: BorderRadius.circular(12),
+ border: primary ? null : Border.all(color: cs.border),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: primary ? cs.primaryForeground : cs.foreground,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/common/widgets/markdown_style_utils.dart b/lib/common/widgets/markdown_style_utils.dart
new file mode 100644
index 0000000..5a57696
--- /dev/null
+++ b/lib/common/widgets/markdown_style_utils.dart
@@ -0,0 +1,120 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+/// Keep markdown horizontal rules subtle so backend `---` separators read as
+/// a light divider instead of a heavy line.
+MarkdownStyleSheet withMarkdownHairlineRule(
+ MarkdownStyleSheet base,
+ Color ruleColor,
+) {
+ return base.copyWith(
+ horizontalRuleDecoration: BoxDecoration(
+ border: Border(
+ top: BorderSide(color: ruleColor.withValues(alpha: 0.85), width: 0.5),
+ ),
+ ),
+ );
+}
+
+MarkdownStyleSheet appMarkdownStyle(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return withMarkdownHairlineRule(
+ MarkdownStyleSheet(
+ // 正文:CJK 适宜行高 1.75,块间统一节奏
+ p: TextStyle(fontSize: 14.5, height: 1.75, color: cs.foreground),
+ pPadding: EdgeInsets.zero,
+ blockSpacing: 14,
+ // 标题层级 + 上方留白形成分节(中文排版重段落分隔)
+ h1: TextStyle(
+ fontSize: 20,
+ height: 1.35,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ h1Padding: const EdgeInsets.only(top: 6, bottom: 2),
+ h2: TextStyle(
+ fontSize: 17,
+ height: 1.4,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ h2Padding: const EdgeInsets.only(top: 16, bottom: 2),
+ h3: TextStyle(
+ fontSize: 15,
+ height: 1.45,
+ fontWeight: FontWeight.w600,
+ color: cs.foreground,
+ ),
+ h3Padding: const EdgeInsets.only(top: 10),
+ // 列表:缩进 + 项目符号对齐正文
+ listIndent: 22,
+ listBullet: TextStyle(fontSize: 14.5, height: 1.75, color: cs.foreground),
+ listBulletPadding: const EdgeInsets.only(right: 6),
+ // 强调:加粗醒目;中文不用斜体(合成斜体难看),em 走常规体
+ strong: TextStyle(fontWeight: FontWeight.w700, color: cs.foreground),
+ em: TextStyle(fontStyle: FontStyle.normal, color: cs.foreground),
+ // 链接:accentForeground + 下划线(浅色深绿提升对比,深色仍 lime)
+ a: TextStyle(
+ color: cs.accentForeground,
+ decoration: TextDecoration.underline,
+ decorationColor: cs.accentForeground,
+ ),
+ // 表格:表头加粗、单元格留白、宽表横向滚动(IntrinsicColumnWidth 触发)、
+ // 外角小圆角(嵌在 11.2 圆角卡片内,用更小一档的 8)
+ tableHead: TextStyle(
+ fontWeight: FontWeight.w600,
+ fontSize: 13,
+ color: cs.foreground,
+ ),
+ // 表格数据用等宽数字(规范 3.1:tabular-nums),多行数值纵向对齐
+ tableBody: TextStyle(
+ fontSize: 13,
+ height: 1.5,
+ color: cs.foreground,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ ),
+ tableHeadAlign: TextAlign.left,
+ tableColumnWidth: const IntrinsicColumnWidth(),
+ tableCellsPadding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 8,
+ ),
+ tablePadding: const EdgeInsets.only(bottom: 4),
+ tableBorder: TableBorder.all(
+ color: cs.border,
+ width: 1,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ // 引用块:中文不用斜体;浅底 + 主色左条 + 内距 + 右侧小圆角
+ blockquoteDecoration: BoxDecoration(
+ color: cs.muted,
+ border: Border(left: BorderSide(color: cs.primary, width: 3)),
+ borderRadius: const BorderRadius.only(
+ topRight: Radius.circular(6),
+ bottomRight: Radius.circular(6),
+ ),
+ ),
+ blockquotePadding: const EdgeInsets.fromLTRB(14, 10, 12, 10),
+ blockquote: TextStyle(
+ fontSize: 13.5,
+ height: 1.7,
+ color: cs.mutedForeground,
+ ),
+ // 行内 code(如 `机构名`):用正文字体(DM Sans),不用等宽 Menlo——
+ // 机构名/英文术语用等宽会显得突兀(demo 已改为正文字体)。仅保留浅底标记。
+ code: TextStyle(
+ fontSize: 13.5,
+ height: 1.75,
+ color: cs.foreground,
+ backgroundColor: cs.muted,
+ fontFamilyFallback: const ['PingFang SC', 'Noto Sans CJK SC'],
+ ),
+ codeblockDecoration: BoxDecoration(
+ color: cs.muted,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ cs.border,
+ );
+}
diff --git a/lib/common/widgets/markdown_table.dart b/lib/common/widgets/markdown_table.dart
new file mode 100644
index 0000000..39982ad
--- /dev/null
+++ b/lib/common/widgets/markdown_table.dart
@@ -0,0 +1,299 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../util/external_link.dart';
+
+/// GFM 表格的「填满 / 滚动」渲染。
+///
+/// flutter_markdown_plus 的内置 `_buildTable()` 二选一:FlexColumnWidth 填满但
+/// 折行;IntrinsicColumnWidth 不折行但横向滚动里无法填满。自定义 builder 又被
+/// 包强制覆盖。故把表格从 markdown 抽出,用本组件接管:
+/// - 量出各列内容固有宽度;
+/// - 总宽 ≤ 可用宽 → 按比例放大列宽**填满整行**(不滚动、不折行);
+/// - 总宽 > 可用宽 → 用固有列宽 + 横向滚动查看(不折行)。
+/// 单元格支持 `**加粗**` 与 `[文字](链接)`(点击走外部承接弹窗)。
+class MarkdownTable extends StatelessWidget {
+ const MarkdownTable({
+ super.key,
+ required this.header,
+ required this.rows,
+ required this.aligns,
+ });
+
+ final List header;
+ final List> rows;
+ final List aligns;
+
+ static const double _hPad = 12;
+ static const double _vPad = 8;
+ static const double _border = 1;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final n = header.length;
+ // 阅读字号倍率(表格在 ReaderScaled 子树内):量宽必须用同一 textScaler,
+ // 否则缩放后列宽测算与实际渲染不符。
+ final textScaler = MediaQuery.textScalerOf(context);
+
+ final headStyle = TextStyle(
+ fontWeight: FontWeight.w600,
+ fontSize: 13,
+ color: cs.foreground,
+ );
+ final bodyStyle = TextStyle(
+ fontSize: 13,
+ height: 1.5,
+ color: cs.foreground,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ );
+
+ // 各列固有宽 = 该列最宽单元格(含表头) + 左右内距;按加粗量(偏宽)更稳妥
+ final intrinsic = List.filled(n, 0);
+ void acc(List cells) {
+ for (var c = 0; c < n && c < cells.length; c++) {
+ final w = _measure(
+ _stripMd(cells[c]),
+ bodyStyle.copyWith(fontWeight: FontWeight.w700),
+ textScaler,
+ );
+ if (w > intrinsic[c]) intrinsic[c] = w;
+ }
+ }
+
+ acc(header);
+ for (final r in rows) {
+ acc(r);
+ }
+ for (var c = 0; c < n; c++) {
+ intrinsic[c] += _hPad * 2;
+ }
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final avail = constraints.maxWidth;
+ final sum =
+ intrinsic.fold(0, (a, b) => a + b) + (n + 1) * _border;
+
+ List widths;
+ bool scroll;
+ if (sum <= avail) {
+ // 够宽:把多余宽度按列固有宽比例分摊,填满整行
+ final extra = avail - sum;
+ final total = intrinsic.fold(0, (a, b) => a + b);
+ widths = [
+ for (final w in intrinsic)
+ w + (total > 0 ? extra * (w / total) : extra / n),
+ ];
+ scroll = false;
+ } else {
+ widths = intrinsic;
+ scroll = true;
+ }
+
+ final table = Table(
+ defaultColumnWidth: const IntrinsicColumnWidth(),
+ columnWidths: {
+ for (var c = 0; c < n; c++) c: FixedColumnWidth(widths[c]),
+ },
+ border: TableBorder.all(
+ color: cs.border,
+ width: _border,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ children: [
+ _row(context, header, headStyle, cs, isHeader: true),
+ for (final r in rows) _row(context, r, bodyStyle, cs),
+ ],
+ );
+
+ if (!scroll) return table;
+ return SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minWidth: avail),
+ child: table,
+ ),
+ );
+ },
+ );
+ }
+
+ TableRow _row(
+ BuildContext context,
+ List cells,
+ TextStyle style,
+ ShadColorScheme cs, {
+ bool isHeader = false,
+ }) {
+ return TableRow(
+ decoration: isHeader ? BoxDecoration(color: cs.muted) : null,
+ children: [
+ for (var c = 0; c < header.length; c++)
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: _hPad,
+ vertical: _vPad,
+ ),
+ child: Align(
+ alignment: _alignment(c),
+ child: Text.rich(
+ _inlineSpans(
+ context,
+ c < cells.length ? cells[c] : '',
+ style,
+ cs,
+ ),
+ textAlign: c < aligns.length ? aligns[c] : TextAlign.left,
+ softWrap: false,
+ overflow: TextOverflow.visible,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Alignment _alignment(int c) {
+ final a = c < aligns.length ? aligns[c] : TextAlign.left;
+ return switch (a) {
+ TextAlign.center => Alignment.center,
+ TextAlign.right => Alignment.centerRight,
+ _ => Alignment.centerLeft,
+ };
+ }
+
+ static double _measure(String text, TextStyle style, TextScaler textScaler) {
+ final tp = TextPainter(
+ text: TextSpan(text: text, style: style),
+ maxLines: 1,
+ textDirection: TextDirection.ltr,
+ textScaler: textScaler,
+ )..layout();
+ return tp.width;
+ }
+}
+
+// ── 解析:从 markdown 抽出表格块,其余按原文保留 ──────────────────────────
+
+class MdSegment {
+ const MdSegment.text(this.text) : table = null;
+ const MdSegment.table(this.table) : text = '';
+ final String text;
+ final MarkdownTable? table;
+ bool get isTable => table != null;
+}
+
+final RegExp _sepLine = RegExp(r'^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$');
+
+bool _isRowLine(String l) => l.contains('|');
+bool _isSepLine(String l) =>
+ l.contains('-') && l.contains('|') && _sepLine.hasMatch(l);
+
+List _splitCells(String line) {
+ var t = line.trim();
+ if (t.startsWith('|')) t = t.substring(1);
+ if (t.endsWith('|')) t = t.substring(0, t.length - 1);
+ return t.split('|').map((s) => s.trim()).toList();
+}
+
+/// 把 markdown 拆成「文本段」与「表格段」,顺序保留。
+List splitMarkdownTables(String md) {
+ final lines = md.split('\n');
+ final out = [];
+ final buf = [];
+ void flush() {
+ if (buf.isNotEmpty) {
+ out.add(MdSegment.text(buf.join('\n')));
+ buf.clear();
+ }
+ }
+
+ var i = 0;
+ while (i < lines.length) {
+ final isHeader =
+ _isRowLine(lines[i]) &&
+ i + 1 < lines.length &&
+ _isSepLine(lines[i + 1]);
+ if (!isHeader) {
+ buf.add(lines[i]);
+ i++;
+ continue;
+ }
+ flush();
+ final headerCells = _splitCells(lines[i]);
+ // demo:忽略 markdown 列对齐,表格内容统一左对齐
+ // (右对齐会让右列大片留白只显几个字)。
+ final aligns = List.filled(headerCells.length, TextAlign.left);
+ final rows = >[];
+ var j = i + 2;
+ while (j < lines.length && _isRowLine(lines[j]) && !_isSepLine(lines[j])) {
+ final cs = _splitCells(lines[j]);
+ rows.add([
+ for (var c = 0; c < headerCells.length; c++) c < cs.length ? cs[c] : '',
+ ]);
+ j++;
+ }
+ out.add(
+ MdSegment.table(
+ MarkdownTable(header: headerCells, rows: rows, aligns: aligns),
+ ),
+ );
+ i = j;
+ }
+ flush();
+ return out;
+}
+
+// ── 单元格内联:**加粗** 与 [文字](链接) ────────────────────────────────
+
+final RegExp _inlineToken = RegExp(r'\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)');
+
+String _stripMd(String s) => s
+ .replaceAllMapped(_inlineToken, (m) => m.group(1) ?? m.group(2) ?? '')
+ .replaceAll('*', '');
+
+InlineSpan _inlineSpans(
+ BuildContext context,
+ String raw,
+ TextStyle base,
+ ShadColorScheme cs,
+) {
+ final spans = [];
+ var last = 0;
+ for (final m in _inlineToken.allMatches(raw)) {
+ if (m.start > last) {
+ spans.add(TextSpan(text: raw.substring(last, m.start), style: base));
+ }
+ if (m.group(1) != null) {
+ spans.add(
+ TextSpan(
+ text: m.group(1),
+ style: base.copyWith(fontWeight: FontWeight.w700),
+ ),
+ );
+ } else {
+ final text = m.group(2)!;
+ final url = m.group(3)!;
+ spans.add(
+ TextSpan(
+ text: text,
+ style: base.copyWith(
+ color: cs.accentForeground,
+ decoration: TextDecoration.underline,
+ decorationColor: cs.accentForeground,
+ ),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () =>
+ openExternalUrl(context, url, fallbackToast: '链接即将上线'),
+ ),
+ );
+ }
+ last = m.end;
+ }
+ if (last < raw.length) {
+ spans.add(TextSpan(text: raw.substring(last), style: base));
+ }
+ return TextSpan(children: spans);
+}
diff --git a/lib/common/widgets/outbound_service_sheet.dart b/lib/common/widgets/outbound_service_sheet.dart
new file mode 100644
index 0000000..c960fef
--- /dev/null
+++ b/lib/common/widgets/outbound_service_sheet.dart
@@ -0,0 +1,113 @@
+import 'package:flutter/material.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../theme/app_text.dart';
+
+Future showOutboundServiceSheet(
+ BuildContext context, {
+ String title = '即将打开外部服务',
+ String description = '你将离开金值,进入第三方服务页面。外部内容由对应服务提供。\n价格与资讯仅供参考,不构成投资建议。',
+ String cancelLabel = '取消',
+ String confirmLabel = '继续前往',
+}) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ isDismissible: true,
+ enableDrag: true,
+ backgroundColor: Colors.transparent,
+ barrierColor: Colors.black.withValues(alpha: 0.45),
+ builder: (_) => OutboundServiceSheet(
+ title: title,
+ description: description,
+ cancelLabel: cancelLabel,
+ confirmLabel: confirmLabel,
+ ),
+ );
+}
+
+class OutboundServiceSheet extends StatelessWidget {
+ const OutboundServiceSheet({
+ super.key,
+ this.title = '即将打开外部服务',
+ this.description = '你将离开金值,进入第三方服务页面。外部内容由对应服务提供。\n价格与资讯仅供参考,不构成投资建议。',
+ this.cancelLabel = '取消',
+ this.confirmLabel = '继续前往',
+ });
+
+ final String title;
+ final String description;
+ final String cancelLabel;
+ final String confirmLabel;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
+ return Container(
+ width: double.infinity,
+ decoration: BoxDecoration(
+ color: cs.background,
+ borderRadius: const BorderRadius.only(
+ topLeft: Radius.circular(24),
+ topRight: Radius.circular(24),
+ ),
+ border: Border.all(color: cs.border),
+ ),
+ padding: EdgeInsets.fromLTRB(22, 12, 22, 22 + bottomInset),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 70,
+ height: 5,
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(999),
+ ),
+ ),
+ ),
+ const SizedBox(height: 26),
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ description,
+ style: AppText.body.copyWith(
+ fontSize: 14,
+ height: 1.65,
+ color: cs.mutedForeground,
+ ),
+ ),
+ const SizedBox(height: 24),
+ Row(
+ children: [
+ Expanded(
+ child: ShadButton.outline(
+ foregroundColor: cs.foreground,
+ onPressed: () => Navigator.of(context).pop(false),
+ child: Text(cancelLabel),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: ShadButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: Text(confirmLabel),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/common/widgets/placeholder_tab_page.dart b/lib/common/widgets/placeholder_tab_page.dart
new file mode 100644
index 0000000..c4f620a
--- /dev/null
+++ b/lib/common/widgets/placeholder_tab_page.dart
@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../theme/spacing.dart';
+import 'adaptive.dart';
+
+class PlaceholderTabPage extends StatelessWidget {
+ const PlaceholderTabPage({
+ super.key,
+ required this.title,
+ required this.description,
+ });
+
+ final String title;
+ final String description;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return SafeArea(
+ top: false,
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(18, 20, 18, 24),
+ children: [
+ Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: Adaptive.contentMaxWidth,
+ ),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(Spacing.radiusCard),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ letterSpacing: 0,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ description,
+ style: TextStyle(
+ fontSize: 14,
+ height: 1.6,
+ color: cs.mutedForeground,
+ letterSpacing: 0,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/common/widgets/screen_header.dart b/lib/common/widgets/screen_header.dart
new file mode 100644
index 0000000..09be198
--- /dev/null
+++ b/lib/common/widgets/screen_header.dart
@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+import '../theme/spacing.dart';
+import '../theme/app_text.dart';
+import 'adaptive.dart';
+
+/// 各 tab 屏顶部标题区(对齐 demo `.apphead`)。
+/// 大标题 34/800/1.15 + 可选副标题 15/muted。
+class ScreenHeader extends StatelessWidget {
+ const ScreenHeader({super.key, required this.title, this.subtitle});
+
+ final String title;
+ final String? subtitle;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: Adaptive.screenPadding(context, top: 10, bottom: 18),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: AppText.appTitle.copyWith(color: cs.foreground)),
+ if (subtitle != null) ...[
+ const SizedBox(height: 8),
+ Text(
+ subtitle!,
+ style: AppText.body.copyWith(
+ color: cs.mutedForeground,
+ height: 1.4,
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+/// 分区标题(对齐 demo `.section-title`):22/700 + 可选右尖角。
+class SectionTitle extends StatelessWidget {
+ const SectionTitle(this.title, {super.key, this.showChevron = false});
+
+ final String title;
+ final bool showChevron;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: Adaptive.screenPadding(
+ context,
+ top: Spacing.sectionGap,
+ bottom: 16,
+ ),
+ child: Row(
+ children: [
+ Text(
+ title,
+ style: AppText.sectionTitle.copyWith(color: cs.foreground),
+ ),
+ if (showChevron) ...[
+ const SizedBox(width: 6),
+ Icon(Icons.chevron_right, size: 18, color: cs.mutedForeground),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/common/widgets/skeleton_card.dart b/lib/common/widgets/skeleton_card.dart
new file mode 100644
index 0000000..63f4f05
--- /dev/null
+++ b/lib/common/widgets/skeleton_card.dart
@@ -0,0 +1,210 @@
+import 'package:flutter/material.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+import 'package:shimmer/shimmer.dart';
+
+import '../theme/spacing.dart';
+
+class SkeletonLineCard extends StatelessWidget {
+ const SkeletonLineCard({
+ super.key,
+ this.lineWidths = const [0.4, 1, 0.8, 0.6],
+ this.height,
+ });
+
+ final List lineWidths;
+ final double? height;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Container(
+ height: height,
+ width: double.infinity,
+ padding: const EdgeInsets.all(Spacing.cardPadding),
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border, width: 1),
+ borderRadius: BorderRadius.circular(Spacing.radiusCard),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ for (var i = 0; i < lineWidths.length; i++) ...[
+ _SkeletonLine(widthFactor: lineWidths[i]),
+ if (i != lineWidths.length - 1) const SizedBox(height: 10),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class SkeletonInstitutionCard extends StatelessWidget {
+ const SkeletonInstitutionCard({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return const SkeletonLineCard(lineWidths: [0.4, 1, 0.6]);
+ }
+}
+
+class SkeletonListenCard extends StatelessWidget {
+ const SkeletonListenCard({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return const SkeletonLineCard(lineWidths: [0.4, 1, 0.6]);
+ }
+}
+
+class SkeletonSectionCard extends StatelessWidget {
+ const SkeletonSectionCard({
+ super.key,
+ required this.roman,
+ required this.lineWidths,
+ this.baseColor,
+ this.highlightColor,
+ });
+
+ final String roman;
+ final List lineWidths;
+ final Color? baseColor;
+ final Color? highlightColor;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final brightness = ShadTheme.of(context).brightness;
+ final resolvedBaseColor =
+ baseColor ??
+ (brightness == Brightness.dark
+ ? cs.secondary
+ : cs.border.withValues(alpha: 0.95));
+ final resolvedHighlightColor =
+ highlightColor ??
+ (brightness == Brightness.dark
+ ? cs.card
+ : cs.secondary.withValues(alpha: 0.95));
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(
+ horizontal: Spacing.cardPadding,
+ vertical: 16,
+ ),
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border, width: 1),
+ borderRadius: BorderRadius.circular(Spacing.radiusCard),
+ ),
+ child: Shimmer.fromColors(
+ baseColor: resolvedBaseColor,
+ highlightColor: resolvedHighlightColor,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ for (var i = 0; i < lineWidths.length; i++) ...[
+ _SkeletonLine(
+ widthFactor: lineWidths[i],
+ height: i == 0 ? 14 : 12,
+ ),
+ if (i != lineWidths.length - 1) const SizedBox(height: 10),
+ ],
+ const SizedBox(height: 2),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _SkeletonLine extends StatelessWidget {
+ const _SkeletonLine({required this.widthFactor, this.height = 12});
+
+ final double widthFactor;
+ final double height;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return FractionallySizedBox(
+ widthFactor: widthFactor,
+ child: Shimmer.fromColors(
+ baseColor: cs.secondary,
+ highlightColor: cs.muted,
+ child: Container(
+ height: height,
+ decoration: BoxDecoration(
+ color: cs.secondary,
+ borderRadius: BorderRadius.circular(6),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class SectionSkeletonList extends StatelessWidget {
+ const SectionSkeletonList({
+ super.key,
+ required this.count,
+ required this.lineWidthsForIndex,
+ this.romanLabels,
+ });
+
+ final int count;
+ final List Function(int index) lineWidthsForIndex;
+ final List? romanLabels;
+
+ @override
+ Widget build(BuildContext context) {
+ final labels =
+ romanLabels ??
+ List.generate(count, (index) => _roman(index + 1));
+ return SliverPadding(
+ padding: const EdgeInsets.fromLTRB(Spacing.page, 4, Spacing.page, 24),
+ sliver: SliverList.separated(
+ itemCount: count,
+ separatorBuilder: (_, _) => const SizedBox(height: Spacing.cardGap),
+ itemBuilder: (context, index) => SkeletonSectionCard(
+ roman: labels[index],
+ lineWidths: lineWidthsForIndex(index),
+ ),
+ ),
+ );
+ }
+}
+
+class SectionSkeletonListView extends StatelessWidget {
+ const SectionSkeletonListView({
+ super.key,
+ required this.count,
+ required this.lineWidthsForIndex,
+ this.romanLabels,
+ });
+
+ final int count;
+ final List Function(int index) lineWidthsForIndex;
+ final List? romanLabels;
+
+ @override
+ Widget build(BuildContext context) {
+ final labels =
+ romanLabels ??
+ List.generate(count, (index) => _roman(index + 1));
+ return ListView.separated(
+ padding: const EdgeInsets.only(bottom: 72),
+ itemCount: count,
+ separatorBuilder: (_, _) => const SizedBox(height: Spacing.cardGap),
+ itemBuilder: (context, index) => SkeletonSectionCard(
+ roman: labels[index],
+ lineWidths: lineWidthsForIndex(index),
+ ),
+ );
+ }
+}
+
+String _roman(int value) {
+ const numerals = ['I.', 'II.', 'III.', 'IV.', 'V.', 'VI.', 'VII.', 'VIII.'];
+ if (value <= 0 || value > numerals.length) return '$value.';
+ return numerals[value - 1];
+}
diff --git a/lib/features/assets/application/asset_portfolio_controller.dart b/lib/features/assets/application/asset_portfolio_controller.dart
new file mode 100644
index 0000000..faaefb3
--- /dev/null
+++ b/lib/features/assets/application/asset_portfolio_controller.dart
@@ -0,0 +1,122 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../../common/domain/metal_type.dart';
+import '../../market/data/mock_market_repository.dart';
+import '../data/asset_models.dart';
+import '../data/mock_asset_repository.dart';
+
+final assetPortfolioControllerProvider =
+ StateNotifierProvider((ref) {
+ final holdings = ref.watch(mockAssetRepositoryProvider).loadHoldings();
+ final market = ref.watch(mockMarketRepositoryProvider);
+ return AssetPortfolioController(
+ holdings: holdings,
+ spotPriceFor: (metal) => market.quoteFor(metal).spotPrice,
+ changeFor: (metal) => market.quoteFor(metal).changeAmount,
+ );
+ });
+
+class AssetPortfolioController extends StateNotifier {
+ AssetPortfolioController({
+ required List holdings,
+ required double Function(MetalType metal) spotPriceFor,
+ required double Function(MetalType metal) changeFor,
+ this.recycleDiscount = 0.97,
+ }) : _holdings = holdings,
+ _spotPriceFor = spotPriceFor,
+ _changeFor = changeFor,
+ super(
+ _buildSummary(
+ holdings: holdings,
+ spotPriceFor: spotPriceFor,
+ changeFor: changeFor,
+ recycleDiscount: recycleDiscount,
+ ),
+ );
+
+ final List _holdings;
+ final double Function(MetalType metal) _spotPriceFor;
+ final double Function(MetalType metal) _changeFor;
+ final double recycleDiscount;
+
+ void addHolding(GoldAssetHolding holding) {
+ _holdings.add(holding);
+ state = _buildSummary(
+ holdings: _holdings,
+ spotPriceFor: _spotPriceFor,
+ changeFor: _changeFor,
+ recycleDiscount: recycleDiscount,
+ );
+ }
+
+ static PortfolioSummary _buildSummary({
+ required List holdings,
+ required double Function(MetalType metal) spotPriceFor,
+ required double Function(MetalType metal) changeFor,
+ required double recycleDiscount,
+ }) {
+ final active = holdings
+ .where((holding) => holding.status == HoldingStatus.active)
+ .toList(growable: false);
+ final valuations = [
+ for (final holding in active)
+ HoldingValuation(
+ holding: holding,
+ materialValue: holding.materialValue(spotPriceFor(holding.metal)),
+ todayChange:
+ holding.weightGram * holding.purity * changeFor(holding.metal),
+ ),
+ ];
+
+ final totalValue = valuations.fold(
+ 0,
+ (sum, item) => sum + item.materialValue,
+ );
+ final todayChange = valuations.fold(
+ 0,
+ (sum, item) => sum + item.todayChange,
+ );
+ final totalCost = active.fold(
+ 0,
+ (sum, holding) => sum + (holding.costAmount ?? 0),
+ );
+ final totalWeight = active.fold(
+ 0,
+ (sum, holding) => sum + holding.weightGram,
+ );
+
+ return PortfolioSummary(
+ totalValue: totalValue,
+ todayChangeAmount: todayChange,
+ todayChangePercent: totalValue == 0 ? 0 : todayChange / totalValue * 100,
+ totalCost: totalCost,
+ totalWeightGram: totalWeight,
+ recycleReferenceValue: totalValue * recycleDiscount,
+ breakdowns: [
+ for (final metal in MetalType.values)
+ _breakdownFor(metal, active, spotPriceFor),
+ ].where((item) => item.count > 0).toList(growable: false),
+ holdings: valuations,
+ );
+ }
+
+ static MetalBreakdown _breakdownFor(
+ MetalType metal,
+ List holdings,
+ double Function(MetalType metal) spotPriceFor,
+ ) {
+ final items = holdings.where((holding) => holding.metal == metal).toList();
+ return MetalBreakdown(
+ metal: metal,
+ value: items.fold(
+ 0,
+ (sum, holding) => sum + holding.materialValue(spotPriceFor(metal)),
+ ),
+ weightGram: items.fold(
+ 0,
+ (sum, holding) => sum + holding.weightGram,
+ ),
+ count: items.length,
+ );
+ }
+}
diff --git a/lib/features/assets/data/asset_models.dart b/lib/features/assets/data/asset_models.dart
new file mode 100644
index 0000000..c2e00a6
--- /dev/null
+++ b/lib/features/assets/data/asset_models.dart
@@ -0,0 +1,102 @@
+import '../../../common/domain/metal_type.dart';
+
+enum AssetCategory {
+ bar('金条'),
+ necklace('项链'),
+ ring('戒指'),
+ bracelet('手镯'),
+ bean('金豆'),
+ earring('耳环'),
+ silverware('银饰'),
+ platinumPiece('铂金件');
+
+ const AssetCategory(this.label);
+
+ final String label;
+}
+
+enum HoldingStatus { active, sold, gifted }
+
+class GoldAssetHolding {
+ const GoldAssetHolding({
+ required this.id,
+ required this.name,
+ required this.metal,
+ required this.category,
+ required this.purity,
+ required this.purityLabel,
+ required this.weightGram,
+ required this.createdAt,
+ required this.updatedAt,
+ this.costAmount,
+ this.purchaseDate,
+ this.channel,
+ this.note,
+ this.status = HoldingStatus.active,
+ });
+
+ final String id;
+ final String name;
+ final MetalType metal;
+ final AssetCategory category;
+ final double purity;
+ final String purityLabel;
+ final double weightGram;
+ final double? costAmount;
+ final DateTime? purchaseDate;
+ final String? channel;
+ final String? note;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+ final HoldingStatus status;
+
+ double materialValue(double spotPrice) => weightGram * purity * spotPrice;
+}
+
+class HoldingValuation {
+ const HoldingValuation({
+ required this.holding,
+ required this.materialValue,
+ required this.todayChange,
+ });
+
+ final GoldAssetHolding holding;
+ final double materialValue;
+ final double todayChange;
+}
+
+class MetalBreakdown {
+ const MetalBreakdown({
+ required this.metal,
+ required this.value,
+ required this.weightGram,
+ required this.count,
+ });
+
+ final MetalType metal;
+ final double value;
+ final double weightGram;
+ final int count;
+}
+
+class PortfolioSummary {
+ const PortfolioSummary({
+ required this.totalValue,
+ required this.todayChangeAmount,
+ required this.todayChangePercent,
+ required this.totalCost,
+ required this.totalWeightGram,
+ required this.recycleReferenceValue,
+ required this.breakdowns,
+ required this.holdings,
+ });
+
+ final double totalValue;
+ final double todayChangeAmount;
+ final double todayChangePercent;
+ final double totalCost;
+ final double totalWeightGram;
+ final double recycleReferenceValue;
+ final List breakdowns;
+ final List holdings;
+}
diff --git a/lib/features/assets/data/mock_asset_repository.dart b/lib/features/assets/data/mock_asset_repository.dart
new file mode 100644
index 0000000..455f47d
--- /dev/null
+++ b/lib/features/assets/data/mock_asset_repository.dart
@@ -0,0 +1,73 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../../common/domain/metal_type.dart';
+import 'asset_models.dart';
+
+final mockAssetRepositoryProvider = Provider(
+ (ref) => MockAssetRepository(),
+);
+
+class MockAssetRepository {
+ List loadHoldings() {
+ final now = DateTime(2026, 6, 18, 16, 11);
+ return [
+ GoldAssetHolding(
+ id: 'holding_gold_bar_001',
+ name: '投资金条',
+ metal: MetalType.gold,
+ category: AssetCategory.bar,
+ purity: 0.9999,
+ purityLabel: '足金9999',
+ weightGram: 50,
+ costAmount: 43200,
+ purchaseDate: DateTime(2025, 10, 12),
+ channel: '银行',
+ createdAt: now.subtract(const Duration(days: 249)),
+ updatedAt: now,
+ ),
+ GoldAssetHolding(
+ id: 'holding_gold_ring_001',
+ name: '素圈戒指',
+ metal: MetalType.gold,
+ category: AssetCategory.ring,
+ purity: 0.999,
+ purityLabel: '足金999',
+ weightGram: 8.6,
+ costAmount: 7820,
+ purchaseDate: DateTime(2024, 12, 4),
+ channel: '金店',
+ createdAt: now.subtract(const Duration(days: 196)),
+ updatedAt: now,
+ ),
+ GoldAssetHolding(
+ id: 'holding_platinum_001',
+ name: 'PT950 项链',
+ metal: MetalType.platinum,
+ category: AssetCategory.platinumPiece,
+ purity: 0.95,
+ purityLabel: 'PT950',
+ weightGram: 12.3,
+ costAmount: 5200,
+ purchaseDate: DateTime(2023, 8, 21),
+ channel: '金店',
+ createdAt: now.subtract(const Duration(days: 667)),
+ updatedAt: now,
+ ),
+ GoldAssetHolding(
+ id: 'holding_silver_001',
+ name: '银手镯',
+ metal: MetalType.silver,
+ category: AssetCategory.bracelet,
+ purity: 0.999,
+ purityLabel: '999银',
+ weightGram: 31.8,
+ costAmount: 980,
+ purchaseDate: DateTime(2024, 4, 7),
+ channel: '金店',
+ note: '材料价值偏低',
+ createdAt: now.subtract(const Duration(days: 802)),
+ updatedAt: now,
+ ),
+ ];
+ }
+}
diff --git a/lib/features/assets/presentation/assets_page.dart b/lib/features/assets/presentation/assets_page.dart
new file mode 100644
index 0000000..ff2226c
--- /dev/null
+++ b/lib/features/assets/presentation/assets_page.dart
@@ -0,0 +1,136 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../../../common/domain/money.dart';
+import '../../../common/widgets/adaptive.dart';
+import '../application/asset_portfolio_controller.dart';
+
+class AssetsPage extends ConsumerWidget {
+ const AssetsPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final summary = ref.watch(assetPortfolioControllerProvider);
+ final cs = ShadTheme.of(context).colorScheme;
+ return SafeArea(
+ top: false,
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
+ children: [
+ Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: Adaptive.contentMaxWidth,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('我的贵金属总估值', style: _mutedStyle(cs)),
+ const SizedBox(height: 10),
+ Text(
+ formatCny(summary.totalValue),
+ style: TextStyle(
+ fontSize: 34,
+ fontWeight: FontWeight.w800,
+ color: cs.foreground,
+ letterSpacing: 0,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '今日 ${formatCny(summary.todayChangeAmount)} · ${summary.todayChangePercent.toStringAsFixed(2)}%',
+ style: TextStyle(
+ fontSize: 13,
+ color: summary.todayChangeAmount >= 0
+ ? const Color(0xFFC0392B)
+ : const Color(0xFF2E8B6F),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ for (final item in summary.breakdowns)
+ Chip(
+ label: Text(
+ '${item.metal.shortLabel} ${formatCny(item.value)}',
+ ),
+ avatar: CircleAvatar(
+ backgroundColor: item.metal.color,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ _Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '我的持仓 · ${summary.holdings.length} 件 · ${formatGram(summary.totalWeightGram)}',
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 12),
+ for (final item in summary.holdings)
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ '${item.holding.name} · ${item.holding.purityLabel} · ${formatGram(item.holding.weightGram)}',
+ style: TextStyle(color: cs.foreground),
+ ),
+ ),
+ Text(formatCny(item.materialValue)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Card extends StatelessWidget {
+ const _Card({required this.child});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Padding(padding: const EdgeInsets.all(16), child: child),
+ );
+ }
+}
+
+TextStyle _mutedStyle(ShadColorScheme cs) {
+ return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0);
+}
diff --git a/lib/features/auth/application/auth_controller.dart b/lib/features/auth/application/auth_controller.dart
new file mode 100644
index 0000000..cd0a6c9
--- /dev/null
+++ b/lib/features/auth/application/auth_controller.dart
@@ -0,0 +1,68 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../../common/network/auth_interceptor.dart';
+import '../../../common/storage/storage_keys.dart';
+import '../../../common/storage/storage_service.dart';
+import '../data/auth_models.dart';
+
+final authProvider =
+ StateNotifierProvider>(
+ (ref) => AuthController(),
+ );
+
+class AuthController extends StateNotifier> {
+ AuthController() : super(const AsyncLoading()) {
+ _bootstrap();
+ }
+
+ Future _bootstrap() async {
+ final token = await loadJwt();
+ if (token == null || token.isEmpty) {
+ state = const AsyncData(GuestAuthState());
+ return;
+ }
+ final phone = StorageService.to.getString(storageJinzhiMockLoggedIn) ?? '';
+ state = AsyncData(
+ LoggedInAuthState(
+ user: JinzhiUser(
+ phone: phone.isEmpty ? '13800000000' : phone,
+ nickname: '金值用户',
+ createdAt: DateTime(2026, 6, 18),
+ ),
+ token: token,
+ ),
+ );
+ }
+
+ Future sendCode(String phone) async {
+ await Future.delayed(const Duration(milliseconds: 200));
+ }
+
+ Future verifyCode(String phone, String code) async {
+ final token = 'mock-jinzhi-token-$phone-$code';
+ await saveJwt(token);
+ await StorageService.to.setString(storageJinzhiMockLoggedIn, phone);
+ state = AsyncData(
+ LoggedInAuthState(
+ user: JinzhiUser(
+ phone: phone,
+ nickname: '金值用户',
+ createdAt: DateTime.now(),
+ ),
+ token: token,
+ ),
+ );
+ }
+
+ Future logout() async {
+ await clearJwt();
+ await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
+ state = const AsyncData(GuestAuthState());
+ }
+
+ Future deleteAccount([String? code]) async {
+ await logout();
+ }
+
+ bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
+}
diff --git a/lib/features/auth/data/auth_models.dart b/lib/features/auth/data/auth_models.dart
new file mode 100644
index 0000000..d5ffda4
--- /dev/null
+++ b/lib/features/auth/data/auth_models.dart
@@ -0,0 +1,26 @@
+class JinzhiUser {
+ const JinzhiUser({
+ required this.phone,
+ required this.nickname,
+ required this.createdAt,
+ });
+
+ final String phone;
+ final String nickname;
+ final DateTime createdAt;
+}
+
+sealed class AuthState {
+ const AuthState();
+}
+
+class GuestAuthState extends AuthState {
+ const GuestAuthState();
+}
+
+class LoggedInAuthState extends AuthState {
+ const LoggedInAuthState({required this.user, required this.token});
+
+ final JinzhiUser user;
+ final String token;
+}
diff --git a/lib/features/auth/presentation/delete_account_page.dart b/lib/features/auth/presentation/delete_account_page.dart
new file mode 100644
index 0000000..7267826
--- /dev/null
+++ b/lib/features/auth/presentation/delete_account_page.dart
@@ -0,0 +1,916 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_remix/flutter_remix.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../../../common/network/api_error_message.dart';
+import '../../../common/router/app_router.dart';
+import '../../../common/theme/app_text.dart';
+import '../../../common/theme/jinzhi_theme.dart';
+import '../../../common/theme/spacing.dart';
+import '../../../common/widgets/circle_back_button.dart';
+import '../application/auth_controller.dart';
+import '../data/auth_models.dart';
+
+class DeleteAccountPage extends ConsumerWidget {
+ const DeleteAccountPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final authAsync = ref.watch(authProvider);
+ final cs = ShadTheme.of(context).colorScheme;
+
+ return authAsync.when(
+ loading: () => Scaffold(
+ backgroundColor: cs.background,
+ body: const Center(child: CircularProgressIndicator()),
+ ),
+ error: (e, _) => Scaffold(
+ backgroundColor: cs.background,
+ body: Center(child: Text('加载失败:$e')),
+ ),
+ data: (authState) {
+ if (authState is! LoggedInAuthState) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!context.mounted) return;
+ context.go(
+ '${AppRoutes.login}?next=${Uri.encodeComponent(AppRoutes.deleteAccount)}',
+ );
+ });
+ return Scaffold(
+ backgroundColor: cs.background,
+ body: const Center(child: CircularProgressIndicator()),
+ );
+ }
+
+ return Scaffold(
+ backgroundColor: cs.background,
+ appBar: AppBar(
+ backgroundColor: cs.background,
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ centerTitle: true,
+ title: Text(
+ '注销账号',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ leading: CircleBackButton(
+ onTap: () {
+ if (context.canPop()) {
+ context.pop();
+ } else {
+ context.go(AppRoutes.settings);
+ }
+ },
+ ),
+ ),
+ body: SafeArea(
+ top: false,
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(
+ Spacing.page,
+ 8,
+ Spacing.page,
+ 28,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const _WarnCard(),
+ const SizedBox(height: 28),
+ Text(
+ '注销前请确认',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 14),
+ const _DeleteCheckList(),
+ const SizedBox(height: 28),
+ _DangerButton(
+ label: '开始注销',
+ onPressed: () =>
+ _showVerifyDialog(context, ref, authState.user.phone),
+ ),
+ const SizedBox(height: 14),
+ Text(
+ '注销后,账号资料与历史内容将无法恢复。',
+ textAlign: TextAlign.center,
+ style: AppText.meta.copyWith(
+ fontSize: 11.5,
+ color: cs.mutedForeground,
+ height: 1.6,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ Future _showVerifyDialog(
+ BuildContext context,
+ WidgetRef ref,
+ String phone,
+ ) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ final completed = await showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ isDismissible: false,
+ enableDrag: false,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (_) => _VerifySheet(phone: phone),
+ );
+ if (!context.mounted || completed != true) return;
+ context.go(AppRoutes.deleteAccountCompleted);
+ }
+}
+
+String _maskPhone(String phone) {
+ if (phone.length < 7) return phone;
+ return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
+}
+
+/// 危险主按钮(demo `.da-btn`:destructive 实底 pill、52 高、16/600)。
+class _DangerButton extends StatelessWidget {
+ const _DangerButton({required this.label, required this.onPressed});
+
+ final String label;
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return GestureDetector(
+ onTap: onPressed,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.destructive,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: cs.destructiveForeground,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// sheet 内操作按钮(demo `.lo-btn`:52 高、radius 12、16/600;
+/// primary=lime / danger=destructive / cancel=secondary+描边)。
+enum _SheetBtnStyle { primary, danger, cancel }
+
+class _SheetActionButton extends StatelessWidget {
+ const _SheetActionButton({
+ required this.label,
+ required this.style,
+ required this.onTap,
+ this.enabled = true,
+ });
+
+ final String label;
+ final _SheetBtnStyle style;
+ final VoidCallback onTap;
+ final bool enabled;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final Color bg;
+ final Color fg;
+ Border? border;
+ switch (style) {
+ case _SheetBtnStyle.primary:
+ bg = cs.primary;
+ fg = cs.primaryForeground;
+ case _SheetBtnStyle.danger:
+ bg = cs.destructive;
+ fg = cs.destructiveForeground;
+ case _SheetBtnStyle.cancel:
+ bg = cs.secondary;
+ fg = cs.foreground;
+ border = Border.all(color: cs.border);
+ }
+ return GestureDetector(
+ onTap: enabled ? onTap : null,
+ behavior: HitTestBehavior.opaque,
+ child: Opacity(
+ opacity: enabled ? 1 : 0.55,
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: bg,
+ borderRadius: BorderRadius.circular(12),
+ border: border,
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: fg,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _PinInput extends StatefulWidget {
+ const _PinInput({
+ required this.controller,
+ required this.onCompleted,
+ this.enabled = true,
+ });
+
+ final TextEditingController controller;
+ final ValueChanged onCompleted;
+ final bool enabled;
+
+ static const length = 6;
+
+ @override
+ State<_PinInput> createState() => _PinInputState();
+}
+
+class _PinInputState extends State<_PinInput> {
+ final _focusNode = FocusNode();
+ String _lastText = '';
+
+ @override
+ void initState() {
+ super.initState();
+ widget.controller.addListener(_onChanged);
+ }
+
+ @override
+ void didUpdateWidget(covariant _PinInput oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.enabled && !oldWidget.enabled) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) _focusNode.requestFocus();
+ });
+ }
+ }
+
+ @override
+ void dispose() {
+ widget.controller.removeListener(_onChanged);
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ void _onChanged() {
+ final value = widget.controller.text;
+ if (value == _lastText) return;
+ _lastText = value;
+ setState(() {});
+ if (value.length == _PinInput.length) {
+ FocusScope.of(context).unfocus();
+ widget.onCompleted(value);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final filled = widget.controller.text;
+ return GestureDetector(
+ onTap: widget.enabled ? () => _focusNode.requestFocus() : null,
+ behavior: HitTestBehavior.opaque,
+ child: SizedBox(
+ height: 56,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Row(
+ children: List.generate(_PinInput.length, (i) {
+ final hasChar = i < filled.length;
+ final isFocus = i == filled.length && widget.enabled;
+ return Expanded(
+ child: Padding(
+ padding: EdgeInsets.only(
+ right: i == _PinInput.length - 1 ? 0 : 12,
+ ),
+ child: _PinCell(
+ char: hasChar ? filled[i] : '',
+ focused: isFocus,
+ cs: cs,
+ ),
+ ),
+ );
+ }),
+ ),
+ Positioned.fill(
+ child: Opacity(
+ opacity: 0,
+ child: TextField(
+ controller: widget.controller,
+ focusNode: _focusNode,
+ enabled: widget.enabled,
+ keyboardType: TextInputType.number,
+ inputFormatters: [
+ FilteringTextInputFormatter.digitsOnly,
+ LengthLimitingTextInputFormatter(_PinInput.length),
+ ],
+ showCursor: false,
+ autofocus: false,
+ decoration: const InputDecoration(
+ border: InputBorder.none,
+ counterText: '',
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _PinCell extends StatelessWidget {
+ const _PinCell({required this.char, required this.focused, required this.cs});
+
+ final String char;
+ final bool focused;
+ final ShadColorScheme cs;
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedContainer(
+ duration: const Duration(milliseconds: 140),
+ height: 56,
+ decoration: BoxDecoration(
+ color: cs.background,
+ borderRadius: BorderRadius.circular(Spacing.radiusBtn),
+ border: Border.all(
+ color: focused ? cs.primary : cs.border,
+ width: focused ? 1.4 : 1,
+ ),
+ ),
+ alignment: Alignment.center,
+ child: Text(
+ char,
+ style: AppText.sectionTitle.copyWith(
+ fontSize: 19,
+ color: cs.foreground,
+ height: 1,
+ ),
+ ),
+ );
+ }
+}
+
+class _WarnCard extends StatelessWidget {
+ const _WarnCard();
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final b = ShadTheme.of(context).brightness;
+ return Container(
+ padding: const EdgeInsets.all(Spacing.cardPadding),
+ decoration: BoxDecoration(
+ color: destructiveSoft(b),
+ border: Border.all(color: destructiveSoftBorder(b)),
+ borderRadius: BorderRadius.circular(Spacing.radiusCard),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 42,
+ height: 42,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: cs.destructive,
+ ),
+ child: Icon(
+ FlutterRemix.alert_fill,
+ size: 21,
+ color: cs.destructiveForeground,
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text.rich(
+ TextSpan(
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ height: 1.3,
+ color: cs.foreground,
+ ),
+ children: [
+ const TextSpan(text: '此操作'),
+ TextSpan(
+ text: '不可恢复',
+ style: TextStyle(color: cs.destructive),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ '注销后,账号、阅读历史、个人数据将永久失效且无法找回。',
+ style: TextStyle(
+ fontSize: 13,
+ height: 1.6,
+ color: cs.mutedForeground,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _DeleteCheckList extends StatelessWidget {
+ const _DeleteCheckList();
+
+ static const _items = [
+ ('账户处于安全状态', '您的账户处于安全状态,无异常登录。'),
+ ('账户信息将被清空', '您的个人账户相关信息将被清空且无法恢复。'),
+ ('浏览记录将被清空', '您最近阅读的文章将被清空且无法恢复。'),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Container(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(Spacing.radiusCard),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: Column(
+ children: [
+ for (var i = 0; i < _items.length; i++) ...[
+ if (i > 0) Divider(height: 1, thickness: 1, color: cs.border),
+ _DeleteCheckItem(
+ number: i + 1,
+ title: _items[i].$1,
+ desc: _items[i].$2,
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _DeleteCheckItem extends StatelessWidget {
+ const _DeleteCheckItem({
+ required this.number,
+ required this.title,
+ required this.desc,
+ });
+
+ final int number;
+ final String title;
+ final String desc;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 24,
+ height: 24,
+ margin: const EdgeInsets.only(top: 1),
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.accent,
+ borderRadius: BorderRadius.circular(7),
+ ),
+ child: Text(
+ '$number',
+ style: AppText.meta.copyWith(
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: cs.accentForeground,
+ height: 1,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ color: cs.foreground,
+ height: 1.4,
+ ),
+ ),
+ const SizedBox(height: 3),
+ Text(
+ desc,
+ style: TextStyle(
+ fontSize: 13,
+ color: cs.mutedForeground,
+ height: 1.55,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// 注销短信验证底部 sheet(对齐 demo `deleteVerifySheet`):
+/// 未发送(说明 + 发送验证码[primary]/取消)→ 已发送(OTP 6 格 + 重发倒计时 +
+/// 确认注销[danger]/取消)→ 提交中(spinner)。
+class _VerifySheet extends ConsumerStatefulWidget {
+ const _VerifySheet({required this.phone});
+
+ final String phone;
+
+ @override
+ ConsumerState<_VerifySheet> createState() => _VerifySheetState();
+}
+
+class _VerifySheetState extends ConsumerState<_VerifySheet> {
+ final _otpController = TextEditingController();
+ Timer? _timer;
+ int _countdown = 0;
+ bool _codeSent = false;
+ bool _sending = false;
+ bool _submitting = false;
+ String? _error;
+
+ @override
+ void dispose() {
+ _otpController.dispose();
+ _timer?.cancel();
+ super.dispose();
+ }
+
+ void _startCountdown() {
+ _timer?.cancel();
+ setState(() => _countdown = 60);
+ _timer = Timer.periodic(const Duration(seconds: 1), (t) {
+ if (!mounted) {
+ t.cancel();
+ return;
+ }
+ if (_countdown <= 1) {
+ t.cancel();
+ setState(() => _countdown = 0);
+ } else {
+ setState(() => _countdown -= 1);
+ }
+ });
+ }
+
+ Future _sendCode() async {
+ if (_sending || _countdown > 0) return;
+ setState(() {
+ _sending = true;
+ _error = null;
+ });
+ try {
+ await ref.read(authProvider.notifier).sendCode(widget.phone);
+ if (!mounted) return;
+ setState(() {
+ _codeSent = true;
+ _otpController.clear();
+ });
+ _startCountdown();
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _error = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试');
+ });
+ } finally {
+ if (mounted) setState(() => _sending = false);
+ }
+ }
+
+ Future _confirm() async {
+ final code = _otpController.text.trim().replaceAll(' ', '');
+ if (code.length != 6) {
+ setState(() => _error = '请输入6位验证码');
+ return;
+ }
+ setState(() {
+ _submitting = true;
+ _error = null;
+ });
+ try {
+ await ref.read(authProvider.notifier).deleteAccount(code);
+ if (!mounted) return;
+ Navigator.of(context).pop(true);
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _error = kratosDisplayMessage(e, fallback: '注销失败,请重试');
+ });
+ } finally {
+ if (mounted) setState(() => _submitting = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ // 键盘弹出时 sheet 跟随上移
+ padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // grip
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '短信验证',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ if (_submitting)
+ ..._buildSubmitting(cs)
+ else if (!_codeSent)
+ ..._buildSendStep(cs)
+ else
+ ..._buildCodeStep(cs),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ List _buildSubmitting(ShadColorScheme cs) {
+ return [
+ Text(
+ '正在清除账户数据,请稍候',
+ style: TextStyle(
+ fontSize: 13,
+ height: 1.55,
+ color: cs.mutedForeground,
+ ),
+ ),
+ const SizedBox(height: 28),
+ Center(
+ child: SizedBox(
+ width: 32,
+ height: 32,
+ child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary),
+ ),
+ ),
+ const SizedBox(height: 28),
+ ];
+ }
+
+ List _buildSendStep(ShadColorScheme cs) {
+ return [
+ Text(
+ '为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
+ style: TextStyle(
+ fontSize: 13,
+ height: 1.55,
+ color: cs.mutedForeground,
+ ),
+ ),
+ if (_error != null) ...[
+ const SizedBox(height: 10),
+ Text(_error!, style: TextStyle(fontSize: 13, color: cs.destructive)),
+ ],
+ const SizedBox(height: 22),
+ _SheetActionButton(
+ label: _sending ? '发送中…' : '发送验证码',
+ style: _SheetBtnStyle.primary,
+ enabled: !_sending,
+ onTap: _sendCode,
+ ),
+ const SizedBox(height: 12),
+ _SheetActionButton(
+ label: '取消',
+ style: _SheetBtnStyle.cancel,
+ enabled: !_sending,
+ onTap: () => Navigator.of(context).pop(false),
+ ),
+ ];
+ }
+
+ List _buildCodeStep(ShadColorScheme cs) {
+ return [
+ Text(
+ '验证码已发送至 ${_maskPhone(widget.phone)}',
+ style: TextStyle(
+ fontSize: 13,
+ height: 1.55,
+ color: cs.mutedForeground,
+ ),
+ ),
+ const SizedBox(height: 20),
+ _PinInput(
+ controller: _otpController,
+ enabled: !_submitting,
+ onCompleted: (_) => _confirm(),
+ ),
+ if (_error != null) ...[
+ const SizedBox(height: 10),
+ Text(_error!, style: TextStyle(fontSize: 13, color: cs.destructive)),
+ ],
+ const SizedBox(height: 14),
+ Center(
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: _countdown > 0 || _sending ? null : _sendCode,
+ child: Text(
+ _countdown > 0 ? '$_countdown s 后可重新发送' : '重新发送',
+ style: TextStyle(
+ fontSize: 13,
+ color: _countdown > 0 ? cs.mutedForeground : cs.primary,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 18),
+ _SheetActionButton(
+ label: '确认注销',
+ style: _SheetBtnStyle.danger,
+ enabled: !_submitting,
+ onTap: _confirm,
+ ),
+ const SizedBox(height: 12),
+ _SheetActionButton(
+ label: '取消',
+ style: _SheetBtnStyle.cancel,
+ enabled: !_submitting,
+ onTap: () => Navigator.of(context).pop(false),
+ ),
+ ];
+ }
+}
+
+class DeleteAccountSuccessPage extends ConsumerWidget {
+ const DeleteAccountSuccessPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Scaffold(
+ backgroundColor: cs.background,
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(
+ Spacing.page,
+ 72,
+ Spacing.page,
+ 28,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Center(
+ child: Container(
+ width: 72,
+ height: 72,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: cs.primary,
+ ),
+ child: Icon(
+ FlutterRemix.check_line,
+ size: 34,
+ color: cs.primaryForeground,
+ ),
+ ),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ '账户已注销',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.w700,
+ height: 1.4,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ '您的账号及相关数据已清除完毕\n感谢您曾使用研听',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 13,
+ color: cs.mutedForeground,
+ height: 1.7,
+ ),
+ ),
+ const SizedBox(height: 48),
+ GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () async {
+ await ref.read(authProvider.notifier).logout();
+ if (context.mounted) context.go(AppRoutes.profile);
+ },
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.primary,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ child: Text(
+ '返回首页',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: cs.primaryForeground,
+ ),
+ ),
+ ),
+ ),
+ const Spacer(),
+ Text(
+ '如未来需再次使用,可重新注册账号\n但原历史数据无法迁移恢复',
+ textAlign: TextAlign.center,
+ style: AppText.meta.copyWith(
+ fontSize: 11,
+ color: cs.mutedForeground,
+ height: 1.7,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart
new file mode 100644
index 0000000..4243850
--- /dev/null
+++ b/lib/features/auth/presentation/login_page.dart
@@ -0,0 +1,702 @@
+import 'dart:async';
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_remix/flutter_remix.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+import 'package:url_launcher/url_launcher_string.dart';
+
+import '../../../common/config/app_h5_urls.dart';
+import '../../../common/network/api_error_message.dart';
+import '../../../common/router/app_router.dart';
+import '../../../common/theme/spacing.dart';
+import '../../../common/widgets/circle_back_button.dart';
+import '../application/auth_controller.dart';
+
+class LoginPage extends HookConsumerWidget {
+ const LoginPage({super.key, this.next});
+ final String? next;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final phone = useTextEditingController();
+ final codeCtrl = useTextEditingController();
+ final codeFocus = useFocusNode();
+ final onCodeStep = useState(false);
+ final agreed = useState(false);
+ final sending = useState(false);
+ final verifying = useState(false);
+ final countdown = useState(0);
+ final error = useState(null);
+ final timer = useRef(null);
+ useListenable(phone);
+ useListenable(codeCtrl);
+ useEffect(
+ () =>
+ () => timer.value?.cancel(),
+ const [],
+ );
+
+ String phoneDigits() => phone.text.replaceAll(RegExp(r'\D'), '');
+
+ Future returnAfterLogin() async {
+ final target = next?.trim();
+ final router = GoRouter.of(context);
+ if (target == null || target.isEmpty) {
+ router.go(AppRoutes.profile);
+ return;
+ }
+ router.go(target);
+ }
+
+ void startCountdown() {
+ countdown.value = 60;
+ timer.value?.cancel();
+ timer.value = Timer.periodic(const Duration(seconds: 1), (t) {
+ if (countdown.value <= 1) {
+ t.cancel();
+ countdown.value = 0;
+ } else {
+ countdown.value--;
+ }
+ });
+ }
+
+ Future doSend() async {
+ if (sending.value || countdown.value > 0) return;
+ if (phoneDigits().length != 11) {
+ error.value = '请输入正确的手机号';
+ return;
+ }
+ sending.value = true;
+ error.value = null;
+ try {
+ await ref.read(authProvider.notifier).sendCode(phoneDigits());
+ startCountdown();
+ onCodeStep.value = true;
+ codeFocus.requestFocus();
+ } catch (e) {
+ error.value = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试');
+ } finally {
+ sending.value = false;
+ }
+ }
+
+ Future doVerify() async {
+ if (verifying.value) return;
+ final code = codeCtrl.text.trim();
+ if (code.length != 6) {
+ error.value = '请输入 6 位验证码';
+ return;
+ }
+ verifying.value = true;
+ error.value = null;
+ try {
+ await ref.read(authProvider.notifier).verifyCode(phoneDigits(), code);
+ if (!context.mounted) return;
+ await returnAfterLogin();
+ } catch (e) {
+ error.value = kratosDisplayMessage(e, fallback: '验证失败,请重试');
+ } finally {
+ verifying.value = false;
+ }
+ }
+
+ void onBack() {
+ if (onCodeStep.value) {
+ onCodeStep.value = false;
+ error.value = null;
+ } else if (context.canPop()) {
+ context.pop();
+ } else {
+ context.go(AppRoutes.profile);
+ }
+ }
+
+ void showPrivacySheet(VoidCallback onAgreed) {
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.6),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(22, 28, 22, 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ '请阅读并同意以下条款',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 14),
+ Text.rich(
+ textAlign: TextAlign.center,
+ _policySpan(context, cs, fontSize: 12.5),
+ ),
+ const SizedBox(height: 22),
+ _PrimaryButton(
+ label: '同意并继续',
+ enabled: true,
+ onTap: () {
+ Navigator.of(ctx).pop();
+ onAgreed();
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ void onSendTap() {
+ if (sending.value || countdown.value > 0) return;
+ if (phoneDigits().length != 11) {
+ error.value = '请输入正确的手机号';
+ return;
+ }
+ error.value = null;
+ if (!agreed.value) {
+ showPrivacySheet(() {
+ agreed.value = true;
+ doSend();
+ });
+ return;
+ }
+ doSend();
+ }
+
+ final phoneReady = phoneDigits().length == 11;
+ final codeReady = codeCtrl.text.trim().length == 6;
+
+ return Scaffold(
+ backgroundColor: cs.background,
+ appBar: AppBar(
+ backgroundColor: cs.background,
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ automaticallyImplyLeading: false,
+ toolbarHeight: 46,
+ titleSpacing: 0,
+ title: CircleBackButton(onTap: onBack),
+ ),
+ body: SafeArea(
+ top: false,
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(22, 8, 22, 28),
+ child: onCodeStep.value
+ ? _CodeStep(
+ cs: cs,
+ phone: phoneDigits(),
+ codeCtrl: codeCtrl,
+ codeFocus: codeFocus,
+ countdown: countdown.value,
+ verifying: verifying.value,
+ codeReady: codeReady,
+ error: error.value,
+ onVerify: doVerify,
+ onResend: doSend,
+ )
+ : _PhoneStep(
+ cs: cs,
+ phone: phone,
+ sending: sending.value,
+ countdown: countdown.value,
+ phoneReady: phoneReady,
+ error: error.value,
+ agreed: agreed.value,
+ onToggleAgreed: () => agreed.value = !agreed.value,
+ onSend: onSendTap,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _PhoneStep extends StatelessWidget {
+ const _PhoneStep({
+ required this.cs,
+ required this.phone,
+ required this.sending,
+ required this.countdown,
+ required this.phoneReady,
+ required this.agreed,
+ required this.onToggleAgreed,
+ required this.error,
+ required this.onSend,
+ });
+
+ final ShadColorScheme cs;
+ final TextEditingController phone;
+ final bool sending;
+ final int countdown;
+ final bool phoneReady;
+ final bool agreed;
+ final VoidCallback onToggleAgreed;
+ final String? error;
+ final VoidCallback onSend;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _SheetTitle('手机号登录', cs),
+ const SizedBox(height: 8),
+ _SheetSub('未注册的手机号验证后将自动创建研听账号。', cs),
+ const SizedBox(height: 18),
+ Row(
+ children: [
+ SizedBox(
+ height: 50,
+ child: Center(
+ child: Text(
+ '+86',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: cs.foreground,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: _BoxedField(controller: phone, hint: '请输入手机号', cs: cs),
+ ),
+ ],
+ ),
+ if (error != null) ...[
+ const SizedBox(height: 10),
+ _ErrorText(error!, cs),
+ ],
+ const SizedBox(height: 18),
+ _PrimaryButton(
+ label: countdown > 0 ? '$countdown s 后可重新发送' : '获取验证码',
+ enabled: !sending && countdown == 0,
+ loading: sending,
+ dim: !phoneReady,
+ onTap: onSend,
+ ),
+ const SizedBox(height: 18),
+ _AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
+ ],
+ );
+ }
+}
+
+class _CodeStep extends StatelessWidget {
+ const _CodeStep({
+ required this.cs,
+ required this.phone,
+ required this.codeCtrl,
+ required this.codeFocus,
+ required this.countdown,
+ required this.verifying,
+ required this.codeReady,
+ required this.error,
+ required this.onVerify,
+ required this.onResend,
+ });
+
+ final ShadColorScheme cs;
+ final String phone;
+ final TextEditingController codeCtrl;
+ final FocusNode codeFocus;
+ final int countdown;
+ final bool verifying;
+ final bool codeReady;
+ final String? error;
+ final VoidCallback onVerify;
+ final VoidCallback onResend;
+
+ String get _masked => phone.length == 11
+ ? '${phone.substring(0, 3)}****${phone.substring(7)}'
+ : phone;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _SheetTitle('输入验证码', cs),
+ const SizedBox(height: 8),
+ _SheetSub('验证码已发送至 +86 $_masked', cs),
+ const SizedBox(height: 20),
+ _OtpInput(controller: codeCtrl, focusNode: codeFocus, cs: cs),
+ if (error != null) ...[
+ const SizedBox(height: 12),
+ _ErrorText(error!, cs),
+ ],
+ const SizedBox(height: 18),
+ _PrimaryButton(
+ label: '验证并登录',
+ enabled: !verifying,
+ loading: verifying,
+ dim: !codeReady,
+ onTap: onVerify,
+ ),
+ const SizedBox(height: 16),
+ Center(
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: countdown > 0 ? null : onResend,
+ child: Text(
+ countdown > 0 ? '$countdown s 后可重新发送' : '重新发送',
+ style: TextStyle(
+ fontSize: 13,
+ color: countdown > 0 ? cs.mutedForeground : cs.primary,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _SheetTitle extends StatelessWidget {
+ const _SheetTitle(this.text, this.cs);
+ final String text;
+ final ShadColorScheme cs;
+ @override
+ Widget build(BuildContext context) => Text(
+ text,
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ );
+}
+
+class _SheetSub extends StatelessWidget {
+ const _SheetSub(this.text, this.cs);
+ final String text;
+ final ShadColorScheme cs;
+ @override
+ Widget build(BuildContext context) => Text(
+ text,
+ style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
+ );
+}
+
+class _ErrorText extends StatelessWidget {
+ const _ErrorText(this.text, this.cs);
+ final String text;
+ final ShadColorScheme cs;
+ @override
+ Widget build(BuildContext context) =>
+ Text(text, style: TextStyle(fontSize: 13, color: cs.destructive));
+}
+
+class _BoxedField extends StatelessWidget {
+ const _BoxedField({
+ required this.controller,
+ required this.hint,
+ required this.cs,
+ });
+ final TextEditingController controller;
+ final String hint;
+ final ShadColorScheme cs;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ height: 50,
+ alignment: Alignment.center,
+ padding: const EdgeInsets.symmetric(horizontal: 14),
+ decoration: BoxDecoration(
+ color: cs.background,
+ borderRadius: BorderRadius.circular(Spacing.radiusBtn),
+ border: Border.all(color: cs.input, width: 1),
+ ),
+ child: TextField(
+ controller: controller,
+ keyboardType: TextInputType.phone,
+ inputFormatters: [_PhoneNumberFormatter()],
+ style: TextStyle(
+ fontSize: 16,
+ color: cs.foreground,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ ),
+ cursorColor: cs.primary,
+ decoration: InputDecoration(
+ isCollapsed: true,
+ border: InputBorder.none,
+ hintText: hint,
+ hintStyle: TextStyle(fontSize: 15, color: cs.mutedForeground),
+ ),
+ ),
+ );
+ }
+}
+
+class _PhoneNumberFormatter extends TextInputFormatter {
+ @override
+ TextEditingValue formatEditUpdate(
+ TextEditingValue oldValue,
+ TextEditingValue newValue,
+ ) {
+ var d = newValue.text.replaceAll(RegExp(r'\D'), '');
+ if (d.length > 11) d = d.substring(0, 11);
+ final b = StringBuffer();
+ for (var i = 0; i < d.length; i++) {
+ if (i == 3 || i == 7) b.write(' ');
+ b.write(d[i]);
+ }
+ final s = b.toString();
+ return TextEditingValue(
+ text: s,
+ selection: TextSelection.collapsed(offset: s.length),
+ );
+ }
+}
+
+class _OtpInput extends StatelessWidget {
+ const _OtpInput({
+ required this.controller,
+ required this.focusNode,
+ required this.cs,
+ });
+ final TextEditingController controller;
+ final FocusNode focusNode;
+ final ShadColorScheme cs;
+
+ @override
+ Widget build(BuildContext context) {
+ final code = controller.text;
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: focusNode.requestFocus,
+ child: SizedBox(
+ height: 54,
+ child: Stack(
+ children: [
+ Row(
+ children: [
+ for (var i = 0; i < 6; i++) ...[
+ if (i > 0) const SizedBox(width: 8),
+ Expanded(
+ child: _OtpCell(
+ digit: i < code.length ? code[i] : '',
+ filled: i < code.length,
+ cs: cs,
+ ),
+ ),
+ ],
+ ],
+ ),
+ Positioned.fill(
+ child: TextField(
+ controller: controller,
+ focusNode: focusNode,
+ keyboardType: TextInputType.number,
+ maxLength: 6,
+ showCursor: false,
+ autofocus: true,
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ style: const TextStyle(color: Colors.transparent),
+ decoration: const InputDecoration(
+ counterText: '',
+ border: InputBorder.none,
+ enabledBorder: InputBorder.none,
+ focusedBorder: InputBorder.none,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _OtpCell extends StatelessWidget {
+ const _OtpCell({required this.digit, required this.filled, required this.cs});
+ final String digit;
+ final bool filled;
+ final ShadColorScheme cs;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.background,
+ borderRadius: BorderRadius.circular(Spacing.radiusBtn),
+ border: Border.all(color: filled ? cs.foreground : cs.input, width: 1),
+ ),
+ child: Text(
+ digit,
+ style: TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w600,
+ color: cs.foreground,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ ),
+ ),
+ );
+ }
+}
+
+class _AgreeRow extends StatelessWidget {
+ const _AgreeRow({
+ required this.agreed,
+ required this.onToggle,
+ required this.cs,
+ });
+ final bool agreed;
+ final VoidCallback onToggle;
+ final ShadColorScheme cs;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onToggle,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 18,
+ height: 18,
+ margin: const EdgeInsets.only(top: 1),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(Spacing.radiusBadge),
+ border: Border.all(
+ color: agreed ? cs.primary : cs.border,
+ width: 1.2,
+ ),
+ color: agreed ? cs.primary : Colors.transparent,
+ ),
+ child: agreed
+ ? Icon(
+ FlutterRemix.check_line,
+ size: 13,
+ color: cs.primaryForeground,
+ )
+ : null,
+ ),
+ const SizedBox(width: 8),
+ Expanded(child: Text.rich(_policySpan(context, cs))),
+ ],
+ ),
+ );
+ }
+}
+
+TextSpan _policySpan(
+ BuildContext context,
+ ShadColorScheme cs, {
+ double fontSize = 11.5,
+}) {
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+ final privacyUrl = AppH5UrlsHelper.buildUrlWithNight(
+ AppH5Urls.privacyUrl,
+ isDark,
+ );
+ final protocolUrl = AppH5UrlsHelper.buildUrlWithNight(
+ AppH5Urls.userProtocolUrl,
+ isDark,
+ );
+
+ TextSpan link(String text, String url) => TextSpan(
+ text: text,
+ style: TextStyle(
+ color: cs.foreground,
+ fontSize: fontSize,
+ decoration: TextDecoration.underline,
+ decorationColor: cs.foreground,
+ ),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () => launchUrlString(
+ AppH5UrlsHelper.withCacheBuster(url),
+ mode: LaunchMode.inAppBrowserView,
+ ),
+ );
+
+ return TextSpan(
+ style: TextStyle(
+ fontSize: fontSize,
+ height: 1.6,
+ color: cs.mutedForeground,
+ ),
+ children: [
+ const TextSpan(text: '已阅读并同意 '),
+ link('《用户协议》', protocolUrl),
+ const TextSpan(text: ' 与 '),
+ link('《隐私政策》', privacyUrl),
+ ],
+ );
+}
+
+class _PrimaryButton extends StatelessWidget {
+ const _PrimaryButton({
+ required this.label,
+ required this.enabled,
+ required this.onTap,
+ this.loading = false,
+ this.dim = false,
+ });
+ final String label;
+ final bool enabled;
+ final bool loading;
+ final bool dim;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final active = enabled && !dim;
+ return SizedBox(
+ width: double.infinity,
+ child: GestureDetector(
+ onTap: enabled ? onTap : null,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 15),
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: active ? cs.primary : cs.muted,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ child: loading
+ ? SizedBox(
+ width: 18,
+ height: 18,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: active ? cs.primaryForeground : cs.mutedForeground,
+ ),
+ )
+ : Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: active ? cs.primaryForeground : cs.mutedForeground,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/debug/presentation/debug_info_page.dart b/lib/features/debug/presentation/debug_info_page.dart
new file mode 100644
index 0000000..0082f13
--- /dev/null
+++ b/lib/features/debug/presentation/debug_info_page.dart
@@ -0,0 +1,176 @@
+import 'package:device_info_plus/device_info_plus.dart';
+import 'package:dio/dio.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+
+import '../../../common/config/shorebird_service.dart';
+import '../../../common/network/auth_interceptor.dart';
+import '../../../common/network/dio_provider.dart';
+import '../../../common/router/app_router.dart';
+import '../../../common/services/device_header_service.dart';
+import '../../../common/storage/storage_keys.dart';
+import '../../../common/storage/storage_service.dart';
+import '../../../common/theme/theme_controller.dart';
+
+class DebugInfoPage extends ConsumerWidget {
+ const DebugInfoPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final appConfig = ref.watch(appConfigProvider);
+ final dio = ref.watch(dioProvider);
+ final themeMode = ref.watch(themeModeControllerProvider);
+
+ return Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ onPressed: () {
+ if (context.canPop()) {
+ context.pop();
+ } else {
+ context.go(AppRoutes.debug);
+ }
+ },
+ icon: const Icon(Icons.arrow_back_ios_new),
+ ),
+ title: const Text('Info'),
+ ),
+ body: FutureBuilder<_DebugInfoSnapshot>(
+ future: _loadSnapshot(
+ apiBaseUrl: appConfig.apiBaseUrl,
+ themeMode: themeMode.name,
+ dio: dio,
+ ),
+ builder: (context, snapshot) {
+ final text = snapshot.data?.text ?? 'loading...';
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: SelectableText(text, style: const TextStyle(fontSize: 12)),
+ );
+ },
+ ),
+ );
+ }
+}
+
+Future<_DebugInfoSnapshot> _loadSnapshot({
+ required String apiBaseUrl,
+ required String themeMode,
+ required Dio dio,
+}) async {
+ await DeviceHeaderService.to.ensureInitialized();
+ final packageInfo = await PackageInfo.fromPlatform();
+ final deviceInfo = await _readDeviceInfo();
+ final storage = StorageService.to;
+ final jwt = await loadJwt();
+ final commonHeaders = DeviceHeaderService.to.commonHeaders();
+ final platformHeaders = DeviceHeaderService.to.platformIdentityHeaders();
+ final headers = {
+ ...commonHeaders,
+ ...platformHeaders,
+ ...dio.options.headers,
+ };
+ final gitVersion = const String.fromEnvironment(
+ 'GITVERSION',
+ defaultValue: 'unknown',
+ );
+ final patch = ShorebirdService.instance.currentPatch;
+ final jwtLine = jwt?.isNotEmpty == true ? 'Bearer ' : '';
+
+ final text = StringBuffer()
+ ..writeln('[build]')
+ ..writeln('app_name: ${packageInfo.appName}')
+ ..writeln('mode: ${kReleaseMode ? 'release' : 'debug'}')
+ ..writeln('package_name: ${packageInfo.packageName}')
+ ..writeln('version: ${packageInfo.version}')
+ ..writeln('build_number: ${packageInfo.buildNumber}')
+ ..writeln('theme_mode: $themeMode')
+ ..writeln('platform: ${defaultTargetPlatform.name}')
+ ..writeln('is_web: $kIsWeb')
+ ..writeln()
+ ..writeln('[environment]')
+ ..writeln('api_base: $apiBaseUrl')
+ ..writeln(
+ 'connect_timeout_ms: ${dio.options.connectTimeout?.inMilliseconds}',
+ )
+ ..writeln(
+ 'receive_timeout_ms: ${dio.options.receiveTimeout?.inMilliseconds}',
+ )
+ ..writeln('response_type: ${dio.options.responseType.name}')
+ ..writeln('git_version: $gitVersion')
+ ..writeln()
+ ..writeln('[storage]')
+ ..writeln('privacy_agreed: ${storage.getBool(storagePrivacyAgreed)}')
+ ..writeln('ios_startup_seen: ${storage.getBool(storageIosStartupSeen)}')
+ ..writeln('device_id: ${DeviceHeaderService.to.deviceId}')
+ ..writeln('android_id: ${storage.getString(storageAndroidId) ?? ''}')
+ ..writeln('device_token: ${storage.getString(storageDeviceToken) ?? ''}')
+ ..writeln('android_oaid: ${storage.getString(storageAndroidOaid) ?? ''}')
+ ..writeln('android_imei: ${storage.getString(storageAndroidImei) ?? ''}')
+ ..writeln('android_mac: ${storage.getString(storageAndroidMac) ?? ''}')
+ ..writeln('ios_idfa: ${storage.getString(storageIosIdfa) ?? ''}')
+ ..writeln('ios_paid: ${storage.getString(storageIosPaid) ?? ''}')
+ ..writeln()
+ ..writeln('[auth]')
+ ..writeln('jwt: $jwtLine')
+ ..writeln()
+ ..writeln('[headers]')
+ ..writeln('common: $commonHeaders')
+ ..writeln('platform: $platformHeaders')
+ ..writeln('dio: $headers')
+ ..writeln()
+ ..writeln('[shorebird]')
+ ..writeln('current_patch: ${patch?.number ?? 'none'}')
+ ..writeln('patch: ${patch ?? 'none'}')
+ ..writeln()
+ ..writeln('[device]')
+ ..writeln(deviceInfo);
+
+ return _DebugInfoSnapshot(text.toString());
+}
+
+Future _readDeviceInfo() async {
+ try {
+ if (!kIsWeb &&
+ defaultTargetPlatform == TargetPlatform.android &&
+ !StorageService.to.getBool(storagePrivacyAgreed)) {
+ return 'privacy policy not accepted; device info not read';
+ }
+ final plugin = DeviceInfoPlugin();
+ if (kIsWeb) {
+ final info = await plugin.webBrowserInfo;
+ return info.data.toString();
+ }
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ final info = await plugin.androidInfo;
+ return info.data.toString();
+ case TargetPlatform.iOS:
+ final info = await plugin.iosInfo;
+ return info.data.toString();
+ case TargetPlatform.macOS:
+ final info = await plugin.macOsInfo;
+ return info.data.toString();
+ case TargetPlatform.windows:
+ final info = await plugin.windowsInfo;
+ return info.data.toString();
+ case TargetPlatform.linux:
+ final info = await plugin.linuxInfo;
+ return info.data.toString();
+ case TargetPlatform.fuchsia:
+ return 'fuchsia';
+ }
+ } catch (error) {
+ return 'unavailable: $error';
+ }
+}
+
+class _DebugInfoSnapshot {
+ const _DebugInfoSnapshot(this.text);
+
+ final String text;
+}
diff --git a/lib/features/debug/presentation/debug_page.dart b/lib/features/debug/presentation/debug_page.dart
new file mode 100644
index 0000000..a1d9b7c
--- /dev/null
+++ b/lib/features/debug/presentation/debug_page.dart
@@ -0,0 +1,201 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../../../common/router/app_router.dart';
+import '../../../common/theme/app_text.dart';
+import '../../../common/theme/spacing.dart';
+
+class DebugPage extends StatelessWidget {
+ const DebugPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Scaffold(
+ appBar: AppBar(
+ backgroundColor: cs.background,
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ title: Text(
+ '调试工具',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ leading: IconButton(
+ icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
+ onPressed: () => context.pop(),
+ ),
+ ),
+ body: ListView(
+ padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
+ children: [
+ _Section(
+ title: '状态',
+ child: _Card(
+ child: Text(
+ kReleaseMode ? 'release' : 'debug',
+ style: AppText.body.copyWith(color: cs.mutedForeground),
+ ),
+ ),
+ ),
+ _Section(
+ title: '入口',
+ child: _Card(
+ children: [
+ _Row(
+ icon: Icons.data_object_outlined,
+ title: '请求',
+ subtitle: '查看 Dio 请求和响应',
+ onTap: () => context.push(AppRoutes.fancyDioInspector),
+ ),
+ _Row(
+ icon: Icons.info_outline,
+ title: '应用信息',
+ subtitle: '版本、包名和构建信息',
+ onTap: () => context.push(AppRoutes.debugInfo),
+ ),
+ _Row(
+ icon: Icons.text_fields,
+ title: 'Typography',
+ subtitle: '查看字号和文本样式基线',
+ onTap: () => context.push(AppRoutes.debugTypography),
+ ),
+ _Row(
+ icon: Icons.system_update_alt_outlined,
+ title: 'Shorebird',
+ subtitle: '查看补丁与更新状态',
+ onTap: () => context.push(AppRoutes.debugShorebird),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Section extends StatelessWidget {
+ const _Section({required this.title, required this.child});
+
+ final String title;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.only(bottom: Spacing.sectionGap),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 4, bottom: 10),
+ child: Text(
+ title,
+ style: AppText.meta.copyWith(
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ color: cs.mutedForeground,
+ ),
+ ),
+ ),
+ child,
+ ],
+ ),
+ );
+ }
+}
+
+class _Card extends StatelessWidget {
+ const _Card({this.child, this.children = const []});
+
+ final Widget? child;
+ final List children;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Container(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(Spacing.radiusBase),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child:
+ child ??
+ Column(
+ children: [
+ for (var i = 0; i < children.length; i++) ...[
+ if (i > 0) Divider(height: 1, thickness: 1, color: cs.border),
+ children[i],
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _Row extends StatelessWidget {
+ const _Row({
+ required this.icon,
+ required this.title,
+ required this.subtitle,
+ required this.onTap,
+ });
+
+ final IconData icon;
+ final String title;
+ final String subtitle;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
+ child: Row(
+ children: [
+ Icon(icon, size: 20, color: cs.foreground),
+ const SizedBox(width: 13),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: AppText.body.copyWith(
+ color: cs.foreground,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ subtitle,
+ style: AppText.meta.copyWith(
+ color: cs.mutedForeground,
+ fontSize: 12.5,
+ ),
+ ),
+ ],
+ ),
+ ),
+ Icon(Icons.chevron_right, size: 18, color: cs.mutedForeground),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/debug/presentation/debug_shorebird_page.dart b/lib/features/debug/presentation/debug_shorebird_page.dart
new file mode 100644
index 0000000..afec39a
--- /dev/null
+++ b/lib/features/debug/presentation/debug_shorebird_page.dart
@@ -0,0 +1,420 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:shorebird_code_push/shorebird_code_push.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../../../common/config/shorebird_service.dart';
+import '../../../common/router/app_router.dart';
+import '../../../common/theme/app_text.dart';
+import '../../../common/theme/spacing.dart';
+
+class DebugShorebirdPage extends StatefulWidget {
+ const DebugShorebirdPage({super.key});
+
+ @override
+ State createState() => _DebugShorebirdPageState();
+}
+
+class _DebugShorebirdPageState extends State {
+ final ShorebirdService _shorebird = ShorebirdService.instance;
+ late final bool _isUpdaterAvailable;
+ var _currentTrack = UpdateTrack.stable;
+ var _isCheckingForUpdates = false;
+ Patch? _currentPatch;
+
+ @override
+ void initState() {
+ super.initState();
+ _isUpdaterAvailable = _shorebird.isAvailable;
+ _loadCurrentPatch();
+ }
+
+ Future _loadCurrentPatch() async {
+ try {
+ await _shorebird.initCurrentPatch();
+ if (!mounted) return;
+ setState(() => _currentPatch = _shorebird.currentPatch);
+ } catch (error) {
+ debugPrint('Error reading current patch: $error');
+ }
+ }
+
+ Future _checkForUpdate() async {
+ if (_isCheckingForUpdates) return;
+
+ try {
+ setState(() => _isCheckingForUpdates = true);
+ final status = await _shorebird.checkForUpdate(track: _currentTrack);
+ if (!mounted) return;
+ switch (status) {
+ case UpdateStatus.upToDate:
+ _showNoUpdateAvailableBanner();
+ break;
+ case UpdateStatus.outdated:
+ _showUpdateAvailableBanner();
+ break;
+ case UpdateStatus.restartRequired:
+ _showRestartBanner();
+ break;
+ case UpdateStatus.unavailable:
+ _showUnavailableBanner();
+ break;
+ }
+ } catch (error) {
+ debugPrint('Error checking for update: $error');
+ _showErrorBanner(error);
+ } finally {
+ if (mounted) {
+ setState(() => _isCheckingForUpdates = false);
+ }
+ }
+ }
+
+ void _showDownloadingBanner() {
+ ScaffoldMessenger.of(context)
+ ..hideCurrentMaterialBanner()
+ ..showMaterialBanner(
+ const MaterialBanner(
+ content: Text('正在下载更新...'),
+ actions: [
+ SizedBox(height: 14, width: 14, child: CircularProgressIndicator()),
+ ],
+ ),
+ );
+ }
+
+ void _showUpdateAvailableBanner() {
+ ScaffoldMessenger.of(context)
+ ..hideCurrentMaterialBanner()
+ ..showMaterialBanner(
+ MaterialBanner(
+ content: Text('当前 ${_currentTrack.name} track 有可用更新。'),
+ actions: [
+ TextButton(
+ onPressed: () async {
+ ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
+ await _downloadUpdate();
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
+ },
+ child: const Text('Download'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showNoUpdateAvailableBanner() {
+ ScaffoldMessenger.of(context)
+ ..hideCurrentMaterialBanner()
+ ..showMaterialBanner(
+ MaterialBanner(
+ content: Text('当前 ${_currentTrack.name} track 没有可用更新。'),
+ actions: [
+ TextButton(
+ onPressed: () {
+ ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
+ },
+ child: const Text('Dismiss'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showRestartBanner() {
+ ScaffoldMessenger.of(context)
+ ..hideCurrentMaterialBanner()
+ ..showMaterialBanner(
+ MaterialBanner(
+ content: const Text('新的 patch 已准备好,请重启应用。'),
+ actions: [
+ TextButton(
+ onPressed: () {
+ ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
+ },
+ child: const Text('Dismiss'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showUnavailableBanner() {
+ ScaffoldMessenger.of(context)
+ ..hideCurrentMaterialBanner()
+ ..showMaterialBanner(
+ MaterialBanner(
+ content: const Text('当前构建不可用 Shorebird。'),
+ actions: [
+ TextButton(
+ onPressed: () {
+ ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
+ },
+ child: const Text('Dismiss'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showErrorBanner(Object error) {
+ ScaffoldMessenger.of(context)
+ ..hideCurrentMaterialBanner()
+ ..showMaterialBanner(
+ MaterialBanner(
+ content: Text('检查更新时发生错误:$error'),
+ actions: [
+ TextButton(
+ onPressed: () {
+ ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
+ },
+ child: const Text('Dismiss'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _downloadUpdate() async {
+ _showDownloadingBanner();
+ try {
+ await _shorebird.downloadUpdate(track: _currentTrack);
+ if (!mounted) return;
+ setState(() => _currentPatch = _shorebird.currentPatch);
+ _showRestartBanner();
+ } on UpdateException catch (error) {
+ _showErrorBanner(error.message);
+ } catch (error) {
+ _showErrorBanner(error);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Scaffold(
+ appBar: AppBar(
+ backgroundColor: cs.background,
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ title: Text(
+ 'Shorebird',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ leading: IconButton(
+ icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
+ onPressed: () {
+ if (context.canPop()) {
+ context.pop();
+ } else {
+ context.go(AppRoutes.debug);
+ }
+ },
+ ),
+ ),
+ body: ListView(
+ padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
+ children: [
+ if (!_isUpdaterAvailable) ...[
+ _Card(
+ child: Text(
+ '当前构建未接入 Shorebird。请确认应用是通过 `shorebird release` 生成的 release 包,然后再检查更新与 patch 状态。',
+ style: AppText.body.copyWith(color: cs.mutedForeground),
+ ),
+ ),
+ const SizedBox(height: Spacing.cardGap),
+ ],
+ _Section(
+ title: '当前状态',
+ child: _Card(
+ child: Column(
+ children: [
+ _InfoTile(
+ title: '当前 patch 版本',
+ value: _currentPatch != null
+ ? '${_currentPatch!.number}'
+ : '未安装 patch',
+ ),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _InfoTile(
+ title: '更新器可用',
+ value: _isUpdaterAvailable ? '是' : '否',
+ ),
+ ],
+ ),
+ ),
+ ),
+ _Section(
+ title: 'Track 选择',
+ child: _Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '选择要检查和下载的 track。',
+ style: AppText.body.copyWith(color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 12),
+ SegmentedButton(
+ segments: const [
+ ButtonSegment(
+ label: Text('Stable'),
+ value: UpdateTrack.stable,
+ ),
+ ButtonSegment(
+ label: Text('Beta'),
+ value: UpdateTrack.beta,
+ ),
+ ButtonSegment(
+ label: Text('Staging'),
+ value: UpdateTrack.staging,
+ ),
+ ],
+ selected: {_currentTrack},
+ onSelectionChanged: (tracks) {
+ setState(() => _currentTrack = tracks.single);
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ _Section(
+ title: '操作',
+ child: _Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ Expanded(
+ child: FilledButton(
+ onPressed: _isCheckingForUpdates
+ ? null
+ : _checkForUpdate,
+ child: _isCheckingForUpdates
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ ),
+ )
+ : const Text('检查更新'),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: OutlinedButton(
+ onPressed: _downloadUpdate,
+ child: const Text('下载更新'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Section extends StatelessWidget {
+ const _Section({required this.title, required this.child});
+
+ final String title;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.only(bottom: Spacing.sectionGap),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 4, bottom: 10),
+ child: Text(
+ title,
+ style: AppText.meta.copyWith(
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ color: cs.mutedForeground,
+ ),
+ ),
+ ),
+ child,
+ ],
+ ),
+ );
+ }
+}
+
+class _Card extends StatelessWidget {
+ const _Card({required this.child});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Container(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(Spacing.radiusBase),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: child,
+ );
+ }
+}
+
+class _InfoTile extends StatelessWidget {
+ const _InfoTile({required this.title, required this.value});
+
+ final String title;
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: AppText.meta.copyWith(color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ value,
+ style: AppText.body.copyWith(
+ color: cs.foreground,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/debug/presentation/fancy_dio_inspector_page.dart b/lib/features/debug/presentation/fancy_dio_inspector_page.dart
new file mode 100644
index 0000000..8d1497e
--- /dev/null
+++ b/lib/features/debug/presentation/fancy_dio_inspector_page.dart
@@ -0,0 +1,24 @@
+import 'package:fancy_dio_inspector/fancy_dio_inspector.dart';
+import 'package:flutter/material.dart';
+
+class FancyDioInspectorPage extends StatelessWidget {
+ const FancyDioInspectorPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return ScaffoldMessenger(
+ child: Scaffold(
+ appBar: AppBar(
+ title: const Text('请求'),
+ leading: Navigator.of(context).canPop()
+ ? IconButton(
+ onPressed: () => Navigator.of(context).pop(),
+ icon: const Icon(Icons.chevron_left),
+ )
+ : null,
+ ),
+ body: const FancyDioInspectorView(),
+ ),
+ );
+ }
+}
diff --git a/lib/features/debug/presentation/typography_test_page.dart b/lib/features/debug/presentation/typography_test_page.dart
new file mode 100644
index 0000000..dd51869
--- /dev/null
+++ b/lib/features/debug/presentation/typography_test_page.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../../../common/theme/app_text.dart';
+import '../../../common/theme/spacing.dart';
+
+class TypographyTestPage extends StatelessWidget {
+ const TypographyTestPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ const samples = <_Sample>[
+ _Sample('appTitle', AppText.appTitle),
+ _Sample('sectionTitle', AppText.sectionTitle),
+ _Sample('cardTitle', AppText.cardTitle),
+ _Sample('listTitle', AppText.listTitle),
+ _Sample('body', AppText.body),
+ _Sample('meta', AppText.meta),
+ _Sample('chip', AppText.chip),
+ _Sample('badge', AppText.badge),
+ ];
+
+ return Scaffold(
+ appBar: AppBar(
+ backgroundColor: cs.background,
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ title: Text(
+ 'Typography',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ leading: IconButton(
+ icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
+ onPressed: () => context.pop(),
+ ),
+ ),
+ body: ListView(
+ padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
+ children: [
+ for (final sample in samples) ...[
+ Container(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(Spacing.radiusBase),
+ ),
+ clipBehavior: Clip.antiAlias,
+ padding: const EdgeInsets.all(16),
+ margin: const EdgeInsets.only(bottom: Spacing.cardGap),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ sample.name,
+ style: AppText.meta.copyWith(color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 8),
+ Text('金值 / Jinzhi / Typography', style: sample.style),
+ ],
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _Sample {
+ const _Sample(this.name, this.style);
+
+ final String name;
+ final TextStyle style;
+}
diff --git a/lib/features/market/application/exchange_calculator_controller.dart b/lib/features/market/application/exchange_calculator_controller.dart
new file mode 100644
index 0000000..8b6f5c3
--- /dev/null
+++ b/lib/features/market/application/exchange_calculator_controller.dart
@@ -0,0 +1,123 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../../common/domain/metal_type.dart';
+import '../data/market_models.dart';
+import '../data/mock_market_repository.dart';
+import 'market_controller.dart';
+
+final exchangeCalculatorControllerProvider =
+ StateNotifierProvider<
+ ExchangeCalculatorController,
+ ExchangeCalculatorState
+ >((ref) {
+ final market = ref.watch(marketControllerProvider);
+ final repository = ref.watch(mockMarketRepositoryProvider);
+ return ExchangeCalculatorController(
+ repository: repository,
+ initialMetal: market.selectedMetal,
+ );
+ });
+
+class ExchangeCalculatorState {
+ const ExchangeCalculatorState({
+ required this.metal,
+ required this.channels,
+ required this.selectedChannel,
+ required this.gram,
+ required this.amount,
+ required this.swapped,
+ });
+
+ final MetalType metal;
+ final List channels;
+ final ExchangeChannel selectedChannel;
+ final double gram;
+ final double amount;
+ final bool swapped;
+
+ ExchangeCalculatorState copyWith({
+ MetalType? metal,
+ List? channels,
+ ExchangeChannel? selectedChannel,
+ double? gram,
+ double? amount,
+ bool? swapped,
+ }) {
+ return ExchangeCalculatorState(
+ metal: metal ?? this.metal,
+ channels: channels ?? this.channels,
+ selectedChannel: selectedChannel ?? this.selectedChannel,
+ gram: gram ?? this.gram,
+ amount: amount ?? this.amount,
+ swapped: swapped ?? this.swapped,
+ );
+ }
+}
+
+class ExchangeCalculatorController
+ extends StateNotifier {
+ ExchangeCalculatorController({
+ required MockMarketRepository repository,
+ required MetalType initialMetal,
+ }) : _repository = repository,
+ super(_initialState(repository, initialMetal));
+
+ final MockMarketRepository _repository;
+
+ void selectMetal(MetalType metal) {
+ final channels = _repository.channelsFor(metal);
+ final selected = channels.first;
+ state = state.copyWith(
+ metal: metal,
+ channels: channels,
+ selectedChannel: selected,
+ amount: state.gram * selected.pricePerGram,
+ );
+ }
+
+ void selectChannel(String channelId) {
+ final selected = state.channels.firstWhere(
+ (channel) => channel.id == channelId,
+ orElse: () => state.channels.first,
+ );
+ state = state.copyWith(
+ selectedChannel: selected,
+ amount: state.gram * selected.pricePerGram,
+ );
+ }
+
+ void setGram(double gram) {
+ state = state.copyWith(
+ gram: gram,
+ amount: gram * state.selectedChannel.pricePerGram,
+ );
+ }
+
+ void setAmount(double amount) {
+ state = state.copyWith(
+ amount: amount,
+ gram: amount / state.selectedChannel.pricePerGram,
+ );
+ }
+
+ void toggleSwapped() {
+ state = state.copyWith(swapped: !state.swapped);
+ }
+
+ static ExchangeCalculatorState _initialState(
+ MockMarketRepository repository,
+ MetalType metal,
+ ) {
+ final channels = repository.channelsFor(metal);
+ final selected = channels.first;
+ const gram = 50.0;
+ return ExchangeCalculatorState(
+ metal: metal,
+ channels: channels,
+ selectedChannel: selected,
+ gram: gram,
+ amount: gram * selected.pricePerGram,
+ swapped: false,
+ );
+ }
+}
diff --git a/lib/features/market/application/market_controller.dart b/lib/features/market/application/market_controller.dart
new file mode 100644
index 0000000..157f488
--- /dev/null
+++ b/lib/features/market/application/market_controller.dart
@@ -0,0 +1,73 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../../common/domain/metal_type.dart';
+import '../data/market_models.dart';
+import '../data/mock_market_repository.dart';
+
+final marketControllerProvider =
+ StateNotifierProvider((ref) {
+ final repository = ref.watch(mockMarketRepositoryProvider);
+ return MarketController(repository);
+ });
+
+class MarketState {
+ const MarketState({
+ required this.selectedMetal,
+ required this.period,
+ required this.quote,
+ required this.points,
+ required this.quotes,
+ });
+
+ final MetalType selectedMetal;
+ final MarketPeriod period;
+ final MetalQuote quote;
+ final List points;
+ final List quotes;
+
+ MarketState copyWith({
+ MetalType? selectedMetal,
+ MarketPeriod? period,
+ MetalQuote? quote,
+ List? points,
+ List? quotes,
+ }) {
+ return MarketState(
+ selectedMetal: selectedMetal ?? this.selectedMetal,
+ period: period ?? this.period,
+ quote: quote ?? this.quote,
+ points: points ?? this.points,
+ quotes: quotes ?? this.quotes,
+ );
+ }
+}
+
+class MarketController extends StateNotifier {
+ MarketController(this._repository)
+ : super(
+ MarketState(
+ selectedMetal: MetalType.gold,
+ period: MarketPeriod.h24,
+ quote: _repository.quoteFor(MetalType.gold),
+ points: _repository.chartFor(MetalType.gold, MarketPeriod.h24),
+ quotes: _repository.getQuotes(),
+ ),
+ );
+
+ final MockMarketRepository _repository;
+
+ void selectMetal(MetalType metal) {
+ state = state.copyWith(
+ selectedMetal: metal,
+ quote: _repository.quoteFor(metal),
+ points: _repository.chartFor(metal, state.period),
+ );
+ }
+
+ void selectPeriod(MarketPeriod period) {
+ state = state.copyWith(
+ period: period,
+ points: _repository.chartFor(state.selectedMetal, period),
+ );
+ }
+}
diff --git a/lib/features/market/data/market_models.dart b/lib/features/market/data/market_models.dart
new file mode 100644
index 0000000..ae43196
--- /dev/null
+++ b/lib/features/market/data/market_models.dart
@@ -0,0 +1,74 @@
+import '../../../common/domain/metal_type.dart';
+
+enum MarketPeriod {
+ h24('24h', '24时'),
+ d5('5d', '5日'),
+ m1('1m', '1月'),
+ m3('3m', '3月'),
+ y1('1y', '1年');
+
+ const MarketPeriod(this.id, this.label);
+
+ final String id;
+ final String label;
+}
+
+enum ExchangeChannelKind { spot, bar, retail, td, recycle }
+
+class MetalQuote {
+ const MetalQuote({
+ required this.metal,
+ required this.spotPrice,
+ required this.changeAmount,
+ required this.changePercent,
+ required this.updatedAt,
+ required this.sourceLabel,
+ required this.basisLabel,
+ });
+
+ final MetalType metal;
+ final double spotPrice;
+ final double changeAmount;
+ final double changePercent;
+ final DateTime updatedAt;
+ final String sourceLabel;
+ final String basisLabel;
+}
+
+class MarketPoint {
+ const MarketPoint({
+ required this.time,
+ required this.open,
+ required this.high,
+ required this.low,
+ required this.close,
+ });
+
+ final DateTime time;
+ final double open;
+ final double high;
+ final double low;
+ final double close;
+}
+
+class ExchangeChannel {
+ const ExchangeChannel({
+ required this.id,
+ required this.metal,
+ required this.name,
+ required this.kind,
+ required this.pricePerGram,
+ required this.source,
+ required this.updatedAt,
+ required this.hint,
+ });
+
+ final String id;
+ final MetalType metal;
+ final String name;
+ final ExchangeChannelKind kind;
+ final double pricePerGram;
+ final String source;
+ final DateTime updatedAt;
+ final String hint;
+}
diff --git a/lib/features/market/data/mock_market_repository.dart b/lib/features/market/data/mock_market_repository.dart
new file mode 100644
index 0000000..be87ce1
--- /dev/null
+++ b/lib/features/market/data/mock_market_repository.dart
@@ -0,0 +1,216 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../../common/domain/metal_type.dart';
+import 'market_models.dart';
+
+final mockMarketRepositoryProvider = Provider(
+ (ref) => MockMarketRepository(),
+);
+
+class MockMarketRepository {
+ MockMarketRepository();
+
+ final DateTime _updatedAt = DateTime(2026, 6, 18, 16, 11);
+
+ List getQuotes() {
+ return [
+ MetalQuote(
+ metal: MetalType.gold,
+ spotPrice: 943.05,
+ changeAmount: 8.12,
+ changePercent: 0.87,
+ updatedAt: _updatedAt,
+ sourceLabel: '行情聚合',
+ basisLabel: '上海金现货参考价',
+ ),
+ MetalQuote(
+ metal: MetalType.platinum,
+ spotPrice: 384.20,
+ changeAmount: -2.46,
+ changePercent: -0.64,
+ updatedAt: _updatedAt,
+ sourceLabel: '行情聚合',
+ basisLabel: '国际盘换算参考价',
+ ),
+ MetalQuote(
+ metal: MetalType.silver,
+ spotPrice: 15.75,
+ changeAmount: 0.18,
+ changePercent: 1.16,
+ updatedAt: _updatedAt,
+ sourceLabel: '行情聚合',
+ basisLabel: '上海银现货参考价',
+ ),
+ ];
+ }
+
+ MetalQuote quoteFor(MetalType metal) =>
+ getQuotes().firstWhere((quote) => quote.metal == metal);
+
+ List chartFor(MetalType metal, MarketPeriod period) {
+ final quote = quoteFor(metal);
+ final points = [];
+ final step = switch (period) {
+ MarketPeriod.h24 => const Duration(hours: 2),
+ MarketPeriod.d5 => const Duration(days: 1),
+ MarketPeriod.m1 => const Duration(days: 3),
+ MarketPeriod.m3 => const Duration(days: 9),
+ MarketPeriod.y1 => const Duration(days: 30),
+ };
+ for (var i = 11; i >= 0; i--) {
+ final drift = (i - 5.5) * quote.changeAmount / 10;
+ final close = quote.spotPrice - drift;
+ points.add(
+ MarketPoint(
+ time: _updatedAt.subtract(step * i),
+ open: close - quote.changeAmount / 20,
+ high: close + quote.spotPrice * 0.003,
+ low: close - quote.spotPrice * 0.003,
+ close: close,
+ ),
+ );
+ }
+ return points;
+ }
+
+ List channelsFor(MetalType metal, {double recycle = 0.97}) {
+ final quote = quoteFor(metal);
+ return switch (metal) {
+ MetalType.gold => [
+ _channel(
+ 'gold_spot',
+ metal,
+ '上海金现货',
+ ExchangeChannelKind.spot,
+ 943.05,
+ '上海金现货',
+ '材料价值口径 · 非金店零售 / 回收价',
+ ),
+ _channel(
+ 'gold_bar',
+ metal,
+ '投资金条',
+ ExchangeChannelKind.bar,
+ 955.00,
+ '银行 / 品牌金条参考',
+ '投资金条参考 · 通常含少量升水',
+ ),
+ _channel(
+ 'gold_ctf',
+ metal,
+ '周大福金店',
+ ExchangeChannelKind.retail,
+ 1238.00,
+ '品牌零售挂牌参考',
+ '含工费与品牌溢价 · 以门店为准',
+ ),
+ _channel(
+ 'gold_lfx',
+ metal,
+ '老凤祥金店',
+ ExchangeChannelKind.retail,
+ 1248.00,
+ '品牌零售挂牌参考',
+ '含工费与品牌溢价 · 以门店为准',
+ ),
+ _channel(
+ 'gold_td',
+ metal,
+ '黄金 T+D',
+ ExchangeChannelKind.td,
+ 919.50,
+ 'SGE 递延参考',
+ '交易品种参考 · 非实物购买价',
+ ),
+ _channel(
+ 'gold_recycle',
+ metal,
+ '回收参考',
+ ExchangeChannelKind.recycle,
+ quote.spotPrice * recycle,
+ '材料价值 × 回收折扣',
+ '实际以门店检测为准',
+ ),
+ ],
+ MetalType.platinum => [
+ _channel(
+ 'platinum_spot',
+ metal,
+ '铂金现货',
+ ExchangeChannelKind.spot,
+ 384.20,
+ '国际盘换算参考',
+ '材料价值口径 · 非饰品零售价',
+ ),
+ _channel(
+ 'platinum_retail',
+ metal,
+ '铂金饰品',
+ ExchangeChannelKind.retail,
+ 498.00,
+ '零售挂牌参考',
+ '含工费与品牌溢价',
+ ),
+ _channel(
+ 'platinum_recycle',
+ metal,
+ '回收参考',
+ ExchangeChannelKind.recycle,
+ quote.spotPrice * recycle,
+ '材料价值 × 回收折扣',
+ '实际以门店检测为准',
+ ),
+ ],
+ MetalType.silver => [
+ _channel(
+ 'silver_spot',
+ metal,
+ '白银现货',
+ ExchangeChannelKind.spot,
+ 15.75,
+ '上海银现货',
+ '材料价值口径 · 非饰品零售价',
+ ),
+ _channel(
+ 'silver_retail',
+ metal,
+ '白银饰品',
+ ExchangeChannelKind.retail,
+ 42.00,
+ '零售挂牌参考',
+ '含工费与品牌溢价',
+ ),
+ _channel(
+ 'silver_recycle',
+ metal,
+ '回收参考',
+ ExchangeChannelKind.recycle,
+ quote.spotPrice * recycle,
+ '材料价值 × 回收折扣',
+ '实际以门店检测为准',
+ ),
+ ],
+ };
+ }
+
+ ExchangeChannel _channel(
+ String id,
+ MetalType metal,
+ String name,
+ ExchangeChannelKind kind,
+ double price,
+ String source,
+ String hint,
+ ) {
+ return ExchangeChannel(
+ id: id,
+ metal: metal,
+ name: name,
+ kind: kind,
+ pricePerGram: price,
+ source: source,
+ updatedAt: _updatedAt,
+ hint: hint,
+ );
+ }
+}
diff --git a/lib/features/market/presentation/market_page.dart b/lib/features/market/presentation/market_page.dart
new file mode 100644
index 0000000..c0e8c3c
--- /dev/null
+++ b/lib/features/market/presentation/market_page.dart
@@ -0,0 +1,176 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../../../common/domain/metal_type.dart';
+import '../../../common/domain/money.dart';
+import '../../../common/widgets/adaptive.dart';
+import '../application/exchange_calculator_controller.dart';
+import '../application/market_controller.dart';
+import '../data/market_models.dart';
+
+class MarketPage extends ConsumerWidget {
+ const MarketPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final market = ref.watch(marketControllerProvider);
+ final exchange = ref.watch(exchangeCalculatorControllerProvider);
+ final cs = ShadTheme.of(context).colorScheme;
+ return SafeArea(
+ top: false,
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
+ children: [
+ Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: Adaptive.contentMaxWidth,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ SegmentedButton(
+ segments: [
+ for (final metal in MetalType.values)
+ ButtonSegment(value: metal, label: Text(metal.label)),
+ ],
+ selected: {market.selectedMetal},
+ onSelectionChanged: (next) {
+ final metal = next.first;
+ ref
+ .read(marketControllerProvider.notifier)
+ .selectMetal(metal);
+ ref
+ .read(exchangeCalculatorControllerProvider.notifier)
+ .selectMetal(metal);
+ },
+ ),
+ const SizedBox(height: 12),
+ _Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(market.quote.basisLabel, style: _mutedStyle(cs)),
+ const SizedBox(height: 8),
+ Text(
+ '${formatCny(market.quote.spotPrice)} / 克',
+ style: TextStyle(
+ fontSize: 30,
+ fontWeight: FontWeight.w800,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ '${market.quote.changeAmount >= 0 ? '+' : ''}${market.quote.changeAmount.toStringAsFixed(2)} · ${market.quote.changePercent.toStringAsFixed(2)}%',
+ style: TextStyle(
+ color: market.quote.changeAmount >= 0
+ ? const Color(0xFFC0392B)
+ : const Color(0xFF2E8B6F),
+ ),
+ ),
+ const SizedBox(height: 14),
+ Wrap(
+ spacing: 8,
+ children: [
+ for (final period in MarketPeriod.values)
+ ChoiceChip(
+ label: Text(period.label),
+ selected: period == market.period,
+ onSelected: (_) => ref
+ .read(marketControllerProvider.notifier)
+ .selectPeriod(period),
+ ),
+ ],
+ ),
+ const SizedBox(height: 14),
+ Text(
+ '走势图 mock 点位:${market.points.length} 个',
+ style: _mutedStyle(cs),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ _Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '兑换试算',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ '${formatGram(exchange.gram)} ${exchange.metal.label} = ${formatCny(exchange.amount)}',
+ style: TextStyle(fontSize: 20, color: cs.foreground),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '1 克 ${exchange.metal.label} = ${formatCny(exchange.selectedChannel.pricePerGram)} · ${exchange.selectedChannel.source}',
+ style: _mutedStyle(cs),
+ ),
+ const SizedBox(height: 10),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ for (final channel in exchange.channels)
+ ChoiceChip(
+ label: Text(channel.name),
+ selected:
+ channel.id == exchange.selectedChannel.id,
+ onSelected: (_) => ref
+ .read(
+ exchangeCalculatorControllerProvider
+ .notifier,
+ )
+ .selectChannel(channel.id),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ exchange.selectedChannel.hint,
+ style: _mutedStyle(cs),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Card extends StatelessWidget {
+ const _Card({required this.child});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Padding(padding: const EdgeInsets.all(16), child: child),
+ );
+ }
+}
+
+TextStyle _mutedStyle(ShadColorScheme cs) {
+ return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0);
+}
diff --git a/lib/features/news/application/news_controller.dart b/lib/features/news/application/news_controller.dart
new file mode 100644
index 0000000..3b655f5
--- /dev/null
+++ b/lib/features/news/application/news_controller.dart
@@ -0,0 +1,34 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../data/mock_news_repository.dart';
+import '../data/news_models.dart';
+
+final newsControllerProvider = StateNotifierProvider(
+ (ref) => NewsController(ref.watch(mockNewsRepositoryProvider)),
+);
+
+class NewsState {
+ const NewsState({required this.items, required this.refreshedAt});
+
+ final List items;
+ final DateTime refreshedAt;
+}
+
+class NewsController extends StateNotifier {
+ NewsController(this._repository)
+ : super(
+ NewsState(
+ items: _repository.loadFeed(),
+ refreshedAt: DateTime(2026, 6, 18, 16, 11),
+ ),
+ );
+
+ final MockNewsRepository _repository;
+
+ void refresh() {
+ state = NewsState(
+ items: _repository.loadFeed(),
+ refreshedAt: DateTime.now(),
+ );
+ }
+}
diff --git a/lib/features/news/data/mock_news_repository.dart b/lib/features/news/data/mock_news_repository.dart
new file mode 100644
index 0000000..0ca2417
--- /dev/null
+++ b/lib/features/news/data/mock_news_repository.dart
@@ -0,0 +1,53 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../../common/domain/metal_type.dart';
+import 'news_models.dart';
+
+final mockNewsRepositoryProvider = Provider(
+ (ref) => MockNewsRepository(),
+);
+
+class MockNewsRepository {
+ List loadFeed() {
+ final base = DateTime(2026, 6, 18, 16, 11);
+ final items = [
+ NewsItem(
+ id: 'news_gold_001',
+ metal: MetalType.gold,
+ title: '金价维持高位震荡,实物金溢价继续分化',
+ source: '金值整理',
+ publishedAt: base.subtract(const Duration(minutes: 18)),
+ summary: '现货价与品牌零售价之间仍有明显价差,持仓估值应优先看材料价值。',
+ body: const [
+ '今日黄金现货价格维持高位震荡,品牌金店挂牌价与现货材料价值之间仍有明显差距。',
+ '对持有者而言,材料价值更适合用于日常估值;对购买者而言,需要额外关注工费、品牌溢价与回收折扣。',
+ ],
+ ),
+ NewsItem(
+ id: 'news_silver_001',
+ metal: MetalType.silver,
+ title: '白银跟随工业金属情绪回暖,短线波动放大',
+ source: '市场简报',
+ publishedAt: base.subtract(const Duration(hours: 1, minutes: 4)),
+ summary: '银价弹性较强,饰品材料价值和零售购买价差异更大。',
+ body: const [
+ '白银价格今日跟随工业金属情绪回暖,短线波动较黄金更明显。',
+ '银饰通常包含较高加工与零售成本,材料价值占比可能低于用户直觉。',
+ ],
+ ),
+ NewsItem(
+ id: 'news_platinum_001',
+ metal: MetalType.platinum,
+ title: '铂金回收报价偏谨慎,饰品估值需看纯度',
+ source: '金属观察',
+ publishedAt: base.subtract(const Duration(hours: 2, minutes: 36)),
+ summary: 'PT950 饰品估值建议按克重、纯度和回收折扣分层查看。',
+ body: const [
+ '铂金饰品回收报价通常更依赖门店检测与成色确认,报价口径比现货价格更谨慎。',
+ '记录持仓时建议保留纯度、克重、购买渠道和成本信息,便于后续估值与盈亏回看。',
+ ],
+ ),
+ ];
+ return [...items]..sort((a, b) => b.publishedAt.compareTo(a.publishedAt));
+ }
+}
diff --git a/lib/features/news/data/news_models.dart b/lib/features/news/data/news_models.dart
new file mode 100644
index 0000000..a27513d
--- /dev/null
+++ b/lib/features/news/data/news_models.dart
@@ -0,0 +1,21 @@
+import '../../../common/domain/metal_type.dart';
+
+class NewsItem {
+ const NewsItem({
+ required this.id,
+ required this.metal,
+ required this.title,
+ required this.source,
+ required this.publishedAt,
+ required this.summary,
+ required this.body,
+ });
+
+ final String id;
+ final MetalType metal;
+ final String title;
+ final String source;
+ final DateTime publishedAt;
+ final String summary;
+ final List body;
+}
diff --git a/lib/features/news/presentation/news_page.dart b/lib/features/news/presentation/news_page.dart
new file mode 100644
index 0000000..38383e8
--- /dev/null
+++ b/lib/features/news/presentation/news_page.dart
@@ -0,0 +1,122 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../../../common/widgets/adaptive.dart';
+import '../application/news_controller.dart';
+
+class NewsPage extends ConsumerWidget {
+ const NewsPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final news = ref.watch(newsControllerProvider);
+ final cs = ShadTheme.of(context).colorScheme;
+ return SafeArea(
+ top: false,
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
+ children: [
+ Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: Adaptive.contentMaxWidth,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ '信息整理 · 非投资建议',
+ style: TextStyle(
+ color: cs.mutedForeground,
+ fontSize: 13,
+ ),
+ ),
+ ),
+ TextButton(
+ onPressed: () =>
+ ref.read(newsControllerProvider.notifier).refresh(),
+ child: const Text('刷新'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ for (final item in news.items)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 10),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 3,
+ ),
+ decoration: BoxDecoration(
+ color: item.metal.color.withValues(
+ alpha: 0.12,
+ ),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ item.metal.shortLabel,
+ style: TextStyle(
+ color: item.metal.color,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ '${item.source} · ${item.publishedAt.hour.toString().padLeft(2, '0')}:${item.publishedAt.minute.toString().padLeft(2, '0')}',
+ style: TextStyle(
+ color: cs.mutedForeground,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Text(
+ item.title,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ item.summary,
+ style: TextStyle(
+ color: cs.mutedForeground,
+ height: 1.5,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/profile/application/profile_settings_controller.dart b/lib/features/profile/application/profile_settings_controller.dart
new file mode 100644
index 0000000..52d2a67
--- /dev/null
+++ b/lib/features/profile/application/profile_settings_controller.dart
@@ -0,0 +1,43 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../../common/domain/metal_type.dart';
+import '../data/profile_models.dart';
+
+final profileSettingsControllerProvider =
+ StateNotifierProvider(
+ (ref) => ProfileSettingsController(),
+ );
+
+class ProfileSettingsController extends StateNotifier {
+ ProfileSettingsController()
+ : super(
+ const ProfileSettings(
+ amountHidden: false,
+ recycleDiscounts: {
+ MetalType.gold: 0.97,
+ MetalType.platinum: 0.93,
+ MetalType.silver: 0.88,
+ },
+ purityPresets: {
+ '足金9999': 0.9999,
+ '足金999': 0.999,
+ 'PT950': 0.95,
+ '999银': 0.999,
+ },
+ priceColorMode: PriceColorMode.redUpGreenDown,
+ mockLoggedIn: false,
+ ),
+ );
+
+ void toggleAmountHidden() {
+ state = state.copyWith(amountHidden: !state.amountHidden);
+ }
+
+ void setMockLoggedIn(bool value) {
+ state = state.copyWith(mockLoggedIn: value);
+ }
+
+ void setPriceColorMode(PriceColorMode mode) {
+ state = state.copyWith(priceColorMode: mode);
+ }
+}
diff --git a/lib/features/profile/data/profile_models.dart b/lib/features/profile/data/profile_models.dart
new file mode 100644
index 0000000..74317a9
--- /dev/null
+++ b/lib/features/profile/data/profile_models.dart
@@ -0,0 +1,42 @@
+import '../../../common/domain/metal_type.dart';
+
+enum PriceColorMode {
+ redUpGreenDown('红涨绿跌'),
+ greenUpRedDown('绿涨红跌');
+
+ const PriceColorMode(this.label);
+
+ final String label;
+}
+
+class ProfileSettings {
+ const ProfileSettings({
+ required this.amountHidden,
+ required this.recycleDiscounts,
+ required this.purityPresets,
+ required this.priceColorMode,
+ required this.mockLoggedIn,
+ });
+
+ final bool amountHidden;
+ final Map recycleDiscounts;
+ final Map purityPresets;
+ final PriceColorMode priceColorMode;
+ final bool mockLoggedIn;
+
+ ProfileSettings copyWith({
+ bool? amountHidden,
+ Map? recycleDiscounts,
+ Map? purityPresets,
+ PriceColorMode? priceColorMode,
+ bool? mockLoggedIn,
+ }) {
+ return ProfileSettings(
+ amountHidden: amountHidden ?? this.amountHidden,
+ recycleDiscounts: recycleDiscounts ?? this.recycleDiscounts,
+ purityPresets: purityPresets ?? this.purityPresets,
+ priceColorMode: priceColorMode ?? this.priceColorMode,
+ mockLoggedIn: mockLoggedIn ?? this.mockLoggedIn,
+ );
+ }
+}
diff --git a/lib/features/profile/presentation/profile_page.dart b/lib/features/profile/presentation/profile_page.dart
new file mode 100644
index 0000000..0cceb65
--- /dev/null
+++ b/lib/features/profile/presentation/profile_page.dart
@@ -0,0 +1,185 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+import '../../../common/router/app_router.dart';
+import '../../../common/widgets/adaptive.dart';
+import '../../auth/application/auth_controller.dart';
+import '../../auth/data/auth_models.dart';
+import '../../profile/application/profile_settings_controller.dart';
+
+class ProfilePage extends ConsumerWidget {
+ const ProfilePage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final settings = ref.watch(profileSettingsControllerProvider);
+ final auth = ref.watch(authProvider);
+ final cs = ShadTheme.of(context).colorScheme;
+ final loggedIn = auth.valueOrNull is LoggedInAuthState;
+ final currentUser = auth.valueOrNull is LoggedInAuthState
+ ? (auth.valueOrNull as LoggedInAuthState).user
+ : null;
+
+ return SafeArea(
+ top: false,
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
+ children: [
+ Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: Adaptive.contentMaxWidth,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _Card(
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ currentUser?.nickname ?? '未登录',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ currentUser?.phone ?? '登录后可使用本地同步、云备份占位和账户管理。',
+ style: TextStyle(color: cs.mutedForeground),
+ ),
+ ],
+ ),
+ ),
+ FilledButton(
+ onPressed: () => context.push(AppRoutes.settings),
+ child: const Text('设置'),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ _Card(
+ child: Column(
+ children: [
+ _SettingRow(
+ label: '隐藏金额',
+ value: settings.amountHidden ? '已隐藏' : '显示中',
+ trailing: Switch(
+ value: settings.amountHidden,
+ onChanged: (_) => ref
+ .read(
+ profileSettingsControllerProvider.notifier,
+ )
+ .toggleAmountHidden(),
+ ),
+ ),
+ _SettingRow(
+ label: '回收折扣',
+ value:
+ '金 ${(settings.recycleDiscounts.values.first * 100).toStringAsFixed(0)}%',
+ ),
+ _SettingRow(
+ label: '涨跌颜色',
+ value: settings.priceColorMode.label,
+ ),
+ _SettingRow(
+ label: '纯度系数',
+ value: '${settings.purityPresets.length} 项',
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ _Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ FilledButton.tonal(
+ onPressed: loggedIn
+ ? () => context.push(AppRoutes.login)
+ : () => context.push(AppRoutes.login),
+ child: Text(loggedIn ? '切换账号' : '去登录'),
+ ),
+ const SizedBox(height: 10),
+ OutlinedButton(
+ onPressed: () => context.push(AppRoutes.settings),
+ child: const Text('打开设置'),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ _Card(
+ child: Text(
+ '风险提示 · 数据来源 · 免责\n价格、资讯与估值均为本地 mock 数据,仅用于产品原型验证,不构成投资建议。',
+ style: TextStyle(
+ color: cs.mutedForeground,
+ height: 1.6,
+ fontSize: 13,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _SettingRow extends StatelessWidget {
+ const _SettingRow({required this.label, required this.value, this.trailing});
+
+ final String label;
+ final String value;
+ final Widget? trailing;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 10),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ label,
+ style: TextStyle(fontSize: 15, color: cs.foreground),
+ ),
+ ),
+ Text(value, style: TextStyle(color: cs.mutedForeground)),
+ if (trailing != null) ...[const SizedBox(width: 8), trailing!],
+ ],
+ ),
+ );
+ }
+}
+
+class _Card extends StatelessWidget {
+ const _Card({required this.child});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Padding(padding: const EdgeInsets.all(16), child: child),
+ );
+ }
+}
diff --git a/lib/features/settings/presentation/privacy_consent_page.dart b/lib/features/settings/presentation/privacy_consent_page.dart
new file mode 100644
index 0000000..36e6571
--- /dev/null
+++ b/lib/features/settings/presentation/privacy_consent_page.dart
@@ -0,0 +1,476 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:go_router/go_router.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+import 'package:url_launcher/url_launcher_string.dart';
+import 'package:video_player/video_player.dart';
+
+import '../../../common/config/app_h5_urls.dart';
+import '../../../common/services/device_header_service.dart';
+import '../../../common/theme/jinzhi_theme.dart';
+import '../../../common/widgets/adaptive.dart';
+import '../../../common/router/app_router.dart';
+
+class PrivacyConsentPage extends StatefulWidget {
+ const PrivacyConsentPage({super.key});
+
+ @override
+ State createState() => _PrivacyConsentPageState();
+}
+
+class _PrivacyConsentPageState extends State {
+ bool _dialogShown = false;
+ late final VideoPlayerController _video;
+ bool _videoReady = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _video = VideoPlayerController.asset('assets/launch/launch-bg.mp4')
+ ..setLooping(true)
+ ..setVolume(0)
+ ..initialize().then((_) {
+ if (!mounted) return;
+ _video.setPlaybackSpeed(0.5);
+ _video.play();
+ setState(() => _videoReady = true);
+ });
+ }
+
+ @override
+ void dispose() {
+ _video.dispose();
+ super.dispose();
+ }
+
+ Future _agree() async {
+ await DeviceHeaderService.to.markPrivacyAgreed();
+ await DeviceHeaderService.to.ensureInitialized();
+ if (mounted) context.go(AppRoutes.assets);
+ }
+
+ Future _enter() async {
+ await DeviceHeaderService.to.markIosStartupSeen();
+ if (mounted) context.go(AppRoutes.assets);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ const cs = jinzhiDarkScheme;
+ final showAndroidConsent = DeviceHeaderService.to.privacyConsentRequired;
+
+ if (showAndroidConsent && !_dialogShown) {
+ _dialogShown = true;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ isDismissible: false,
+ enableDrag: false,
+ backgroundColor: Colors.transparent,
+ barrierColor: Colors.black.withValues(alpha: 0.55),
+ builder: (_) => _PrivacyConsentSheet(onAgree: _agree),
+ );
+ });
+ }
+
+ return AnnotatedRegion(
+ value: SystemUiOverlayStyle.light,
+ child: Scaffold(
+ backgroundColor: cs.background,
+ body: Stack(
+ fit: StackFit.expand,
+ children: [
+ if (_videoReady)
+ FittedBox(
+ fit: BoxFit.cover,
+ clipBehavior: Clip.hardEdge,
+ child: SizedBox(
+ width: _video.value.size.width,
+ height: _video.value.size.height,
+ child: VideoPlayer(_video),
+ ),
+ )
+ else
+ Image.asset('assets/launch/launch-poster.jpg', fit: BoxFit.cover),
+ DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ stops: const [0.0, 0.46, 0.88],
+ colors: [
+ cs.background.withValues(alpha: 0.66),
+ cs.background.withValues(alpha: 0.82),
+ cs.background,
+ ],
+ ),
+ ),
+ ),
+ SafeArea(
+ child: Column(
+ children: [
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const _LaunchMark(size: 88),
+ const SizedBox(height: 26),
+ Text(
+ '研听',
+ style: TextStyle(
+ fontSize: 42,
+ fontWeight: FontWeight.w800,
+ color: cs.foreground,
+ height: 1.1,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ '读懂全球研报',
+ style: TextStyle(
+ fontSize: 16,
+ color: cs.foreground.withValues(alpha: 0.82),
+ letterSpacing: 2,
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (!showAndroidConsent)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(22, 0, 22, 20),
+ child: Center(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxWidth: Adaptive.isTablet(context)
+ ? 400
+ : double.infinity,
+ ),
+ child: GestureDetector(
+ onTap: _enter,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.primary,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ child: Text(
+ '立即进入',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: cs.primaryForeground,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.only(bottom: 14),
+ child: Text(
+ '内容为公开研报的结构化解读,不构成投资建议',
+ style: TextStyle(
+ fontSize: 11.5,
+ color: cs.mutedForeground.withValues(alpha: 0.75),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _LaunchMark extends StatefulWidget {
+ const _LaunchMark({this.size = 88});
+
+ final double size;
+
+ @override
+ State<_LaunchMark> createState() => _LaunchMarkState();
+}
+
+class _LaunchMarkState extends State<_LaunchMark>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _spin = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 8),
+ )..repeat();
+
+ @override
+ void dispose() {
+ _spin.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final m = widget.size;
+ const cxf = 0.6758, cyf = 0.3242, sideF = 0.4121;
+ final side = m * sideF;
+ return SizedBox(
+ width: m,
+ height: m,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Positioned.fill(
+ child: Image.asset('assets/launch/mark-grid-lime.png'),
+ ),
+ Positioned(
+ left: cxf * m - side / 2,
+ top: cyf * m - side / 2,
+ width: side,
+ height: side,
+ child: RotationTransition(
+ turns: _spin,
+ child: Image.asset('assets/launch/mark-star-lime.png'),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _PrivacyConsentSheet extends StatefulWidget {
+ const _PrivacyConsentSheet({required this.onAgree});
+
+ final Future Function() onAgree;
+
+ @override
+ State<_PrivacyConsentSheet> createState() => _PrivacyConsentSheetState();
+}
+
+class _PrivacyConsentSheetState extends State<_PrivacyConsentSheet> {
+ bool _verify = false;
+
+ @override
+ Widget build(BuildContext context) {
+ const cs = jinzhiDarkScheme;
+ final privacyUrl = AppH5UrlsHelper.buildUrlWithNight(
+ AppH5Urls.privacyUrl,
+ false,
+ );
+ final protocolUrl = AppH5UrlsHelper.buildUrlWithNight(
+ AppH5Urls.userProtocolUrl,
+ false,
+ );
+ return SafeArea(
+ top: false,
+ child: Container(
+ width: double.infinity,
+ decoration: BoxDecoration(
+ color: cs.card,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(11.2)),
+ border: Border.all(color: cs.border),
+ ),
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: _verify
+ ? _verifyView(cs, protocolUrl, privacyUrl)
+ : _consentView(cs, protocolUrl, privacyUrl),
+ ),
+ );
+ }
+
+ Widget _grip(ShadColorScheme cs) => Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ );
+
+ Widget _consentView(
+ ShadColorScheme cs,
+ String protocolUrl,
+ String privacyUrl,
+ ) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _grip(cs),
+ Text(
+ '欢迎使用研听',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 12),
+ _policyBody(cs, protocolUrl: protocolUrl, privacyUrl: privacyUrl),
+ const SizedBox(height: 20),
+ Row(
+ children: [
+ Expanded(
+ child: _Btn(
+ label: '不同意',
+ primary: false,
+ cs: cs,
+ onTap: () => setState(() => _verify = true),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: _Btn(
+ label: '同意',
+ primary: true,
+ cs: cs,
+ onTap: () async {
+ Navigator.of(context).pop();
+ await widget.onAgree();
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _verifyView(
+ ShadColorScheme cs,
+ String protocolUrl,
+ String privacyUrl,
+ ) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _grip(cs),
+ Text(
+ '确认放弃使用?',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 12),
+ _policyBody(cs, protocolUrl: protocolUrl, privacyUrl: privacyUrl),
+ const SizedBox(height: 20),
+ Row(
+ children: [
+ Expanded(
+ child: _Btn(
+ label: '不同意并退出',
+ primary: false,
+ cs: cs,
+ onTap: () => SystemNavigator.pop(),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: _Btn(
+ label: '同意',
+ primary: true,
+ cs: cs,
+ onTap: () async {
+ Navigator.of(context).pop();
+ await widget.onAgree();
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _policyBody(
+ ShadColorScheme cs, {
+ required String protocolUrl,
+ required String privacyUrl,
+ }) {
+ return Text.rich(
+ TextSpan(
+ style: TextStyle(fontSize: 13, height: 1.7, color: cs.mutedForeground),
+ children: [
+ const TextSpan(
+ text:
+ '尊敬的用户:\n\n我们非常重视您的个人信息和隐私保护,为了更好的保障您的个人权益,请您在使用我们的产品前,仔细阅读并充分理解 ',
+ ),
+ _link('《用户协议》', protocolUrl, cs),
+ const TextSpan(text: ' 和 '),
+ _link('《隐私政策》', privacyUrl, cs),
+ const TextSpan(
+ text:
+ '内容。我们将按照该协议内容收集、使用和共享您的个人信息。\n为了保证业务安全风控,在您使用我们基本功能的过程中,我们会收集您的手机号码,以及硬件序列号或唯一设备识别码(如 AndroidID/MAC/OAID/IMEI/WIFI 的 BSSID)等信息。\n如您同意,请点击“同意”开始接受我们的服务。',
+ ),
+ ],
+ ),
+ );
+ }
+
+ TextSpan _link(String text, String url, ShadColorScheme cs) => TextSpan(
+ text: text,
+ style: TextStyle(
+ color: cs.accentForeground,
+ fontWeight: FontWeight.w500,
+ decoration: TextDecoration.underline,
+ decorationColor: cs.accentForeground,
+ ),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () => launchUrlString(
+ AppH5UrlsHelper.withCacheBuster(url),
+ mode: LaunchMode.inAppBrowserView,
+ ),
+ );
+}
+
+class _Btn extends StatelessWidget {
+ const _Btn({
+ required this.label,
+ required this.primary,
+ required this.cs,
+ required this.onTap,
+ });
+
+ final String label;
+ final bool primary;
+ final ShadColorScheme cs;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: primary ? cs.primary : cs.secondary,
+ borderRadius: BorderRadius.circular(12),
+ border: primary ? null : Border.all(color: cs.border),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: primary ? cs.primaryForeground : cs.foreground,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/settings/presentation/settings_page.dart b/lib/features/settings/presentation/settings_page.dart
new file mode 100644
index 0000000..95ed0f1
--- /dev/null
+++ b/lib/features/settings/presentation/settings_page.dart
@@ -0,0 +1,1231 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_remix/flutter_remix.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+import 'package:url_launcher/url_launcher_string.dart';
+
+import '../../../common/config/app_h5_urls.dart';
+import '../../../common/router/app_router.dart';
+import '../../../common/theme/app_text.dart';
+import '../../../common/theme/spacing.dart';
+import '../../../common/theme/theme_controller.dart';
+import '../../../common/widgets/adaptive.dart';
+import '../../../common/widgets/app_toast.dart';
+import '../../../common/widgets/circle_back_button.dart';
+import '../../auth/application/auth_controller.dart';
+import '../../auth/data/auth_models.dart';
+import '../../profile/application/profile_settings_controller.dart';
+import '../../profile/data/profile_models.dart';
+
+class SettingsPage extends HookConsumerWidget {
+ const SettingsPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final themeMode = ref.watch(themeModeControllerProvider);
+ final profileSettings = ref.watch(profileSettingsControllerProvider);
+ final authAsync = ref.watch(authProvider);
+ final loggedIn = authAsync.valueOrNull is LoggedInAuthState;
+ final contactDebugTapCount = useState(0);
+ final contactLastTapAt = useRef(null);
+
+ return Scaffold(
+ backgroundColor: cs.background,
+ appBar: AppBar(
+ backgroundColor: cs.background,
+ surfaceTintColor: Colors.transparent,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ centerTitle: true,
+ title: Text(
+ '设置',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ leading: const CircleBackButton(),
+ ),
+ body: ListView(
+ padding: Adaptive.screenPadding(context, top: 8, bottom: 32),
+ children: [
+ const _Section('外观'),
+ _ThemeSegmentCard(
+ themeMode: themeMode,
+ onChanged: (mode) =>
+ ref.read(themeModeControllerProvider.notifier).set(mode),
+ ),
+
+ const SizedBox(height: 22),
+ const _Section('资产参数'),
+ _Card(
+ children: [
+ _SwitchRow(
+ title: '隐藏金额',
+ subtitle: '在资产页和持仓列表中隐藏金额信息',
+ value: profileSettings.amountHidden,
+ onChanged: (_) => ref
+ .read(profileSettingsControllerProvider.notifier)
+ .toggleAmountHidden(),
+ ),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: FlutterRemix.line_chart_line,
+ label: '涨跌颜色',
+ trailing: profileSettings.priceColorMode.label,
+ onTap: () => _showPriceColorSheet(context, ref),
+ ),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: FlutterRemix.coins_line,
+ label: '纯度系数',
+ trailing: '${profileSettings.purityPresets.length} 项',
+ onTap: () => _showPuritySheet(context, profileSettings),
+ ),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: FlutterRemix.exchange_box_line,
+ label: '回收折扣',
+ trailing: '已设置',
+ onTap: () => _showRecycleSheet(context, profileSettings),
+ ),
+ ],
+ ),
+
+ const SizedBox(height: 22),
+ const _Section('备份'),
+ _Card(
+ children: [
+ _NavRow(
+ icon: FlutterRemix.download_2_line,
+ label: '本地导出备份',
+ trailing: '导出',
+ onTap: () => _showLocalBackupSheet(context),
+ ),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: FlutterRemix.cloud_line,
+ label: '云备份',
+ trailing: '占位',
+ onTap: () => _showCloudBackupSheet(context),
+ ),
+ ],
+ ),
+
+ const SizedBox(height: 22),
+ const _Section('工具'),
+ _Card(
+ children: [
+ _NavRow(
+ icon: FlutterRemix.layout_grid_line,
+ label: '桌面小组件',
+ trailing: '占位',
+ onTap: () => _showWidgetSheet(context),
+ ),
+ if (kDebugMode) ...[
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: Icons.bug_report_outlined,
+ label: '调试工具',
+ onTap: () => context.push(AppRoutes.debug),
+ ),
+ ],
+ ],
+ ),
+
+ const SizedBox(height: 22),
+ const _Section('法律'),
+ _Card(
+ children: [
+ _NavRow(
+ icon: FlutterRemix.file_list_3_line,
+ label: '用户协议',
+ onTap: () => launchUrlString(
+ AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl),
+ mode: LaunchMode.inAppBrowserView,
+ ),
+ ),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: FlutterRemix.shield_check_line,
+ label: '隐私政策',
+ onTap: () => launchUrlString(
+ AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl),
+ mode: LaunchMode.inAppBrowserView,
+ ),
+ ),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: FlutterRemix.alert_line,
+ label: '免责声明',
+ onTap: () => _showDisclaimerSheet(context),
+ ),
+ ],
+ ),
+
+ if (loggedIn) ...[
+ const SizedBox(height: 22),
+ const _Section('账户'),
+ _Card(
+ children: [
+ _NavRow(
+ icon: FlutterRemix.logout_box_r_line,
+ label: '退出登录',
+ onTap: () => _confirmLogout(context, ref),
+ ),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: FlutterRemix.delete_bin_line,
+ label: '注销账号',
+ destructive: true,
+ onTap: () => context.push(AppRoutes.deleteAccount),
+ ),
+ ],
+ ),
+ ],
+
+ const SizedBox(height: 22),
+ const _Section('关于'),
+ _Card(
+ children: [
+ const _AboutRow(),
+ Divider(height: 1, thickness: 1, color: cs.border),
+ _NavRow(
+ icon: FlutterRemix.mail_line,
+ label: '联系我们',
+ trailing: 'business@hnsiderui.cn',
+ onTap: () {
+ final now = DateTime.now();
+ final lastTapAt = contactLastTapAt.value;
+ if (lastTapAt == null ||
+ now.difference(lastTapAt) >
+ const Duration(milliseconds: 500)) {
+ contactDebugTapCount.value = 0;
+ }
+ contactLastTapAt.value = now;
+ contactDebugTapCount.value += 1;
+ if (contactDebugTapCount.value >= 10) {
+ contactDebugTapCount.value = 0;
+ contactLastTapAt.value = null;
+ context.push(AppRoutes.debug);
+ }
+ },
+ ),
+ ],
+ ),
+ const SizedBox(height: 14),
+ _RiskFooter(cs: cs),
+ ],
+ ),
+ );
+ }
+
+ Future _confirmLogout(BuildContext context, WidgetRef ref) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ final ok = await showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '退出登录',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '确定要退出登录吗?',
+ style: TextStyle(fontSize: 13, color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 22),
+ _LogoutSheetButton(
+ label: '退出',
+ primary: true,
+ onTap: () => Navigator.of(ctx).pop(true),
+ ),
+ const SizedBox(height: 12),
+ _LogoutSheetButton(
+ label: '取消',
+ primary: false,
+ onTap: () => Navigator.of(ctx).pop(false),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ if (ok == true && context.mounted) {
+ await ref.read(authProvider.notifier).logout();
+ }
+ }
+
+ Future _showPriceColorSheet(BuildContext context, WidgetRef ref) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ final current = ref.read(profileSettingsControllerProvider).priceColorMode;
+ final selected = await showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '涨跌颜色',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '选择行情上涨和下跌的配色方案。',
+ style: TextStyle(fontSize: 13, color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 18),
+ _ChoiceButton(
+ label: PriceColorMode.redUpGreenDown.label,
+ selected: current == PriceColorMode.redUpGreenDown,
+ onTap: () => Navigator.of(ctx).pop(PriceColorMode.redUpGreenDown),
+ ),
+ const SizedBox(height: 12),
+ _ChoiceButton(
+ label: PriceColorMode.greenUpRedDown.label,
+ selected: current == PriceColorMode.greenUpRedDown,
+ onTap: () => Navigator.of(ctx).pop(PriceColorMode.greenUpRedDown),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ if (selected != null && context.mounted) {
+ ref.read(profileSettingsControllerProvider.notifier).setPriceColorMode(
+ selected,
+ );
+ }
+ }
+
+ Future _showPuritySheet(
+ BuildContext context,
+ ProfileSettings settings,
+ ) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ await showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '纯度系数',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '用于估值换算的本地纯度预设。',
+ style: TextStyle(fontSize: 13, color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 16),
+ for (final entry in settings.purityPresets.entries) ...[
+ _ValueRow(
+ label: entry.key,
+ value: '${(entry.value * 100).toStringAsFixed(2)}%',
+ ),
+ if (entry.key != settings.purityPresets.keys.last) ...[
+ const SizedBox(height: 10),
+ ],
+ ],
+ const SizedBox(height: 18),
+ _SheetPrimaryButton(
+ label: '知道了',
+ onTap: () => Navigator.of(ctx).pop(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future _showRecycleSheet(
+ BuildContext context,
+ ProfileSettings settings,
+ ) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ await showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '回收折扣',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '用于计算金、铂、银的回收参考值。',
+ style: TextStyle(fontSize: 13, color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 16),
+ for (final entry in settings.recycleDiscounts.entries) ...[
+ _ValueRow(
+ label: entry.key.label,
+ value: '${(entry.value * 100).toStringAsFixed(0)}%',
+ ),
+ if (entry.key != settings.recycleDiscounts.keys.last) ...[
+ const SizedBox(height: 10),
+ ],
+ ],
+ const SizedBox(height: 18),
+ _SheetPrimaryButton(
+ label: '知道了',
+ onTap: () => Navigator.of(ctx).pop(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future _showLocalBackupSheet(BuildContext context) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ final ok = await showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '本地导出备份',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '将当前持仓、偏好设置和 mock 账号状态导出到本地文件。',
+ style: TextStyle(fontSize: 13, color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 18),
+ _SheetPrimaryButton(
+ label: '导出备份',
+ onTap: () => Navigator.of(ctx).pop(true),
+ ),
+ const SizedBox(height: 12),
+ _SheetSecondaryButton(
+ label: '取消',
+ onTap: () => Navigator.of(ctx).pop(false),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ if (ok == true && context.mounted) {
+ toastInfo(msg: '已导出本地备份(mock)');
+ }
+ }
+
+ Future _showCloudBackupSheet(BuildContext context) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ await showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '云备份',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '后续会接入云同步与多端恢复,这里先保留入口。',
+ style: TextStyle(fontSize: 13, color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 18),
+ _SheetPrimaryButton(
+ label: '知道了',
+ onTap: () => Navigator.of(ctx).pop(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future _showWidgetSheet(BuildContext context) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ await showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '桌面小组件',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '桌面行情小组件正在整理中,先保留入口。',
+ style: TextStyle(fontSize: 13, color: cs.mutedForeground),
+ ),
+ const SizedBox(height: 18),
+ _SheetPrimaryButton(
+ label: '知道了',
+ onTap: () => Navigator.of(ctx).pop(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future _showDisclaimerSheet(BuildContext context) async {
+ final cs = ShadTheme.of(context).colorScheme;
+ await showModalBottomSheet(
+ context: context,
+ backgroundColor: cs.card,
+ barrierColor: Colors.black.withValues(alpha: 0.5),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(Spacing.radiusCard),
+ ),
+ ),
+ builder: (ctx) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 38,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: cs.border,
+ borderRadius: BorderRadius.circular(9999),
+ ),
+ ),
+ ),
+ Text(
+ '免责声明',
+ style: TextStyle(
+ fontSize: 19,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '金值提供的价格、资讯和估值均为本地 mock 数据,仅用于产品原型验证,不构成投资建议。',
+ style: TextStyle(
+ fontSize: 13,
+ height: 1.55,
+ color: cs.mutedForeground,
+ ),
+ ),
+ const SizedBox(height: 18),
+ _SheetPrimaryButton(
+ label: '知道了',
+ onTap: () => Navigator.of(ctx).pop(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _Section extends StatelessWidget {
+ const _Section(this.title);
+
+ final String title;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 10),
+ child: Text(
+ title,
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ color: cs.foreground,
+ ),
+ ),
+ );
+ }
+}
+
+class _Card extends StatelessWidget {
+ const _Card({required this.children});
+
+ final List children;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Container(
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(Spacing.radiusCard),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: Column(
+ children: [
+ for (var i = 0; i < children.length; i++) ...[
+ if (i > 0) Divider(height: 1, thickness: 1, color: cs.border),
+ children[i],
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _ThemeSegmentCard extends StatelessWidget {
+ const _ThemeSegmentCard({required this.themeMode, required this.onChanged});
+
+ final ThemeMode themeMode;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final options = <(ThemeMode, String, IconData)>[
+ (ThemeMode.system, '跟随系统', Icons.brightness_auto_outlined),
+ (ThemeMode.light, '浅色', Icons.light_mode_outlined),
+ (ThemeMode.dark, '深色', Icons.dark_mode_outlined),
+ ];
+ return Container(
+ padding: const EdgeInsets.all(4),
+ decoration: BoxDecoration(
+ color: cs.secondary,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ children: [
+ for (final opt in options)
+ Expanded(
+ child: Padding(
+ padding: EdgeInsets.only(
+ left: opt == options.first ? 0 : 2,
+ right: opt == options.last ? 0 : 2,
+ ),
+ child: _ThemeSegmentButton(
+ label: opt.$2,
+ icon: opt.$3,
+ selected: themeMode == opt.$1,
+ onTap: () => onChanged(opt.$1),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ThemeSegmentButton extends StatelessWidget {
+ const _ThemeSegmentButton({
+ required this.label,
+ required this.icon,
+ required this.selected,
+ required this.onTap,
+ });
+
+ final String label;
+ final IconData icon;
+ final bool selected;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final fg = selected ? cs.foreground : cs.mutedForeground;
+ return Material(
+ color: selected ? cs.background : Colors.transparent,
+ borderRadius: BorderRadius.circular(10),
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(10),
+ child: Container(
+ height: 44,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(10),
+ boxShadow: selected
+ ? [
+ const BoxShadow(
+ color: Color(0x0A000000),
+ blurRadius: 10,
+ offset: Offset(0, 2),
+ ),
+ ]
+ : null,
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 17, color: fg),
+ const SizedBox(width: 8),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ color: fg,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SwitchRow extends StatelessWidget {
+ const _SwitchRow({
+ required this.title,
+ required this.subtitle,
+ required this.value,
+ required this.onChanged,
+ });
+
+ final String title;
+ final String subtitle;
+ final bool value;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ subtitle,
+ style: AppText.meta.copyWith(
+ fontSize: 13,
+ color: cs.mutedForeground,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 16),
+ ShadSwitch(
+ value: value,
+ onChanged: onChanged,
+ checkedTrackColor: cs.primary,
+ thumbColor: cs.background,
+ width: 44,
+ height: 26,
+ margin: 2,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _NavRow extends StatelessWidget {
+ const _NavRow({
+ required this.icon,
+ required this.label,
+ this.trailing,
+ this.onTap,
+ this.destructive = false,
+ });
+
+ final IconData icon;
+ final String label;
+ final String? trailing;
+ final VoidCallback? onTap;
+ final bool destructive;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ final color = destructive ? cs.destructive : cs.foreground;
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
+ child: Row(
+ children: [
+ SizedBox(width: 24, child: Icon(icon, size: 20, color: color)),
+ const SizedBox(width: 13),
+ Expanded(
+ child: Text(label, style: TextStyle(fontSize: 15, color: color)),
+ ),
+ if (trailing != null)
+ Text(
+ trailing!,
+ style: AppText.meta.copyWith(
+ fontSize: 13,
+ color: cs.mutedForeground,
+ ),
+ ),
+ if (onTap != null && trailing == null)
+ Icon(
+ FlutterRemix.arrow_right_s_line,
+ size: 20,
+ color: cs.mutedForeground,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _AboutRow extends StatelessWidget {
+ const _AboutRow();
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return FutureBuilder(
+ future: PackageInfo.fromPlatform(),
+ builder: (context, snap) {
+ final v = snap.hasData
+ ? 'v${snap.data!.version}+${snap.data!.buildNumber}'
+ : '';
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
+ child: Row(
+ children: [
+ SizedBox(
+ width: 24,
+ child: Icon(
+ FlutterRemix.information_line,
+ size: 20,
+ color: cs.foreground,
+ ),
+ ),
+ const SizedBox(width: 13),
+ Expanded(
+ child: Text(
+ '金值',
+ style: TextStyle(fontSize: 15, color: cs.foreground),
+ ),
+ ),
+ Text(
+ v,
+ style: AppText.meta.copyWith(
+ fontSize: 13,
+ color: cs.mutedForeground,
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _RiskFooter extends StatelessWidget {
+ const _RiskFooter({required this.cs});
+
+ final ShadColorScheme cs;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
+ decoration: BoxDecoration(
+ color: cs.card,
+ border: Border.all(color: cs.border),
+ borderRadius: BorderRadius.circular(Spacing.radiusCard),
+ ),
+ child: Text(
+ '风险提示 · 数据来源 · 免责\n价格、资讯与估值均为本地 mock 数据,仅用于产品原型验证,不构成投资建议。',
+ style: AppText.meta.copyWith(
+ fontSize: 12,
+ color: cs.mutedForeground,
+ height: 1.65,
+ ),
+ ),
+ );
+ }
+}
+
+class _ChoiceButton extends StatelessWidget {
+ const _ChoiceButton({
+ required this.label,
+ required this.selected,
+ required this.onTap,
+ });
+
+ final String label;
+ final bool selected;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: selected ? cs.primary : cs.secondary,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: selected ? cs.primary : cs.border,
+ ),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: selected ? cs.primaryForeground : cs.foreground,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SheetPrimaryButton extends StatelessWidget {
+ const _SheetPrimaryButton({required this.label, required this.onTap});
+
+ final String label;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.primary,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: cs.primaryForeground,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SheetSecondaryButton extends StatelessWidget {
+ const _SheetSecondaryButton({required this.label, required this.onTap});
+
+ final String label;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: cs.secondary,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: cs.border),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: cs.foreground,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ValueRow extends StatelessWidget {
+ const _ValueRow({required this.label, required this.value});
+
+ final String label;
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return Row(
+ children: [
+ Expanded(
+ child: Text(
+ label,
+ style: TextStyle(fontSize: 15, color: cs.foreground),
+ ),
+ ),
+ Text(
+ value,
+ style: TextStyle(fontSize: 13, color: cs.mutedForeground),
+ ),
+ ],
+ );
+ }
+}
+
+class _LogoutSheetButton extends StatelessWidget {
+ const _LogoutSheetButton({
+ required this.label,
+ required this.primary,
+ required this.onTap,
+ });
+
+ final String label;
+ final bool primary;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = ShadTheme.of(context).colorScheme;
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: double.infinity,
+ height: 52,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: primary ? cs.primary : cs.secondary,
+ borderRadius: BorderRadius.circular(12),
+ border: primary ? null : Border.all(color: cs.border),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: primary ? cs.primaryForeground : cs.foreground,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..e1dcdc2
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,8 @@
+import 'package:flutter/widgets.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'app/bootstrap.dart';
+
+Future main() async {
+ final app = await bootstrap();
+ runApp(ProviderScope(child: app));
+}
diff --git a/packages/android_id/.gitattributes b/packages/android_id/.gitattributes
new file mode 100644
index 0000000..dfe0770
--- /dev/null
+++ b/packages/android_id/.gitattributes
@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto
diff --git a/packages/android_id/.github/dependabot.yml b/packages/android_id/.github/dependabot.yml
new file mode 100644
index 0000000..f8218c7
--- /dev/null
+++ b/packages/android_id/.github/dependabot.yml
@@ -0,0 +1,37 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+
+ - package-ecosystem: "pub"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ ignore:
+ - dependency-name: "*"
+ update-types: ["version-update:semver-minor", "version-update:semver-patch"]
+
+ - package-ecosystem: "pub"
+ directory: "example"
+ schedule:
+ interval: "daily"
+ ignore:
+ - dependency-name: "*"
+ update-types: ["version-update:semver-minor", "version-update:semver-patch"]
+
+ - package-ecosystem: "gradle"
+ directory: "android"
+ schedule:
+ interval: "daily"
+
+ - package-ecosystem: "gradle"
+ directory: "example/android"
+ schedule:
+ interval: "daily"
diff --git a/packages/android_id/.github/workflows/flutter_build_example.yml b/packages/android_id/.github/workflows/flutter_build_example.yml
new file mode 100644
index 0000000..542ef32
--- /dev/null
+++ b/packages/android_id/.github/workflows/flutter_build_example.yml
@@ -0,0 +1,34 @@
+name: Flutter build example
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ build_android:
+ name: Build example for Android
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+ - name: Install Flutter
+ uses: subosito/flutter-action@v2
+ - name: Disable analytics
+ run: flutter config --no-analytics
+ - name: Flutter pub get
+ run: flutter pub get
+ - name: Flutter build appbundle
+ run: |
+ cd example
+ flutter build appbundle --release
diff --git a/packages/android_id/.github/workflows/flutter_checks.yml b/packages/android_id/.github/workflows/flutter_checks.yml
new file mode 100644
index 0000000..a124a06
--- /dev/null
+++ b/packages/android_id/.github/workflows/flutter_checks.yml
@@ -0,0 +1,101 @@
+name: Flutter checks
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ check_pub_dependencies:
+ name: Check dependencies
+ timeout-minutes: 10
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Install Flutter
+ uses: subosito/flutter-action@v2
+ - name: Disable analytics
+ run: flutter config --no-analytics
+ - name: Flutter pub get
+ run: flutter pub get
+
+ check_formatting:
+ name: Check formatting
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Install Flutter
+ uses: subosito/flutter-action@v2
+ - name: Disable analytics
+ run: flutter config --no-analytics
+ - name: Flutter pub get
+ run: flutter pub get
+ - name: Check Dart formatting
+ run: dart format --line-length 80 --set-exit-if-changed .
+
+ analyze:
+ name: Dart analyze
+ timeout-minutes: 10
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Install Flutter
+ uses: subosito/flutter-action@v2
+ - name: Disable analytics
+ run: flutter config --no-analytics
+ - name: Flutter pub get
+ run: flutter pub get
+ - name: Run Dart analyze
+ uses: invertase/github-action-dart-analyzer@v3.0.0
+
+ test:
+ name: Flutter test
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Install Flutter
+ uses: subosito/flutter-action@v2
+ - name: Disable analytics
+ run: flutter config --no-analytics
+ - name: Flutter pub get
+ run: flutter pub get
+ - name: Flutter test
+ run: flutter test
+
+ test_example:
+ name: Flutter test example
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Install Flutter
+ uses: subosito/flutter-action@v2
+ - name: Disable analytics
+ run: flutter config --no-analytics
+ - name: Flutter pub get
+ run: |
+ cd example
+ flutter pub get
+ - name: Flutter test example
+ run: |
+ cd example
+ flutter test
diff --git a/packages/android_id/.github/workflows/pub_publish.yml b/packages/android_id/.github/workflows/pub_publish.yml
new file mode 100644
index 0000000..1630b63
--- /dev/null
+++ b/packages/android_id/.github/workflows/pub_publish.yml
@@ -0,0 +1,18 @@
+name: Pub publish
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ publish:
+ name: Pub publish
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Publish
+ uses: k-paxian/dart-package-publisher@v1.6
+ with:
+ credentialJson: ${{ secrets.CREDENTIALS }}
diff --git a/packages/android_id/.github/workflows/pub_publish_dry_run.yml b/packages/android_id/.github/workflows/pub_publish_dry_run.yml
new file mode 100644
index 0000000..8cb6b5d
--- /dev/null
+++ b/packages/android_id/.github/workflows/pub_publish_dry_run.yml
@@ -0,0 +1,21 @@
+name: Pub publish dry run
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+jobs:
+ publish-dry-run:
+ name: Pub publish dry run
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Publish dry run
+ uses: k-paxian/dart-package-publisher@v1.6
+ with:
+ credentialJson: 'MockCredentialJson'
+ dryRunOnly: true
diff --git a/packages/android_id/.github/workflows/pub_score.yml b/packages/android_id/.github/workflows/pub_score.yml
new file mode 100644
index 0000000..c423c77
--- /dev/null
+++ b/packages/android_id/.github/workflows/pub_score.yml
@@ -0,0 +1,32 @@
+name: Check pub score
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ check-score:
+ name: Check pub score
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ - uses: axel-op/dart-package-analyzer@v3
+ id: workflow
+ with:
+ githubToken: ${{ secrets.GITHUB_TOKEN }}
+ - name: Check score
+ env:
+ TOTAL: ${{ steps.workflow.outputs.total }}
+ TOTAL_MAX: ${{ steps.workflow.outputs.total_max }}
+ run: |
+ MISSING_POINTS=$(( $TOTAL_MAX - $TOTAL ))
+ if (( MISSING_POINTS > 0 ))
+ then
+ echo Score can be improved!
+ exit 1
+ fi
diff --git a/packages/android_id/.gitignore b/packages/android_id/.gitignore
new file mode 100644
index 0000000..f2e83f9
--- /dev/null
+++ b/packages/android_id/.gitignore
@@ -0,0 +1,26 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+.vscode/
+
+# Flutter/Dart/Pub related
+# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
+/pubspec.lock
+**/doc/api/
+.dart_tool/
+.packages
+build/
diff --git a/packages/android_id/.metadata b/packages/android_id/.metadata
new file mode 100644
index 0000000..f3deb51
--- /dev/null
+++ b/packages/android_id/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "a14f74ff3a1cbd521163c5f03d68113d50af93d3"
+ channel: "stable"
+
+project_type: plugin
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3
+ base_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3
+ - platform: android
+ create_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3
+ base_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/packages/android_id/CHANGELOG.md b/packages/android_id/CHANGELOG.md
new file mode 100644
index 0000000..1e24ea5
--- /dev/null
+++ b/packages/android_id/CHANGELOG.md
@@ -0,0 +1,103 @@
+## 0.4.0
+
+* Upgrade Android dependencies (Gradle, AGP, Java, Kotlin)
+* Widen `flutter_lints` constraints
+
+## 0.3.6
+
+* Return `null` on platforms other than Android, instead of throwing an exception
+
+## 0.3.5
+
+* Maintenance release
+* Update `kotlin` and `gradle`
+
+## 0.3.4
+
+* Refactor native code
+
+## 0.3.3
+
+* Add topics to pubspec.yaml
+
+## 0.3.2
+
+* Improve `namespace` fix for `Android Gradle Plugin` versions < 7
+
+## 0.3.1
+
+* Fix `namespace` for `Android Gradle Plugin` versions < 7
+
+## 0.3.0
+
+* Add `namespace` for compatibility with `Android Gradle Plugin 8`
+* Update `Gradle` version
+
+## 0.2.0
+
+* Update `Gradle` version
+* Update `Android Gradle plugin` version
+* Update `Kotlin` version
+* Add Gradle wrapper files
+* Update `pubspec.lock` of example (`url: "https://pub.dev"`)
+
+## 0.1.3+1
+
+* Improve readme: Add explanation for connection of `Android ID` and signing key
+
+## 0.1.3
+
+* Add building of example app to GitHub workflows
+* Remove unneeded code
+
+## 0.1.2
+
+* Add `Flutter test` for example app to workflows
+
+## 0.1.1+1
+
+* Update readme
+
+## 0.1.1
+
+* Update example app dependencies
+
+## 0.1.0
+
+* Rename Android `applicationId`
+* Recreate `android` folder from template
+
+## 0.0.8
+
+* Add more GitHub Actions for checking code quality
+* Update kotlin_version from 1.6.10 to 1.7.20
+* Update gradle from 7.1.2 to 7.3.1
+* Add dependabot
+
+## 0.0.7
+
+* Add Google Play information to README.
+
+## 0.0.6
+
+* Lower minimal sdk version to `2.12.0`.
+
+## 0.0.5
+
+* Documentation typo fix.
+
+## 0.0.4
+
+* Small improvements.
+
+## 0.0.3
+
+* Improve example.
+
+## 0.0.2
+
+* Enhance description to get full pub.dev score.
+
+## 0.0.1
+
+* Initial release.
diff --git a/packages/android_id/LICENSE b/packages/android_id/LICENSE
new file mode 100644
index 0000000..a1092eb
--- /dev/null
+++ b/packages/android_id/LICENSE
@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2022, Joachim Nohl
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file
diff --git a/packages/android_id/README.md b/packages/android_id/README.md
new file mode 100644
index 0000000..36aed12
--- /dev/null
+++ b/packages/android_id/README.md
@@ -0,0 +1,47 @@
+# android_id
+
+[](https://github.com/fluttercommunity/community)
+
+
+
+A Flutter plugin for retrieving the Android ID.
+
+## Getting started
+
+* Add plugin to pubspec.yaml
+* Use it in your code (see examples of all methods below)
+
+## Usage
+
+```dart
+const _androidIdPlugin = AndroidId();
+
+final String? androidId = await _androidIdPlugin.getId();
+```
+
+## Important
+
+Please note that on `Android 8` and above, the `Android ID` is not unique per device, but also per signing key the app was built with:
+
+
+On Android 8.0 (API level 26) and higher versions of the platform, a 64-bit number (expressed as a hexadecimal string), unique to each combination of app-signing key, user, and device.
+
+The value may change if a factory reset is performed on the device or if an APK signing key changes.
+
+
+[Android API reference](https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID)
+
+[Stack Overflow explanation](https://stackoverflow.com/a/43393373)
+
+
+## Google Play
+
+Before using this plugin in your app, make sure to follow Google Play guidelines.
+For example [here](https://support.google.com/googleplay/android-developer/answer/6048248#zippy=%2Cpersistent-identifiers-including-android-id):
+
+
+Persistent identifiers, including Android ID
+
+Use for non-advertising purposes
+You can use persistent identifiers as long as you have a [privacy policy](https://support.google.com/googleplay/android-developer/answer/9859455) and handle the data in accordance with the [Developer Distribution Agreement](https://play.google.com/about/developer-distribution-agreement.html#use) and all applicable privacy laws in the areas where you make your app available.
+
diff --git a/packages/android_id/analysis_options.yaml b/packages/android_id/analysis_options.yaml
new file mode 100644
index 0000000..f9b3034
--- /dev/null
+++ b/packages/android_id/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:flutter_lints/flutter.yaml
diff --git a/packages/android_id/android/.gitignore b/packages/android_id/android/.gitignore
new file mode 100644
index 0000000..161bdcd
--- /dev/null
+++ b/packages/android_id/android/.gitignore
@@ -0,0 +1,9 @@
+*.iml
+.gradle
+/local.properties
+/.idea/workspace.xml
+/.idea/libraries
+.DS_Store
+/build
+/captures
+.cxx
diff --git a/packages/android_id/android/build.gradle b/packages/android_id/android/build.gradle
new file mode 100644
index 0000000..2faa0c2
--- /dev/null
+++ b/packages/android_id/android/build.gradle
@@ -0,0 +1,74 @@
+group = "dev.fluttercommunity.android_id"
+version = "1.0-SNAPSHOT"
+
+buildscript {
+ ext.kotlin_version = "2.0.0"
+ repositories {
+ google()
+ mavenCentral()
+ maven{url'https://repo1.maven.org/maven2/'}
+ }
+
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.1.4")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ maven{url'https://repo1.maven.org/maven2/'}
+ }
+}
+
+apply plugin: "com.android.library"
+apply plugin: "kotlin-android"
+
+android {
+ if (project.android.hasProperty("namespace")) {
+ namespace = "dev.fluttercommunity.android_id"
+ }
+
+ compileSdk = 34
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ sourceSets {
+ main.java.srcDirs += "src/main/kotlin"
+ test.java.srcDirs += "src/test/kotlin"
+ }
+
+ defaultConfig {
+ minSdk = 21
+ }
+
+ dependencies {
+ testImplementation("org.jetbrains.kotlin:kotlin-test")
+ testImplementation("org.mockito:mockito-core:5.12.0")
+ }
+
+ testOptions {
+ unitTests.all {
+ useJUnitPlatform()
+
+ testLogging {
+ events "passed", "skipped", "failed", "standardOut", "standardError"
+ outputs.upToDateWhen {false}
+ showStandardStreams = true
+ }
+ }
+ }
+}
+dependencies {
+// implementation 'com.umeng.umsdk:common:9.7.7'// 必选
+// implementation 'com.umeng.umsdk:asms:1.8.3'// 必选
+}
diff --git a/packages/android_id/android/settings.gradle b/packages/android_id/android/settings.gradle
new file mode 100644
index 0000000..8bbde2e
--- /dev/null
+++ b/packages/android_id/android/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'android_id'
diff --git a/packages/android_id/android/src/main/AndroidManifest.xml b/packages/android_id/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..84a140d
--- /dev/null
+++ b/packages/android_id/android/src/main/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/packages/android_id/android/src/main/kotlin/dev/fluttercommunity/android_id/AndroidIdPlugin.kt b/packages/android_id/android/src/main/kotlin/dev/fluttercommunity/android_id/AndroidIdPlugin.kt
new file mode 100644
index 0000000..d79dfc1
--- /dev/null
+++ b/packages/android_id/android/src/main/kotlin/dev/fluttercommunity/android_id/AndroidIdPlugin.kt
@@ -0,0 +1,279 @@
+package dev.fluttercommunity.android_id
+
+import android.annotation.SuppressLint
+import android.content.ContentResolver
+import android.provider.Settings
+import androidx.annotation.NonNull
+
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler
+import io.flutter.plugin.common.MethodChannel.Result
+import android.content.Context
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.text.TextUtils
+import androidx.core.app.ActivityCompat
+
+import java.io.IOException
+import java.io.InputStreamReader
+import java.io.LineNumberReader
+import java.net.NetworkInterface
+import java.util.Collections
+import java.util.List
+import java.util.ArrayList
+import java.util.Locale
+import android.content.pm.PackageManager
+import android.telephony.TelephonyManager
+//import com.umeng.commonsdk.UMConfigure
+
+/** AndroidIdPlugin */
+class AndroidIdPlugin : FlutterPlugin, MethodCallHandler {
+ /// The MethodChannel that will the communication between Flutter and native Android
+ ///
+ /// This local reference serves to register the plugin with the Flutter Engine and unregister it
+ /// when the Flutter Engine is detached from the Activity
+ private lateinit var channel: MethodChannel
+
+ private lateinit var contentResolver: ContentResolver
+
+ private lateinit var context: Context
+
+ override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
+ contentResolver = flutterPluginBinding.applicationContext.contentResolver
+ channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_id")
+ channel.setMethodCallHandler(this)
+ context = flutterPluginBinding.getApplicationContext()
+ }
+
+ override fun onMethodCall(call: MethodCall, result: Result) {
+ when (call.method) {
+ "getId" -> {
+ try {
+ result.success(getAndroidId())
+ } catch (e: Exception) {
+ result.success("")
+// result.error("ERROR_GETTING_ID", "Failed to get Android ID", e.localizedMessage)
+ }
+ }
+
+ "getImei" -> {
+ try {
+ result.success(getImei())
+ } catch (e: Exception) {
+ result.success("")
+// result.error("ERROR_GETTING_ID", "Failed to get Imei ", e.localizedMessage)
+ }
+ }
+
+ "getMacAddress" -> {
+ try {
+ result.success(getMacAddress())
+ } catch (e: Exception) {
+ result.success("")
+// result.error("ERROR_GETTING_ID", "Failed to get MacAddress", e.localizedMessage)
+ }
+ }
+
+// "getOaid" -> {
+// try {
+// UMConfigure.getOaid(context) { p0 ->
+// if (p0 != null) {
+// result.success(p0)
+// } else {
+// result.success("")
+// }
+// }
+// } catch (e: Exception) {
+// result.success("")
+//// result.error("ERROR_GETTING_ID", "Failed to get MacAddress", e.localizedMessage)
+// }
+// }
+
+ "checkRoot" -> {
+ try {
+ result.success(checkRoot())
+ } catch (e: Exception) {
+ result.success(false)
+// result.error("ERROR_GETTING_ID", "Failed to get MacAddress", e.localizedMessage)
+ }
+ }
+ "checkIsEmulator" -> {
+ try {
+ result.success(checkIsEmulator())
+ } catch (e: Exception) {
+ result.success(false)
+// result.error("ERROR_GETTING_ID", "Failed to get MacAddress", e.localizedMessage)
+ }
+ }
+
+ else -> result.notImplemented()
+ }
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ channel.setMethodCallHandler(null)
+ }
+
+ // Fetch the Android ID while suppressing lint warning about hardware IDs
+ @SuppressLint("HardwareIds")
+ private fun getAndroidId(): String {
+ return Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
+ }
+
+ private fun getImei(): String? {
+ val tm: TelephonyManager =
+ context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
+ var IMEIStr = ""
+ if (ActivityCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.READ_PHONE_STATE
+ ) !== PackageManager.PERMISSION_GRANTED
+ ) {
+ } else {
+ try {
+ IMEIStr = tm.getDeviceId()
+ if (TextUtils.isEmpty(IMEIStr)) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ IMEIStr = tm.getImei()
+ }
+ }
+ } catch (e: java.lang.Exception) {
+ }
+ }
+ return IMEIStr
+ }
+
+ private fun getMacAddress(): String? {
+ var macNo = MacUtil().getMac(context)
+ return macNo
+ }
+
+
+ private fun checkRoot(): Boolean {
+ return false
+ }
+
+ private fun checkIsEmulator(): Boolean {
+ return false
+ }
+
+}
+
+class MacUtil {
+ /**
+ * Android 6.0 之前(不包括6.0)获取mac地址
+ * 必须的权限
+ *
+ * @param context * @return
+ */
+ @SuppressLint("MissingPermission")
+ fun getMacDefault(context: Context?): String? {
+ var mac = ""
+ if (context == null) {
+ return mac
+ }
+ val wifi: WifiManager =
+ context.getApplicationContext().getSystemService(Context.WIFI_SERVICE) as WifiManager
+ var info: WifiInfo? = null
+ try {
+ info = wifi.getConnectionInfo()
+ } catch (e: java.lang.Exception) {
+ e.printStackTrace()
+ }
+
+ if (info == null) {
+ return null
+ }
+ mac = info.getMacAddress()
+ if (!TextUtils.isEmpty(mac)) {
+ mac = mac.uppercase()
+ }
+ return mac
+ }
+
+ val macAddress: String?
+ /**
+ * Android 6.0-Android 7.0 获取mac地址
+ */
+ get() {
+ var macSerial: String? = null
+ var str = ""
+
+ try {
+ val pp: java.lang.Process =
+ java.lang.Runtime.getRuntime().exec("cat/sys/class/net/wlan0/address")
+ val ir: InputStreamReader = InputStreamReader(pp.getInputStream())
+ val input: LineNumberReader = LineNumberReader(ir)
+
+ while (null != str) {
+ str = input.readLine()
+ if (str != null) {
+ macSerial = str.trim { it <= ' ' } //去空格
+ break
+ }
+ }
+ } catch (ex: IOException) {
+ // 赋予默认值
+ ex.printStackTrace()
+ }
+
+ return macSerial
+ }
+
+ /**
+ * 获取mac地址(适配所有Android版本)
+ *
+ * @return
+ */
+ fun getMac(context: Context?): String? {
+ var mac: String? = ""
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ mac = getMacDefault(context)
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ mac = macAddress
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ mac = macFromHardware
+ }
+ return mac
+ }
+
+ companion object {
+ private val macFromHardware: String?
+ /**
+ * Android 7.0之后获取Mac地址
+ * 遍历循环所有的网络接口,找到接口是 wlan0
+ * 必须的权限
+ *
+ * @return
+ */
+ get() {
+ try {
+ val all: ArrayList =
+ Collections.list(NetworkInterface.getNetworkInterfaces())
+ for (nif in all) {
+ if (!nif.getName().equals("wlan0", ignoreCase = true)) {
+ continue
+ }
+
+ val macBytes: ByteArray = nif.getHardwareAddress() ?: return null
+
+ val res1: java.lang.StringBuilder = java.lang.StringBuilder()
+ for (b in macBytes) {
+ res1.append(String.format("%02X:", b))
+ }
+
+ if (res1.length > 0) {
+ res1.deleteCharAt(res1.length - 1)
+ }
+ return res1.toString()
+ }
+ } catch (e: java.lang.Exception) {
+ e.printStackTrace()
+ }
+ return null
+ }
+ }
+}
diff --git a/packages/android_id/lib/android_id.dart b/packages/android_id/lib/android_id.dart
new file mode 100644
index 0000000..e3fccd1
--- /dev/null
+++ b/packages/android_id/lib/android_id.dart
@@ -0,0 +1,58 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+/// The plugin class for retrieving the Android ID.
+class AndroidId {
+ const AndroidId();
+
+ /// The method channel used to interact with the native platform.
+ static const _methodChannel = MethodChannel('android_id');
+
+ /// Calls the native method to retrieve the Android ID.
+ Future getId() async {
+ final isAndroid =
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ if (!isAndroid) return null;
+
+ return _methodChannel.invokeMethod('getId');
+ }
+
+ Future getImei() async {
+ final isAndroid =
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ if (!isAndroid) return null;
+
+ return _methodChannel.invokeMethod('getImei');
+ }
+
+ Future getMaAddress() async {
+ final isAndroid =
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ if (!isAndroid) return null;
+
+ return _methodChannel.invokeMethod('getMacAddress');
+ }
+ // Future getOaid() async {
+ // final isAndroid =
+ // !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ // if (!isAndroid) return null;
+ //
+ // return _methodChannel.invokeMethod('getOaid');
+ // }
+
+ Future isRoot() async {
+ final isAndroid =
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ if (!isAndroid) return null;
+
+ return _methodChannel.invokeMethod('checkRoot');
+ }
+
+ Future isEmulator() async {
+ final isAndroid =
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ if (!isAndroid) return null;
+
+ return _methodChannel.invokeMethod('checkIsEmulator');
+ }
+}
diff --git a/packages/android_id/pubspec.yaml b/packages/android_id/pubspec.yaml
new file mode 100644
index 0000000..e835129
--- /dev/null
+++ b/packages/android_id/pubspec.yaml
@@ -0,0 +1,36 @@
+name: android_id
+description: A Flutter plugin for getting the Android ID. Only for Android.
+
+repository: https://github.com/fluttercommunity/android_id
+issue_tracker: https://github.com/fluttercommunity/android_id/issues
+
+maintainer: Joachim Nohl (@nohli)
+
+topics:
+ - androidid
+ - device
+ - identifier
+ - android
+ - native
+
+version: 0.4.0
+
+environment:
+ sdk: '>=2.12.0 <4.0.0'
+ flutter: '>=2.0.0'
+
+dependencies:
+ flutter:
+ sdk: flutter
+
+dev_dependencies:
+ flutter_lints: '>=2.0.0 <4.0.0'
+ flutter_test:
+ sdk: flutter
+
+flutter:
+ plugin:
+ platforms:
+ android:
+ package: dev.fluttercommunity.android_id
+ pluginClass: AndroidIdPlugin
diff --git a/packages/android_id/test/android_id_test.dart b/packages/android_id/test/android_id_test.dart
new file mode 100644
index 0000000..8c015e6
--- /dev/null
+++ b/packages/android_id/test/android_id_test.dart
@@ -0,0 +1,26 @@
+import 'package:android_id/android_id.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ const plugin = AndroidId();
+ const channel = MethodChannel('android_id');
+
+ TestWidgetsFlutterBinding.ensureInitialized();
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+
+ setUp(() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, (methodCall) async => '42');
+ });
+
+ tearDown(() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, null);
+ });
+
+ test('getAndroidId', () async {
+ expect(await plugin.getId(), '42');
+ });
+}
diff --git a/packages/android_oaid/.gitignore b/packages/android_oaid/.gitignore
new file mode 100644
index 0000000..f2e83f9
--- /dev/null
+++ b/packages/android_oaid/.gitignore
@@ -0,0 +1,26 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+.vscode/
+
+# Flutter/Dart/Pub related
+# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
+/pubspec.lock
+**/doc/api/
+.dart_tool/
+.packages
+build/
diff --git a/packages/android_oaid/CHANGELOG.md b/packages/android_oaid/CHANGELOG.md
new file mode 100644
index 0000000..d0bd041
--- /dev/null
+++ b/packages/android_oaid/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.0.1
+
+* Initial release.
diff --git a/packages/android_oaid/LICENSE b/packages/android_oaid/LICENSE
new file mode 100644
index 0000000..a1092eb
--- /dev/null
+++ b/packages/android_oaid/LICENSE
@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2022, Joachim Nohl
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file
diff --git a/packages/android_oaid/README.md b/packages/android_oaid/README.md
new file mode 100644
index 0000000..87a8f5d
--- /dev/null
+++ b/packages/android_oaid/README.md
@@ -0,0 +1,15 @@
+# flutter_oaid
+
+A new Flutter plugin project.
+
+## Getting Started
+
+This project is a starting point for a Flutter
+[plug-in package](https://flutter.dev/developing-packages/),
+a specialized package that includes platform-specific implementation code for
+Android and/or iOS.
+
+For help getting started with Flutter development, view the
+[online documentation](https://flutter.dev/docs), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
+
diff --git a/packages/android_oaid/analysis_options.yaml b/packages/android_oaid/analysis_options.yaml
new file mode 100644
index 0000000..f9b3034
--- /dev/null
+++ b/packages/android_oaid/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:flutter_lints/flutter.yaml
diff --git a/packages/android_oaid/android/.gitignore b/packages/android_oaid/android/.gitignore
new file mode 100644
index 0000000..161bdcd
--- /dev/null
+++ b/packages/android_oaid/android/.gitignore
@@ -0,0 +1,9 @@
+*.iml
+.gradle
+/local.properties
+/.idea/workspace.xml
+/.idea/libraries
+.DS_Store
+/build
+/captures
+.cxx
diff --git a/packages/android_oaid/android/build.gradle b/packages/android_oaid/android/build.gradle
new file mode 100644
index 0000000..ac30102
--- /dev/null
+++ b/packages/android_oaid/android/build.gradle
@@ -0,0 +1,74 @@
+group = "dev.fluttercommunity.android_oaid"
+version = "1.0-SNAPSHOT"
+
+buildscript {
+ ext.kotlin_version = "2.0.0"
+ repositories {
+ google()
+ mavenCentral()
+ maven{url'https://repo1.maven.org/maven2/'}
+ }
+
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.1.4")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ maven{url'https://repo1.maven.org/maven2/'}
+ }
+}
+
+apply plugin: "com.android.library"
+apply plugin: "kotlin-android"
+
+android {
+ if (project.android.hasProperty("namespace")) {
+ namespace = "dev.fluttercommunity.android_oaid"
+ }
+
+ compileSdk = 34
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ sourceSets {
+ main.java.srcDirs += "src/main/kotlin"
+ test.java.srcDirs += "src/test/kotlin"
+ }
+
+ defaultConfig {
+ minSdk = 21
+ }
+
+ dependencies {
+ testImplementation("org.jetbrains.kotlin:kotlin-test")
+ testImplementation("org.mockito:mockito-core:5.12.0")
+ }
+
+ testOptions {
+ unitTests.all {
+ useJUnitPlatform()
+
+ testLogging {
+ events "passed", "skipped", "failed", "standardOut", "standardError"
+ outputs.upToDateWhen {false}
+ showStandardStreams = true
+ }
+ }
+ }
+}
+dependencies {
+ implementation 'com.umeng.umsdk:common:9.7.7'// 必选
+ implementation 'com.umeng.umsdk:asms:1.8.3'// 必选
+}
diff --git a/packages/android_oaid/android/settings.gradle b/packages/android_oaid/android/settings.gradle
new file mode 100644
index 0000000..0af1752
--- /dev/null
+++ b/packages/android_oaid/android/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'android_oaid'
diff --git a/packages/android_oaid/android/src/main/AndroidManifest.xml b/packages/android_oaid/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..57a3bdb
--- /dev/null
+++ b/packages/android_oaid/android/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/android_oaid/android/src/main/kotlin/dev/fluttercommunity/android_oaid/AndroidOaidPlugin.kt b/packages/android_oaid/android/src/main/kotlin/dev/fluttercommunity/android_oaid/AndroidOaidPlugin.kt
new file mode 100644
index 0000000..31e4b49
--- /dev/null
+++ b/packages/android_oaid/android/src/main/kotlin/dev/fluttercommunity/android_oaid/AndroidOaidPlugin.kt
@@ -0,0 +1,59 @@
+package dev.fluttercommunity.android_oaid
+
+import android.content.ContentResolver
+
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler
+import io.flutter.plugin.common.MethodChannel.Result
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+
+import com.umeng.commonsdk.UMConfigure
+
+/** AndroidOaidPlugin */
+class AndroidOaidPlugin : FlutterPlugin, MethodCallHandler {
+
+ private lateinit var channel: MethodChannel
+
+ private lateinit var contentResolver: ContentResolver
+
+ private lateinit var context: Context
+
+ override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
+ contentResolver = flutterPluginBinding.applicationContext.contentResolver
+ channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_oaid")
+ channel.setMethodCallHandler(this)
+ context = flutterPluginBinding.getApplicationContext()
+ }
+
+ override fun onMethodCall(call: MethodCall, result: Result) {
+ when (call.method) {
+ "getOaid" -> {
+ try {
+ UMConfigure.getOaid(context) { p0 ->
+ Handler(Looper.getMainLooper()).post {
+ if (p0 != null) {
+ result.success(p0)
+ } else {
+ result.success("")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Handler(Looper.getMainLooper()).post {
+ result.success("")
+ }
+ }
+ }
+
+ else -> result.notImplemented()
+ }
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ channel.setMethodCallHandler(null)
+ }
+}
diff --git a/packages/android_oaid/lib/android_oaid.dart b/packages/android_oaid/lib/android_oaid.dart
new file mode 100644
index 0000000..865a2e4
--- /dev/null
+++ b/packages/android_oaid/lib/android_oaid.dart
@@ -0,0 +1,18 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+class AndroidOaidId {
+ const AndroidOaidId();
+
+ static const _methodChannel = MethodChannel('android_oaid');
+
+
+ Future getOaid() async {
+ final isAndroid =
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ if (!isAndroid) return null;
+
+ return _methodChannel.invokeMethod('getOaid');
+ }
+
+}
diff --git a/packages/android_oaid/pubspec.yaml b/packages/android_oaid/pubspec.yaml
new file mode 100644
index 0000000..1317f24
--- /dev/null
+++ b/packages/android_oaid/pubspec.yaml
@@ -0,0 +1,26 @@
+name: android_oaid
+description: A Flutter plugin for getting the Android OAID. Only for Android.
+
+maintainer: Joachim Nohl (@nohli)
+
+version: 0.4.0
+
+environment:
+ sdk: '>=2.12.0 <4.0.0'
+ flutter: '>=2.0.0'
+
+dependencies:
+ flutter:
+ sdk: flutter
+
+dev_dependencies:
+ flutter_lints: '>=2.0.0 <4.0.0'
+ flutter_test:
+ sdk: flutter
+
+flutter:
+ plugin:
+ platforms:
+ android:
+ package: dev.fluttercommunity.android_oaid
+ pluginClass: AndroidOaidPlugin
diff --git a/packages/android_oaid/test/android_id_test.dart b/packages/android_oaid/test/android_id_test.dart
new file mode 100644
index 0000000..8fc0dd9
--- /dev/null
+++ b/packages/android_oaid/test/android_id_test.dart
@@ -0,0 +1,26 @@
+import 'package:android_oaid/android_oaid.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ const plugin = AndroidOaidId();
+ const channel = MethodChannel('android_oaid');
+
+ TestWidgetsFlutterBinding.ensureInitialized();
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+
+ setUp(() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, (methodCall) async => '42');
+ });
+
+ tearDown(() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, null);
+ });
+
+ test('getAndroidId', () async {
+ expect(await plugin.getOaid(), '42');
+ });
+}
diff --git a/packages/flutter_paid/.gitignore b/packages/flutter_paid/.gitignore
new file mode 100644
index 0000000..ac5aa98
--- /dev/null
+++ b/packages/flutter_paid/.gitignore
@@ -0,0 +1,29 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
+/pubspec.lock
+**/doc/api/
+.dart_tool/
+build/
diff --git a/packages/flutter_paid/.metadata b/packages/flutter_paid/.metadata
new file mode 100644
index 0000000..3a8bfa6
--- /dev/null
+++ b/packages/flutter_paid/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "abb292a07e20d696c4568099f918f6c5f330e6b0"
+ channel: "stable"
+
+project_type: plugin
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0
+ base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0
+ - platform: ios
+ create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0
+ base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/packages/flutter_paid/CHANGELOG.md b/packages/flutter_paid/CHANGELOG.md
new file mode 100644
index 0000000..41cc7d8
--- /dev/null
+++ b/packages/flutter_paid/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.0.1
+
+* TODO: Describe initial release.
diff --git a/packages/flutter_paid/LICENSE b/packages/flutter_paid/LICENSE
new file mode 100644
index 0000000..ba75c69
--- /dev/null
+++ b/packages/flutter_paid/LICENSE
@@ -0,0 +1 @@
+TODO: Add your license here.
diff --git a/packages/flutter_paid/README.md b/packages/flutter_paid/README.md
new file mode 100644
index 0000000..ebc7b79
--- /dev/null
+++ b/packages/flutter_paid/README.md
@@ -0,0 +1,15 @@
+# flutter_paid
+
+A new Flutter plugin project.
+
+## Getting Started
+
+This project is a starting point for a Flutter
+[plug-in package](https://flutter.dev/developing-packages/),
+a specialized package that includes platform-specific implementation code for
+Android and/or iOS.
+
+For help getting started with Flutter development, view the
+[online documentation](https://flutter.dev/docs), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
+
diff --git a/packages/flutter_paid/analysis_options.yaml b/packages/flutter_paid/analysis_options.yaml
new file mode 100644
index 0000000..a5744c1
--- /dev/null
+++ b/packages/flutter_paid/analysis_options.yaml
@@ -0,0 +1,4 @@
+include: package:flutter_lints/flutter.yaml
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/packages/flutter_paid/example/.gitignore b/packages/flutter_paid/example/.gitignore
new file mode 100644
index 0000000..29a3a50
--- /dev/null
+++ b/packages/flutter_paid/example/.gitignore
@@ -0,0 +1,43 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/packages/flutter_paid/example/README.md b/packages/flutter_paid/example/README.md
new file mode 100644
index 0000000..1662596
--- /dev/null
+++ b/packages/flutter_paid/example/README.md
@@ -0,0 +1,16 @@
+# flutter_paid_example
+
+Demonstrates how to use the flutter_paid plugin.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/packages/flutter_paid/example/analysis_options.yaml b/packages/flutter_paid/example/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/packages/flutter_paid/example/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/packages/flutter_paid/example/integration_test/plugin_integration_test.dart b/packages/flutter_paid/example/integration_test/plugin_integration_test.dart
new file mode 100644
index 0000000..5a6e347
--- /dev/null
+++ b/packages/flutter_paid/example/integration_test/plugin_integration_test.dart
@@ -0,0 +1,25 @@
+// This is a basic Flutter integration test.
+//
+// Since integration tests run in a full Flutter application, they can interact
+// with the host side of a plugin implementation, unlike Dart unit tests.
+//
+// For more information about Flutter integration tests, please see
+// https://docs.flutter.dev/cookbook/testing/integration/introduction
+
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import 'package:flutter_paid/flutter_paid.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('getPlatformVersion test', (WidgetTester tester) async {
+ final FlutterPaid plugin = FlutterPaid();
+ final String? version = await plugin.getPlatformVersion();
+ // The version string depends on the host platform running the test, so
+ // just assert that some non-empty string is returned.
+ expect(version?.isNotEmpty, true);
+ });
+}
diff --git a/packages/flutter_paid/example/ios/.gitignore b/packages/flutter_paid/example/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/packages/flutter_paid/example/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/packages/flutter_paid/example/ios/Flutter/AppFrameworkInfo.plist b/packages/flutter_paid/example/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..7c56964
--- /dev/null
+++ b/packages/flutter_paid/example/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+ MinimumOSVersion
+ 12.0
+
+
diff --git a/packages/flutter_paid/example/ios/Flutter/Debug.xcconfig b/packages/flutter_paid/example/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..ec97fc6
--- /dev/null
+++ b/packages/flutter_paid/example/ios/Flutter/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"
diff --git a/packages/flutter_paid/example/ios/Flutter/Release.xcconfig b/packages/flutter_paid/example/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..c4855bf
--- /dev/null
+++ b/packages/flutter_paid/example/ios/Flutter/Release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"
diff --git a/packages/flutter_paid/example/ios/Podfile b/packages/flutter_paid/example/ios/Podfile
new file mode 100644
index 0000000..c9339a0
--- /dev/null
+++ b/packages/flutter_paid/example/ios/Podfile
@@ -0,0 +1,41 @@
+# Uncomment this line to define a global platform for your project
+# platform :ios, '12.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ end
+end
diff --git a/packages/flutter_paid/example/ios/Podfile.lock b/packages/flutter_paid/example/ios/Podfile.lock
new file mode 100644
index 0000000..e9e715f
--- /dev/null
+++ b/packages/flutter_paid/example/ios/Podfile.lock
@@ -0,0 +1,28 @@
+PODS:
+ - Flutter (1.0.0)
+ - flutter_paid (0.0.1):
+ - Flutter
+ - integration_test (0.0.1):
+ - Flutter
+
+DEPENDENCIES:
+ - Flutter (from `Flutter`)
+ - flutter_paid (from `.symlinks/plugins/flutter_paid/ios`)
+ - integration_test (from `.symlinks/plugins/integration_test/ios`)
+
+EXTERNAL SOURCES:
+ Flutter:
+ :path: Flutter
+ flutter_paid:
+ :path: ".symlinks/plugins/flutter_paid/ios"
+ integration_test:
+ :path: ".symlinks/plugins/integration_test/ios"
+
+SPEC CHECKSUMS:
+ Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
+ flutter_paid: 4461d2236314bded6afb221cd9d69b7f7ca51fc6
+ integration_test: 13825b8a9334a850581300559b8839134b124670
+
+PODFILE CHECKSUM: 6e700dec67e6deac9b1c69bb14c49a2217a12d15
+
+COCOAPODS: 1.14.3
diff --git a/packages/flutter_paid/example/ios/Runner.xcodeproj/project.pbxproj b/packages/flutter_paid/example/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..343e2db
--- /dev/null
+++ b/packages/flutter_paid/example/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,704 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1200A014D953763B34535EB3 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CB8157ECFBC61BAC8F6DDD98 /* libPods-Runner.a */; };
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C80F4294D02FB00263BE5 /* RunnerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 331C80F3294D02FB00263BE5 /* RunnerTests.m */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74D02B72575A8C8A1492AE70 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BF91C3F82401F1EC86B8BDBB /* libPods-RunnerTests.a */; };
+ 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; };
+ 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C80F5294D02FB00263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 0733CEEBA4D517AC13DB7032 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C80F1294D02FB00263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 331C80F3294D02FB00263BE5 /* RunnerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RunnerTests.m; sourceTree = ""; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; };
+ 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ AA2F596DEE97CE584753926C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
+ BF91C3F82401F1EC86B8BDBB /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ C4F40650D87222AA05FBB409 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ CB8157ECFBC61BAC8F6DDD98 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ DB6D020E7BD00B83208B9A03 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
+ F5AC35C52992172B47892D71 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
+ F606F420C75B7A999C3003F1 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 331C80EE294D02FB00263BE5 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74D02B72575A8C8A1492AE70 /* libPods-RunnerTests.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 1200A014D953763B34535EB3 /* libPods-Runner.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C80F2294D02FB00263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C80F3294D02FB00263BE5 /* RunnerTests.m */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 3D1BD31C0A91C924BAF12C36 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ CB8157ECFBC61BAC8F6DDD98 /* libPods-Runner.a */,
+ BF91C3F82401F1EC86B8BDBB /* libPods-RunnerTests.a */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 331C80F2294D02FB00263BE5 /* RunnerTests */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ F4667D52B031A4D8AAF7780D /* Pods */,
+ 3D1BD31C0A91C924BAF12C36 /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C80F1294D02FB00263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */,
+ 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */,
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 97C146F11CF9000F007C117D /* Supporting Files */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ 97C146F11CF9000F007C117D /* Supporting Files */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146F21CF9000F007C117D /* main.m */,
+ );
+ name = "Supporting Files";
+ sourceTree = "";
+ };
+ F4667D52B031A4D8AAF7780D /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ C4F40650D87222AA05FBB409 /* Pods-Runner.debug.xcconfig */,
+ AA2F596DEE97CE584753926C /* Pods-Runner.release.xcconfig */,
+ F5AC35C52992172B47892D71 /* Pods-Runner.profile.xcconfig */,
+ DB6D020E7BD00B83208B9A03 /* Pods-RunnerTests.debug.xcconfig */,
+ F606F420C75B7A999C3003F1 /* Pods-RunnerTests.release.xcconfig */,
+ 0733CEEBA4D517AC13DB7032 /* Pods-RunnerTests.profile.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C80F0294D02FB00263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C80F7294D02FB00263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ AFF8B152CD6A1DD5D70EC498 /* [CP] Check Pods Manifest.lock */,
+ 331C80ED294D02FB00263BE5 /* Sources */,
+ 331C80EE294D02FB00263BE5 /* Frameworks */,
+ 331C80EF294D02FB00263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C80F6294D02FB00263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C80F1294D02FB00263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 99BA78E65A21CE04C8370CA7 /* [CP] Check Pods Manifest.lock */,
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C80F0294D02FB00263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C80F0294D02FB00263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C80EF294D02FB00263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 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";
+ };
+ 99BA78E65A21CE04C8370CA7 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ 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;
+ };
+ AFF8B152CD6A1DD5D70EC498 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ 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;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C80ED294D02FB00263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C80F4294D02FB00263BE5 /* RunnerTests.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */,
+ 97C146F31CF9000F007C117D /* main.m in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C80F6294D02FB00263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C80F5294D02FB00263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 979UK3QQJ3;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaidExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C80F8294D02FB00263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = DB6D020E7BD00B83208B9A03 /* Pods-RunnerTests.debug.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaidExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C80F9294D02FB00263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = F606F420C75B7A999C3003F1 /* Pods-RunnerTests.release.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaidExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C80FA294D02FB00263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 0733CEEBA4D517AC13DB7032 /* Pods-RunnerTests.profile.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaidExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 979UK3QQJ3;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaidExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 979UK3QQJ3;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterPaidExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C80F7294D02FB00263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C80F8294D02FB00263BE5 /* Debug */,
+ 331C80F9294D02FB00263BE5 /* Release */,
+ 331C80FA294D02FB00263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/packages/flutter_paid/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/flutter_paid/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/packages/flutter_paid/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+