commit 56830e94ec5e682924b68da020d74e0921cd1f4f
Author: andyesfly <35127884@qq.com>
Date: Tue Oct 6 11:54:10 2026 +0800
feat: 完成智能电网电工端 Flutter 应用初始化
创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3820a95
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,45 @@
+# 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/
+.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..6738224
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,33 @@
+# 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: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ - platform: android
+ create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ - platform: ios
+ create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+ base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+
+ # 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/README.md b/README.md
new file mode 100644
index 0000000..d9abab9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# smart_grid_iot_app_flutter
+
+A new Flutter project.
+
+## 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:
+
+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+
+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/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/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/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /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..200095c
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,44 @@
+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")
+}
+
+android {
+ namespace = "com.voltlink.smart_grid_iot_app_flutter"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.voltlink.smart_grid_iot_app_flutter"
+ // 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
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
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..4d14762
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/voltlink/smart_grid_iot_app_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/voltlink/smart_grid_iot_app_flutter/MainActivity.kt
new file mode 100644
index 0000000..ff1eecc
--- /dev/null
+++ b/android/app/src/main/kotlin/com/voltlink/smart_grid_iot_app_flutter/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.voltlink.smart_grid_iot_app_flutter
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
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..f74085f
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
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..304732f
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
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..db77bb4
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-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
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-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
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-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
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-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
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/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
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..cb1ef88
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
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/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..4622f69
--- /dev/null
+++ b/android/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..fbee1d8
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e4ef43f
--- /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.14-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..ca7fe06
--- /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.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/assets/fonts/iconfont.ttf b/assets/fonts/iconfont.ttf
new file mode 100644
index 0000000..f894672
Binary files /dev/null and b/assets/fonts/iconfont.ttf differ
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..391a902
--- /dev/null
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+
+
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..8518b4b
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,620 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 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 */; };
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
+ 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 = ""; };
+ 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; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; 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 = ""; };
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.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 = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ 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 */,
+ );
+ 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 */,
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ 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 = (
+ 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 = {
+ 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 */
+ 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";
+ };
+/* 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 */,
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ 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.voltlink.smartGridIotAppFlutter.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;
+ 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.voltlink.smartGridIotAppFlutter.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;
+ 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.voltlink.smartGridIotAppFlutter.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;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ 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;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.voltlink.smartGridIotAppFlutter;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ 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..1d526a1
--- /dev/null
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
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..c30b367
--- /dev/null
+++ b/ios/Runner/AppDelegate.swift
@@ -0,0 +1,16 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
+}
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..d36b1fa
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
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..dc9ada4
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..7353c41
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..797d452
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..6ed2d93
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..4cd7b00
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..fe73094
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..321773c
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-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..797d452
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..502f463
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..0ec3034
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..0ec3034
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..e9f5fea
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-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..84ac32a
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..8953cba
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..0467bf1
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/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
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..9da19ea
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..9da19ea
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..9da19ea
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/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..f2e259c
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..52ab127
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,70 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Smart Grid Iot App Flutter
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ smart_grid_iot_app_flutter
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
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/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..b9ce8ea
--- /dev/null
+++ b/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
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..06723f0
--- /dev/null
+++ b/lib/app/app.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../core/app_keys.dart';
+import '../core/theme/app_theme.dart';
+import '../core/theme/app_typography.dart';
+import 'router.dart';
+
+class VoltLinkApp extends ConsumerWidget {
+ const VoltLinkApp({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final router = ref.watch(routerProvider);
+
+ return MaterialApp.router(
+ title: 'VoltLink',
+ debugShowCheckedModeBanner: false,
+ theme: AppTheme.build(),
+ routerConfig: router,
+ scaffoldMessengerKey: AppKeys.scaffoldMessenger,
+ locale: const Locale('zh', 'CN'),
+ supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],
+ localizationsDelegates: const [
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ // 对应源 `page` 上的全局字体设置。
+ builder: (context, child) => DefaultTextStyle(
+ style: appDefaultTextStyle,
+ child: child ?? const SizedBox.shrink(),
+ ),
+ );
+ }
+}
diff --git a/lib/app/router.dart b/lib/app/router.dart
new file mode 100644
index 0000000..d034b84
--- /dev/null
+++ b/lib/app/router.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../core/app_keys.dart';
+import '../features/auth/application/session_controller.dart';
+import '../features/auth/presentation/login_page.dart';
+import '../features/auth/presentation/register_page.dart';
+import '../features/notice/presentation/notice_center_page.dart';
+import '../features/order/presentation/order_detail_page.dart';
+import 'tabs_page.dart';
+
+/// 会话语义变化时驱动 go_router 重新执行 `redirect`。
+class _SessionRefresh extends ChangeNotifier {
+ void bump() => notifyListeners();
+}
+
+/// 路由表,对应源 `pages.json` 的 5 个页面。
+///
+/// 除登录/注册外,其余页面统一使用主题里的 `CupertinoPageTransitionsBuilder`
+/// (新页面从右侧滑入),与源 `PageTransition.vue` 的表现一致。
+final routerProvider = Provider((ref) {
+ final refresh = _SessionRefresh();
+ ref.listen(sessionProvider, (_, _) => refresh.bump());
+ ref.onDispose(refresh.dispose);
+
+ return GoRouter(
+ navigatorKey: AppKeys.rootNavigator,
+ initialLocation: '/login',
+ refreshListenable: refresh,
+ redirect: (context, state) {
+ final loggedIn = ref.read(sessionProvider).isLoggedIn;
+ final atAuth =
+ state.matchedLocation == '/login' || state.matchedLocation == '/register';
+ if (!loggedIn) return atAuth ? null : '/login';
+ if (atAuth) return '/tabs';
+ return null;
+ },
+ routes: [
+ GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
+ GoRoute(
+ path: '/register',
+ builder: (context, state) => const RegisterPage(),
+ ),
+ GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
+
+ // 消息中心:顶栏铃铛入口
+ GoRoute(
+ path: '/notice',
+ builder: (context, state) => const NoticeCenterPage(),
+ ),
+
+ // 订单详情:source 为 repair|install|maintenance|survey
+ GoRoute(
+ path: '/order/:source/:id',
+ builder: (context, state) => OrderDetailPage(
+ source: state.pathParameters['source']!,
+ id: int.parse(state.pathParameters['id']!),
+ ),
+ ),
+ ],
+ );
+});
diff --git a/lib/app/tab_index.dart b/lib/app/tab_index.dart
new file mode 100644
index 0000000..948438a
--- /dev/null
+++ b/lib/app/tab_index.dart
@@ -0,0 +1,30 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+/// 底部导航的三个 Tab,对应 `components/CustomTabBar.vue` 的 3 个入口。
+enum AppTab { order, earnings, profile }
+
+extension AppTabKey on AppTab {
+ /// 与 `XBottomNav.items` 中的 key 一致。
+ String get key => switch (this) {
+ AppTab.order => 'order',
+ AppTab.earnings => 'earnings',
+ AppTab.profile => 'profile',
+ };
+}
+
+/// 当前 Tab。源里每个页面各自挂 `CustomTabBar` 并用 `redirectTo` 切换,
+/// Flutter 端改为单容器 + [IndexedStack],避免页面重建、保留各自滚动位置。
+class TabIndexController extends Notifier {
+ @override
+ AppTab build() => AppTab.order;
+
+ void select(AppTab tab) => state = tab;
+
+ void selectByKey(String key) {
+ state = AppTab.values.firstWhere((t) => t.key == key, orElse: () => state);
+ }
+}
+
+final tabIndexProvider = NotifierProvider(
+ TabIndexController.new,
+);
diff --git a/lib/app/tabs_page.dart b/lib/app/tabs_page.dart
new file mode 100644
index 0000000..4a9bc0f
--- /dev/null
+++ b/lib/app/tabs_page.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../core/theme/app_colors.dart';
+import '../features/earnings/presentation/earnings_tab.dart';
+import '../features/order/presentation/order_tab.dart';
+import '../features/profile/presentation/profile_tab.dart';
+import '../shared/widgets/x_bottom_nav.dart';
+import 'tab_index.dart';
+
+/// 3 个 Tab 的常驻容器。源项目每个页面各自挂 `CustomTabBar`,
+/// 这里统一收口到容器层,用 [IndexedStack] 保留各页内部状态。
+class TabsPage extends ConsumerWidget {
+ const TabsPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final current = ref.watch(tabIndexProvider);
+
+ return Scaffold(
+ backgroundColor: AppColors.bgPage,
+ body: Stack(
+ children: [
+ Positioned.fill(
+ child: IndexedStack(
+ index: current.index,
+ children: const [OrderTab(), EarningsTab(), ProfileTab()],
+ ),
+ ),
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: XBottomNav(
+ current: current.key,
+ onChanged: (key) =>
+ ref.read(tabIndexProvider.notifier).selectByKey(key),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/core/app_keys.dart b/lib/core/app_keys.dart
new file mode 100644
index 0000000..b7c62b4
--- /dev/null
+++ b/lib/core/app_keys.dart
@@ -0,0 +1,7 @@
+import 'package:flutter/material.dart';
+
+/// 全局 Key:供非 Widget 层(拦截器、工具函数)访问 Navigator / ScaffoldMessenger。
+abstract final class AppKeys {
+ static final rootNavigator = GlobalKey();
+ static final scaffoldMessenger = GlobalKey();
+}
diff --git a/lib/core/env.dart b/lib/core/env.dart
new file mode 100644
index 0000000..1cfc91c
--- /dev/null
+++ b/lib/core/env.dart
@@ -0,0 +1,35 @@
+/// 运行期配置。
+///
+/// 与 uni-app 版 `utils/request.ts` 的地址策略一致:App 端直连后端。
+/// 真机联调时用 `--dart-define=API_BASE_URL=http://<局域网IP>:7001/api` 覆盖默认值。
+///
+/// 登录页「服务端设置」(仅调试版可见)保存的 IP+端口优先级更高,
+/// 解析顺序见 [resolveBaseUrl]。
+abstract final class Env {
+ /// 编译期默认地址,作为兜底方案。
+ static const String apiBaseUrl = String.fromEnvironment(
+ 'API_BASE_URL',
+ defaultValue: 'http://192.168.3.29:7001/api',
+ );
+
+ static final Uri _default = Uri.parse(apiBaseUrl);
+
+ /// 默认主机,取自 [apiBaseUrl]。
+ static String get defaultHost => _default.host;
+
+ /// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7007。
+ static int get defaultPort => _default.hasPort ? _default.port : 7001;
+
+ /// 由 IP + 端口拼接 Base URL;协议与路径沿用 [apiBaseUrl],
+ /// 这样 `--dart-define` 传入自定义路径时也能保持一致。
+ static String buildBaseUrl(String host, String port) {
+ final h = host.trim();
+ final p = port.trim();
+ final resolvedPort = p.isEmpty ? '$defaultPort' : p;
+ return '${_default.scheme}://$h:$resolvedPort${_default.path}';
+ }
+
+ /// 当前生效的 Base URL:登录页保存值优先,其次编译期默认值。
+ static String resolveBaseUrl({String host = '', String port = ''}) =>
+ host.trim().isEmpty ? apiBaseUrl : buildBaseUrl(host, port);
+}
diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart
new file mode 100644
index 0000000..18bce5f
--- /dev/null
+++ b/lib/core/network/api_client.dart
@@ -0,0 +1,133 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:dio/dio.dart';
+
+import '../storage/token_storage.dart';
+import 'api_response.dart';
+
+/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
+/// - 自动注入 `Authorization: Bearer `
+/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
+/// - 401 直接判定会话失效并广播给会话层,由会话层清空本地登录态回到登录页。
+///
+/// 注意:电工端后端未提供 refresh 接口(与管理端 `auth.controller` 不同),
+/// 因此这里不做静默续期,过期即重新登录。
+class ApiClient {
+ ApiClient({required TokenStorage storage, required String baseUrl, Dio? dio})
+ : _storage = storage,
+ _dio =
+ dio ??
+ Dio(
+ BaseOptions(
+ baseUrl: baseUrl,
+ connectTimeout: const Duration(seconds: 15),
+ receiveTimeout: const Duration(seconds: 30),
+ sendTimeout: const Duration(seconds: 30),
+ // 自行判断业务 code,不因 HTTP 状态码抛异常
+ validateStatus: (_) => true,
+ ),
+ );
+
+ final TokenStorage _storage;
+ final Dio _dio;
+
+ final _unauthorizedController = StreamController.broadcast();
+
+ /// 会话失效事件:401 时触发,由会话层订阅后跳转登录页。
+ Stream get onUnauthorized => _unauthorizedController.stream;
+
+ Future> get(
+ String url, {
+ Map? query,
+ T Function(dynamic data)? parse,
+ bool needAuth = true,
+ }) => _send(
+ 'GET',
+ url,
+ query: _compact(query),
+ parse: parse,
+ needAuth: needAuth,
+ );
+
+ Future> post(
+ String url, {
+ Object? body,
+ T Function(dynamic data)? parse,
+ bool needAuth = true,
+ }) => _send('POST', url, body: body, parse: parse, needAuth: needAuth);
+
+ Future> put(
+ String url, {
+ Object? body,
+ T Function(dynamic data)? parse,
+ bool needAuth = true,
+ }) => _send('PUT', url, body: body, parse: parse, needAuth: needAuth);
+
+ void dispose() => _unauthorizedController.close();
+
+ // ---- 内部实现 ----
+
+ Future> _send(
+ String method,
+ String url, {
+ Map? query,
+ Object? body,
+ T Function(dynamic data)? parse,
+ required bool needAuth,
+ }) async {
+ late Response res;
+ try {
+ res = await _dio.request(
+ url,
+ data: body,
+ queryParameters: query,
+ options: Options(
+ method: method,
+ headers: needAuth ? _authHeader() : null,
+ ),
+ );
+ } on DioException catch (e) {
+ throw ApiException(e.message ?? '网络请求失败');
+ }
+
+ final response = ApiResponse.fromJson(_asMap(res.data), parse);
+
+ if (response.code == 401) {
+ _unauthorizedController.add(null);
+ throw const ApiException('登录已失效,请重新登录', code: 401);
+ }
+
+ return response;
+ }
+
+ Map _authHeader() {
+ final token = _storage.accessToken;
+ return token.isEmpty ? const {} : {'Authorization': 'Bearer $token'};
+ }
+
+ static Map _asMap(dynamic data) {
+ if (data is Map) return data.cast();
+ if (data is String && data.isNotEmpty) {
+ try {
+ final decoded = jsonDecode(data);
+ if (decoded is Map) return decoded.cast();
+ } catch (_) {}
+ }
+ return const {'code': -1, 'message': '响应解析失败'};
+ }
+
+ /// 去掉 null / 空串查询条件。
+ /// uni-app 会把 undefined 序列化成空串(如 `status=`),后端对空串查询条件会 500,
+ /// 因此这里同样只在有值时才拼接参数。
+ static Map? _compact(Map? src) {
+ if (src == null || src.isEmpty) return null;
+ final out = {};
+ src.forEach((k, v) {
+ if (v == null) return;
+ if (v is String && v.isEmpty) return;
+ out[k] = v;
+ });
+ return out.isEmpty ? null : out;
+ }
+}
diff --git a/lib/core/network/api_response.dart b/lib/core/network/api_response.dart
new file mode 100644
index 0000000..bdc56b6
--- /dev/null
+++ b/lib/core/network/api_response.dart
@@ -0,0 +1,33 @@
+/// 后端统一响应体:`{ code, message, data }`。
+class ApiResponse {
+ const ApiResponse({required this.code, required this.message, this.data});
+
+ final int code;
+ final String message;
+ final T? data;
+
+ bool get isOk => code == 200;
+
+ factory ApiResponse.fromJson(
+ Map json,
+ T Function(dynamic data)? parse,
+ ) {
+ final raw = json['data'];
+ return ApiResponse(
+ code: (json['code'] as num?)?.toInt() ?? -1,
+ message: (json['message'] as String?) ?? '',
+ data: (raw == null || parse == null) ? null : parse(raw),
+ );
+ }
+}
+
+/// 网络/协议层异常。调用方捕获后用 `message` 提示用户。
+class ApiException implements Exception {
+ const ApiException(this.message, {this.code});
+
+ final String message;
+ final int? code;
+
+ @override
+ String toString() => message;
+}
diff --git a/lib/core/providers.dart b/lib/core/providers.dart
new file mode 100644
index 0000000..b724698
--- /dev/null
+++ b/lib/core/providers.dart
@@ -0,0 +1,110 @@
+import 'dart:async';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../features/auth/application/session_controller.dart';
+import '../features/notice/data/notice_api.dart';
+import 'env.dart';
+import 'network/api_client.dart';
+import 'network/api_response.dart';
+import 'storage/token_storage.dart';
+
+/// 在 `main()` 中读取 SharedPreferences 后通过 override 注入。
+final tokenStorageProvider = Provider(
+ (ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
+);
+
+/// 登录页「服务端设置」保存的主机与端口;空串表示未覆盖,走编译期默认值。
+typedef ServerAddress = ({String host, String port});
+
+/// 服务端地址状态。登录页保存/重置后通知 [apiClientProvider] 重建客户端,
+/// 因此无需重启 App 即可切换后端。
+class ServerAddressNotifier extends Notifier {
+ @override
+ ServerAddress build() {
+ final storage = ref.watch(tokenStorageProvider);
+ return (host: storage.serverHost, port: storage.serverPort);
+ }
+
+ Future save(String host, String port) async {
+ await ref.read(tokenStorageProvider).setServerAddress(host, port);
+ state = (host: host, port: port);
+ }
+
+ Future reset() async {
+ await ref.read(tokenStorageProvider).clearServerAddress();
+ state = (host: '', port: '');
+ }
+}
+
+final serverAddressProvider =
+ NotifierProvider(
+ ServerAddressNotifier.new,
+ );
+
+/// 当前生效的 API Base URL。
+final apiBaseUrlProvider = Provider(
+ (ref) => Env.resolveBaseUrl(
+ host: ref.watch(serverAddressProvider).host,
+ port: ref.watch(serverAddressProvider).port,
+ ),
+);
+
+final apiClientProvider = Provider((ref) {
+ final client = ApiClient(
+ storage: ref.watch(tokenStorageProvider),
+ baseUrl: ref.watch(apiBaseUrlProvider),
+ );
+ ref.onDispose(client.dispose);
+ return client;
+});
+
+/// 站内消息中心接口。
+final noticeApiProvider = Provider(
+ (ref) => NoticeApi(ref.watch(apiClientProvider)),
+);
+
+/// 未读消息数。登录态下每 60s 轮询一次,登出立即停止并归零;
+/// 三处顶栏铃铛与消息中心页共用,标记已读后调用 [noticeUnreadCountProvider]
+/// 的 `refresh()` 立即刷新。
+final noticeUnreadCountProvider =
+ NotifierProvider(
+ NoticeUnreadCountNotifier.new,
+ );
+
+class NoticeUnreadCountNotifier extends Notifier {
+ static const _interval = Duration(seconds: 60);
+
+ Timer? _timer;
+
+ @override
+ int build() {
+ // 登录态变化时重建:先停掉上一轮轮询,避免重复计时器。
+ _stop();
+
+ final loggedIn = ref.watch(sessionProvider.select((s) => s.isLoggedIn));
+ if (!loggedIn) return 0;
+
+ ref.onDispose(_stop);
+ _timer = Timer.periodic(_interval, (_) => unawaited(refresh()));
+ unawaited(refresh());
+ return 0;
+ }
+
+ /// 立即拉取一次未读数(进入消息中心 / 单条或全部已读后调用)。
+ Future refresh() async {
+ try {
+ final res = await ref.read(noticeApiProvider).unreadCount();
+ // 请求期间若已登出,丢弃结果,避免残留红点。
+ if (!ref.read(sessionProvider).isLoggedIn) return;
+ if (res.isOk && res.data != null) state = res.data!;
+ } on ApiException {
+ // 轮询失败保持上一次的值,不打断 UI。
+ }
+ }
+
+ void _stop() {
+ _timer?.cancel();
+ _timer = null;
+ }
+}
diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart
new file mode 100644
index 0000000..3f89f7d
--- /dev/null
+++ b/lib/core/storage/token_storage.dart
@@ -0,0 +1,59 @@
+import 'dart:convert';
+
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// 本地持久化。键名与 uni-app 版 `uni.getStorageSync` 使用的键保持一致,
+/// 便于两端在真机上迁移/对拍时理解同一份数据。
+class TokenStorage {
+ TokenStorage(this._prefs);
+
+ final SharedPreferences _prefs;
+
+ static const _kAccessToken = 'accessToken';
+ static const _kUserInfo = 'userInfo';
+ static const _kServerHost = 'serverHost';
+ static const _kServerPort = 'serverPort';
+
+ static Future open() async =>
+ TokenStorage(await SharedPreferences.getInstance());
+
+ String get accessToken => _prefs.getString(_kAccessToken) ?? '';
+
+ Map? get userInfo {
+ final raw = _prefs.getString(_kUserInfo);
+ if (raw == null || raw.isEmpty) return null;
+ try {
+ return jsonDecode(raw) as Map;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ Future setAccessToken(String token) =>
+ _prefs.setString(_kAccessToken, token);
+
+ Future setUserInfo(Map info) =>
+ _prefs.setString(_kUserInfo, jsonEncode(info));
+
+ /// 登录页「服务端设置」保存的 IP 与端口,空串表示未覆盖、走编译期默认值。
+ String get serverHost => _prefs.getString(_kServerHost) ?? '';
+
+ String get serverPort => _prefs.getString(_kServerPort) ?? '';
+
+ Future setServerAddress(String host, String port) async {
+ await _prefs.setString(_kServerHost, host);
+ await _prefs.setString(_kServerPort, port);
+ }
+
+ Future clearServerAddress() async {
+ await _prefs.remove(_kServerHost);
+ await _prefs.remove(_kServerPort);
+ }
+
+ Future clearSession() async {
+ await _prefs.remove(_kAccessToken);
+ await _prefs.remove(_kUserInfo);
+ }
+
+ bool get hasSession => accessToken.isNotEmpty;
+}
diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart
new file mode 100644
index 0000000..78badd1
--- /dev/null
+++ b/lib/core/theme/app_colors.dart
@@ -0,0 +1,116 @@
+import 'package:flutter/material.dart';
+
+/// 设计令牌 - 色彩。
+/// 逐项对应 uni-app 版 `src/uni.scss` 中的 `$color-*` 变量。
+abstract final class AppColors {
+ // Primary
+ /// 品牌主色(源 `$primary-container`)。源页面绝大多数蓝色元素用它。
+ static const primary = Color(0xFF0052FF);
+
+ /// 深一档的品牌色(源 `$primary`),用于余额卡渐变起始色。
+ static const primaryDeep = Color(0xFF003EC7);
+ static const onPrimary = Color(0xFFFFFFFF);
+ static const onPrimaryContainer = Color(0xFFDFE3FF);
+ static const primaryFixed = Color(0xFFDDE1FF);
+ static const primaryFixedDim = Color(0xFFB7C4FF);
+ static const onPrimaryFixed = Color(0xFF001452);
+ static const onPrimaryFixedVariant = Color(0xFF0038B6);
+
+ // Secondary
+ static const secondary = Color(0xFF4C4ACA);
+ static const secondaryContainer = Color(0xFF6664E4);
+ static const onSecondary = Color(0xFFFFFFFF);
+ static const onSecondaryContainer = Color(0xFFFFFBFF);
+ static const secondaryFixed = Color(0xFFE2DFFF);
+ static const secondaryFixedDim = Color(0xFFC2C1FF);
+ static const onSecondaryFixed = Color(0xFF0C006A);
+ static const onSecondaryFixedVariant = Color(0xFF3631B4);
+
+ // Tertiary
+ static const tertiary = Color(0xFF952200);
+ static const tertiaryContainer = Color(0xFFBF3003);
+ static const onTertiary = Color(0xFFFFFFFF);
+ static const onTertiaryContainer = Color(0xFFFFDDD5);
+ static const tertiaryFixed = Color(0xFFFFDBD2);
+ static const tertiaryFixedDim = Color(0xFFFFB4A1);
+ static const onTertiaryFixed = Color(0xFF3C0800);
+ static const onTertiaryFixedVariant = Color(0xFF891E00);
+
+ // Error
+ static const error = Color(0xFFBA1A1A);
+ static const errorContainer = Color(0xFFFFDAD6);
+ static const onError = Color(0xFFFFFFFF);
+ static const onErrorContainer = Color(0xFF93000A);
+
+ // Surface
+ static const surface = Color(0xFFFAF9FE);
+ static const surfaceBright = Color(0xFFFAF9FE);
+ static const surfaceDim = Color(0xFFDAD9DF);
+ static const surfaceContainerLowest = Color(0xFFFFFFFF);
+ static const surfaceContainerLow = Color(0xFFF4F3F8);
+ static const surfaceContainer = Color(0xFFEEEDF3);
+ static const surfaceContainerHigh = Color(0xFFE9E7ED);
+ static const surfaceContainerHighest = Color(0xFFE3E2E7);
+ static const surfaceVariant = Color(0xFFE3E2E7);
+
+ // Background
+ static const bgPage = Color(0xFFFAF9FE);
+ static const background = Color(0xFFFAF9FE);
+
+ // Text
+ static const onSurface = Color(0xFF1A1B1F);
+ static const onSurfaceVariant = Color(0xFF434656);
+ static const textMuted = Color(0xFF737688);
+ static const textPlaceholder = Color(0xFFC3C5D9);
+
+ // Outline
+ static const outline = Color(0xFF737688);
+ static const outlineVariant = Color(0xFFC3C5D9);
+ static const borderRule = Color(0xFFC3C5D9);
+
+ /// `rgba($outline-variant, 0.2)` —— 各页面 `.ios-card` / 分隔线用的浅描边。
+ static const borderSoft = Color(0x33C3C5D9);
+
+ /// `.ios-blur` / `.custom-tab-bar` 的毛玻璃底色 rgba(250,249,254,0.85)
+ static const headerGlass = Color(0xD9FAF9FE);
+
+ // Inverse
+ static const inverseSurface = Color(0xFF2F3034);
+ static const inverseOnSurface = Color(0xFFF1F0F5);
+ static const inversePrimary = Color(0xFFB7C4FF);
+
+ // Accent
+ static const accentSubtle = Color(0x140052FF);
+
+ // Status
+ static const success = Color(0xFF34C759);
+ static const star = Color(0xFFFF9500);
+ static const statusWarn = Color(0xFFE0852B);
+ static const statusError = Color(0xFFDC2626);
+
+ // 底部导航毛玻璃底:rgba(250,249,254,0.95)
+ static const navGlass = Color(0xF2FAF9FE);
+}
+
+/// chip 语义样式:背景色 + 前景色,对应各页面 `.chip-*` / 徽章配色。
+enum ChipTone { primary, secondary, urgent, warning, success, muted }
+
+extension ChipToneStyle on ChipTone {
+ Color get foreground => switch (this) {
+ ChipTone.primary => AppColors.primary,
+ ChipTone.secondary => AppColors.secondary,
+ ChipTone.urgent => AppColors.tertiaryContainer,
+ ChipTone.warning => AppColors.statusWarn,
+ ChipTone.success => AppColors.success,
+ ChipTone.muted => AppColors.onSurfaceVariant,
+ };
+
+ Color get background => switch (this) {
+ ChipTone.primary => AppColors.accentSubtle,
+ ChipTone.secondary => const Color(0x144C4ACA),
+ ChipTone.urgent => const Color(0x14BF3003),
+ ChipTone.warning => const Color(0x14E0852B),
+ ChipTone.success => const Color(0x1434C759),
+ ChipTone.muted => AppColors.surfaceContainer,
+ };
+}
diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart
new file mode 100644
index 0000000..2b14c05
--- /dev/null
+++ b/lib/core/theme/app_dimens.dart
@@ -0,0 +1,66 @@
+import 'package:flutter/painting.dart';
+
+/// 设计令牌 - 间距与圆角。对应 `uni.scss` 的 `$spacing-*` / `$rounded-*`。
+abstract final class AppSpacing {
+ static const double base = 4;
+ static const double xs = 8;
+ static const double sm = 12;
+ static const double md = 16;
+ static const double lg = 24;
+ static const double xl = 40;
+
+ /// `$spacing-margin-page` / `$spacing-gutter-grid` = 1rem
+ static const double gutter = 16;
+}
+
+abstract final class AppRadius {
+ /// `$rounded-sm` = 0.25rem
+ static const double sm = 4;
+
+ /// `$rounded-md` = 0.5rem
+ static const double regular = 8;
+
+ /// `$rounded-lg` = 0.75rem
+ static const double lg = 12;
+
+ /// `$rounded-xl` = 1rem
+ static const double xl = 16;
+
+ /// 订单区 / 底部弹窗的 24px 大圆角
+ static const double xxl = 24;
+
+ /// `$rounded-full`
+ static const double full = 9999;
+}
+
+abstract final class AppShadows {
+ /// 全局 `.ios-card`:0 1px 3px rgba(0,0,0,0.04)
+ static const card = [
+ BoxShadow(color: Color(0x0A000000), blurRadius: 3, offset: Offset(0, 1)),
+ ];
+
+ /// 订单卡片:0 2px 8px rgba(0,0,0,0.04)
+ static const orderCard = [
+ BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
+ ];
+
+ /// 顶栏下边框阴影:0 -1px 3px rgba(0,0,0,0.05)
+ static const topBar = [
+ BoxShadow(color: Color(0x0D000000), blurRadius: 3, offset: Offset(0, 1)),
+ ];
+
+ /// `@mixin ios-shadow`:0 2px 8px rgba(0,0,0,0.04)
+ static const soft = [
+ BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
+ ];
+
+ /// 底部导航条:向上投影
+ static const bottomNav = [
+ BoxShadow(color: Color(0x14000000), blurRadius: 24, offset: Offset(0, -8)),
+ ];
+
+ /// 品牌主按钮发光:0 8px 20px rgba(0,82,255,0.28)
+ static const primaryButton = [
+ BoxShadow(color: Color(0x470052FF), blurRadius: 20, offset: Offset(0, 8)),
+ ];
+}
diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart
new file mode 100644
index 0000000..35ebb64
--- /dev/null
+++ b/lib/core/theme/app_theme.dart
@@ -0,0 +1,97 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'app_colors.dart';
+
+abstract final class AppTheme {
+ /// 全平台统一使用 iOS 风格「新页面从右侧滑入」转场,与 uni-app 版
+ /// `components/PageTransition.vue` 的表现对齐。
+ static const _transitions = PageTransitionsTheme(
+ builders: {
+ TargetPlatform.android: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.windows: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.linux: CupertinoPageTransitionsBuilder(),
+ TargetPlatform.fuchsia: CupertinoPageTransitionsBuilder(),
+ },
+ );
+
+ static ThemeData build() {
+ final base = ThemeData.light(useMaterial3: true);
+
+ return base.copyWith(
+ scaffoldBackgroundColor: AppColors.bgPage,
+ pageTransitionsTheme: _transitions,
+ colorScheme: base.colorScheme.copyWith(
+ primary: AppColors.primary,
+ onPrimary: AppColors.onPrimary,
+ secondary: AppColors.secondary,
+ onSecondary: AppColors.onSecondary,
+ tertiary: AppColors.tertiary,
+ onTertiary: AppColors.onTertiary,
+ error: AppColors.error,
+ onError: AppColors.onError,
+ surface: AppColors.surfaceContainerLowest,
+ onSurface: AppColors.onSurface,
+ outline: AppColors.outline,
+ outlineVariant: AppColors.outlineVariant,
+ ),
+ textTheme: base.textTheme.apply(
+ bodyColor: AppColors.onSurface,
+ displayColor: AppColors.onSurface,
+ ),
+ dividerTheme: const DividerThemeData(
+ color: AppColors.borderRule,
+ thickness: 0.5,
+ space: 1,
+ ),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: AppColors.surface,
+ foregroundColor: AppColors.onSurface,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ centerTitle: true,
+ systemOverlayStyle: SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarIconBrightness: Brightness.dark,
+ statusBarBrightness: Brightness.light,
+ ),
+ titleTextStyle: TextStyle(
+ fontSize: 17,
+ height: 24 / 17,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: AppColors.surfaceContainerLow,
+ hintStyle: const TextStyle(color: AppColors.textPlaceholder, fontSize: 17),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
+ ),
+ errorBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: AppColors.error),
+ ),
+ focusedErrorBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: AppColors.error, width: 1.5),
+ ),
+ ),
+ splashFactory: InkSparkle.splashFactory,
+ tooltipTheme: const TooltipThemeData(waitDuration: Duration(milliseconds: 400)),
+ );
+ }
+}
diff --git a/lib/core/theme/app_typography.dart b/lib/core/theme/app_typography.dart
new file mode 100644
index 0000000..31f6119
--- /dev/null
+++ b/lib/core/theme/app_typography.dart
@@ -0,0 +1,114 @@
+import 'package:flutter/material.dart';
+
+import 'app_colors.dart';
+
+/// 设计令牌 - 字号 / 行高 / 字重。
+/// 对应 `uni.scss` 的 `$font-*` 与 `global.scss` 的 `.font-*` 工具类,
+/// 行高统一换算成 Flutter 的 `height`(行高 ÷ 字号)。
+abstract final class AppTypography {
+ static const _sansFallback = [
+ 'Inter',
+ 'PingFang SC',
+ 'Microsoft YaHei',
+ 'Noto Sans CJK SC',
+ ];
+
+ static const _monoFallback = [
+ 'JetBrains Mono',
+ 'SF Mono',
+ 'Menlo',
+ 'Consolas',
+ ];
+
+ /// 12 / 16 / w500 / ls 0.02em — `.font-label-caps`
+ static const labelCaps = TextStyle(
+ fontSize: 12,
+ height: 16 / 12,
+ fontWeight: FontWeight.w500,
+ letterSpacing: 0.24,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 13 / 18 / w500 — 卡片内的次要信息行
+ static const bodySm = TextStyle(
+ fontSize: 13,
+ height: 18 / 13,
+ fontWeight: FontWeight.w500,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 15 / 20 / w400 — `.font-body-sm`
+ static const bodyMd = TextStyle(
+ fontSize: 15,
+ height: 20 / 15,
+ fontWeight: FontWeight.w400,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 17 / 22 / w400 / ls -0.01em — `.font-body-lg`
+ static const bodyLg = TextStyle(
+ fontSize: 17,
+ height: 22 / 17,
+ fontWeight: FontWeight.w400,
+ letterSpacing: -0.17,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 20 / 25 / w600 / ls -0.01em — `.font-headline-md`
+ static const headlineMd = TextStyle(
+ fontSize: 20,
+ height: 25 / 20,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.2,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 22 / 28 / w700 — 「我的」页用户名
+ static const titleLg = TextStyle(
+ fontSize: 22,
+ height: 28 / 22,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 28 / 34 / w700 / ls -0.02em — `.font-nav-large-title-mobile`
+ static const navLargeTitle = TextStyle(
+ fontSize: 28,
+ height: 34 / 28,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.56,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 32 / 40 / w700 — 登录页品牌字
+ static const displayMd = TextStyle(
+ fontSize: 32,
+ height: 40 / 32,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 40 / 48 / w700 — 收益页总余额
+ static const displayLg = TextStyle(
+ fontSize: 40,
+ height: 48 / 40,
+ fontWeight: FontWeight.w700,
+ fontFamilyFallback: _sansFallback,
+ );
+
+ /// 金额 / 编号等需要等宽对齐的场景
+ static const dataMono = TextStyle(
+ fontSize: 14,
+ height: 18 / 14,
+ fontWeight: FontWeight.w600,
+ fontFamilyFallback: _monoFallback,
+ );
+}
+
+/// 全局默认文字样式,对应 `global.scss` 里 `page` 上的字体设置。
+TextStyle get appDefaultTextStyle => const TextStyle(
+ fontSize: 15,
+ height: 1.4,
+ color: AppColors.onSurface,
+ fontFamilyFallback: AppTypography._sansFallback,
+);
diff --git a/lib/core/utils/format_util.dart b/lib/core/utils/format_util.dart
new file mode 100644
index 0000000..d2f4c02
--- /dev/null
+++ b/lib/core/utils/format_util.dart
@@ -0,0 +1,45 @@
+/// 日期格式化工具。
+String _pad(int n) => n.toString().padLeft(2, '0');
+
+/// 统一转 DateTime:兼容 `YYYY-MM-DD`、ISO 字符串与时间戳。
+DateTime? parseDate(Object? v) {
+ if (v == null) return null;
+ if (v is DateTime) return v;
+ if (v is num) return DateTime.fromMillisecondsSinceEpoch(v.toInt());
+ final s = v.toString();
+ if (s.isEmpty) return null;
+ return DateTime.tryParse(s) ?? DateTime.tryParse(s.replaceAll('-', '/'));
+}
+
+/// `YYYY-MM-DD`
+String formatDate(Object? v) {
+ final d = parseDate(v);
+ if (d == null) return '-';
+ return '${d.year}-${_pad(d.month)}-${_pad(d.day)}';
+}
+
+/// `YYYY-MM-DD HH:mm`
+String formatDateTime(Object? v) {
+ final d = parseDate(v);
+ if (d == null) return '-';
+ return '${formatDate(d)} ${_pad(d.hour)}:${_pad(d.minute)}';
+}
+
+/// 今天的 `YYYY-MM-DD`
+String todayStr() => formatDate(DateTime.now());
+
+/// 金额格式化:千分位 + 两位小数,对应源页面 `formatNumber`。
+String formatMoney(num? value) {
+ final v = (value ?? 0).toDouble();
+ final fixed = v.toStringAsFixed(2);
+ final parts = fixed.split('.');
+ final intPart = parts[0];
+ final negative = intPart.startsWith('-');
+ final digits = negative ? intPart.substring(1) : intPart;
+ final buf = StringBuffer();
+ for (var i = 0; i < digits.length; i++) {
+ if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
+ buf.write(digits[i]);
+ }
+ return '${negative ? '-' : ''}$buf.${parts[1]}';
+}
diff --git a/lib/core/utils/id_util.dart b/lib/core/utils/id_util.dart
new file mode 100644
index 0000000..0e461fa
--- /dev/null
+++ b/lib/core/utils/id_util.dart
@@ -0,0 +1,22 @@
+/// 后端主键为 BIGINT,经 JSON 序列化后是字符串(如 `"1"`)。
+/// 统一归一化为 int,避免与表单/路由里的 int 型 id 做比较时失效。
+Object? toNumId(Object? v) {
+ if (v == null || v == '') return v;
+ if (v is int) return v;
+ if (v is num) return v.toInt();
+ final n = int.tryParse(v.toString());
+ return n ?? v;
+}
+
+int? toIntOrNull(Object? v) {
+ final n = toNumId(v);
+ return n is int ? n : null;
+}
+
+int toIntOr(Object? v, int fallback) => toIntOrNull(v) ?? fallback;
+
+double toDoubleOr(Object? v, double fallback) {
+ if (v == null) return fallback;
+ if (v is num) return v.toDouble();
+ return double.tryParse(v.toString()) ?? fallback;
+}
diff --git a/lib/features/auth/application/session_controller.dart b/lib/features/auth/application/session_controller.dart
new file mode 100644
index 0000000..f854537
--- /dev/null
+++ b/lib/features/auth/application/session_controller.dart
@@ -0,0 +1,100 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/providers.dart';
+import '../../../core/storage/token_storage.dart';
+import '../data/auth_api.dart';
+import '../data/auth_models.dart';
+
+final authApiProvider = Provider(
+ (ref) => AuthApi(ref.watch(apiClientProvider)),
+);
+
+class SessionState {
+ const SessionState({this.worker, required this.isLoggedIn});
+
+ final WorkerInfo? worker;
+ final bool isLoggedIn;
+
+ SessionState copyWith({WorkerInfo? worker, bool? isLoggedIn}) =>
+ SessionState(
+ worker: worker ?? this.worker,
+ isLoggedIn: isLoggedIn ?? this.isLoggedIn,
+ );
+}
+
+/// 登录态。对应源 `store/user.ts` 的登录/登出与本地缓存恢复。
+class SessionController extends Notifier {
+ TokenStorage get _storage => ref.read(tokenStorageProvider);
+
+ @override
+ SessionState build() {
+ // 拦截器判定会话失效(401)时,清空本地会话并回到登录页。
+ final sub = ref
+ .watch(apiClientProvider)
+ .onUnauthorized
+ .listen((_) => clearLocal());
+ ref.onDispose(sub.cancel);
+
+ final cached = _storage.userInfo;
+ return SessionState(
+ worker: cached == null ? null : WorkerInfo.fromJson(cached),
+ isLoggedIn: _storage.hasSession,
+ );
+ }
+
+ /// 登录成功后写入 token 与电工信息。
+ Future login({
+ required String phone,
+ required String password,
+ }) async {
+ final res = await ref
+ .read(authApiProvider)
+ .login(phone: phone, password: password);
+ if (!res.isOk || res.data == null) {
+ throw AuthException(res.message.isEmpty ? '登录失败' : res.message);
+ }
+
+ final data = res.data!;
+ await _storage.setAccessToken(data.token);
+ await _storage.setUserInfo(data.workerInfo.toJson());
+ state = SessionState(worker: data.workerInfo, isLoggedIn: true);
+ }
+
+ /// 从后端刷新电工信息(「我的」页进入时调用)。
+ Future refreshProfile() async {
+ try {
+ final res = await ref.read(authApiProvider).profile();
+ if (res.isOk && res.data != null) {
+ final info = res.data!.basic;
+ await _storage.setUserInfo(info.toJson());
+ state = SessionState(worker: info, isLoggedIn: true);
+ return info;
+ }
+ } catch (_) {
+ // 拉取失败保持原状态
+ }
+ return null;
+ }
+
+ Future logout() async {
+ await _storage.clearSession();
+ state = const SessionState(isLoggedIn: false, worker: null);
+ }
+
+ /// 清空本地会话但不跳转(被拦截器判定会话失效时调用)。
+ Future clearLocal() => logout();
+}
+
+final sessionProvider = NotifierProvider(
+ SessionController.new,
+);
+
+/// 会话层业务异常,携带后端返回的可读 message。
+class AuthException implements Exception {
+ const AuthException(this.message);
+
+ final String message;
+
+ @override
+ String toString() => message;
+}
diff --git a/lib/features/auth/data/auth_api.dart b/lib/features/auth/data/auth_api.dart
new file mode 100644
index 0000000..1b25cdd
--- /dev/null
+++ b/lib/features/auth/data/auth_api.dart
@@ -0,0 +1,58 @@
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import 'auth_models.dart';
+
+/// 认证与个人资料接口,对应后端 `/api/worker-app/auth/*` 与 `/profile`。
+class AuthApi {
+ AuthApi(this._client);
+
+ final ApiClient _client;
+
+ Future> login({
+ required String phone,
+ required String password,
+ }) => _client.post(
+ '/worker-app/auth/login',
+ body: {'phone': phone, 'password': password},
+ parse: (d) => LoginResult.fromJson((d as Map).cast()),
+ needAuth: false,
+ );
+
+ Future> register({
+ required String workerName,
+ required String phone,
+ required String password,
+ String? idCard,
+ String? workerTitle,
+ }) => _client.post(
+ '/worker-app/auth/register',
+ body: {
+ 'workerName': workerName,
+ 'phone': phone,
+ 'password': password,
+ if (idCard != null && idCard.isNotEmpty) 'idCard': idCard,
+ if (workerTitle != null && workerTitle.isNotEmpty) 'workerTitle': workerTitle,
+ },
+ parse: (d) => WorkerInfo.fromJson((d as Map).cast()),
+ needAuth: false,
+ );
+
+ Future> profile() => _client.get(
+ '/worker-app/profile',
+ parse: (d) => WorkerProfile.fromJson((d as Map).cast()),
+ );
+
+ Future> updateProfile({
+ String? workerName,
+ String? idCard,
+ String? workerTitle,
+ }) => _client.put(
+ '/worker-app/profile',
+ body: {
+ 'workerName': ?workerName,
+ 'idCard': ?idCard,
+ 'workerTitle': ?workerTitle,
+ },
+ parse: (d) => WorkerInfo.fromJson((d as Map).cast()),
+ );
+}
diff --git a/lib/features/auth/data/auth_models.dart b/lib/features/auth/data/auth_models.dart
new file mode 100644
index 0000000..b3fb08a
--- /dev/null
+++ b/lib/features/auth/data/auth_models.dart
@@ -0,0 +1,151 @@
+import '../../../core/utils/id_util.dart';
+
+/// 电工基础信息,对应后端 `WorkerAppService.basicInfo()`。
+class WorkerInfo {
+ const WorkerInfo({
+ required this.id,
+ required this.workerName,
+ required this.phone,
+ required this.workerTitle,
+ required this.auditStatus,
+ required this.status,
+ required this.rating,
+ required this.completedCount,
+ });
+
+ final int id;
+ final String workerName;
+ final String phone;
+ final String workerTitle;
+
+ /// 审核状态:0 待审核 / 1 已通过 / 2 已驳回
+ final int auditStatus;
+
+ /// 在职状态:0 离职 / 1 在职
+ final int status;
+
+ final double rating;
+ final int completedCount;
+
+ String get displayName => workerName.isNotEmpty ? workerName : phone;
+
+ /// 头像占位文字:取显示名首个字符(按 Unicode 码点截取,兼容中文)。
+ String get avatarText =>
+ displayName.isEmpty ? '电' : String.fromCharCode(displayName.runes.first);
+
+ bool get isApproved => auditStatus == 1;
+
+ String get auditStatusText => switch (auditStatus) {
+ 0 => '待审核',
+ 1 => '已通过',
+ 2 => '已驳回',
+ _ => '未知',
+ };
+
+ factory WorkerInfo.fromJson(Map json) => WorkerInfo(
+ id: toIntOr(json['id'], 0),
+ workerName: json['workerName'] as String? ?? '',
+ phone: json['phone'] as String? ?? '',
+ workerTitle: json['workerTitle'] as String? ?? '',
+ auditStatus: toIntOr(json['auditStatus'], 0),
+ status: toIntOr(json['status'], 0),
+ rating: toDoubleOr(json['rating'], 5),
+ completedCount: toIntOr(json['completedCount'], 0),
+ );
+
+ Map toJson() => {
+ 'id': id,
+ 'workerName': workerName,
+ 'phone': phone,
+ 'workerTitle': workerTitle,
+ 'auditStatus': auditStatus,
+ 'status': status,
+ 'rating': rating,
+ 'completedCount': completedCount,
+ };
+}
+
+/// 登录返回体。
+class LoginResult {
+ const LoginResult({required this.token, required this.workerInfo});
+
+ final String token;
+ final WorkerInfo workerInfo;
+
+ factory LoginResult.fromJson(Map json) => LoginResult(
+ token: json['token'] as String? ?? '',
+ workerInfo: WorkerInfo.fromJson(
+ (json['workerInfo'] as Map?)?.cast() ?? const {},
+ ),
+ );
+}
+
+/// 电工资质证照,对应 `worker_cert` 表。
+class CertInfo {
+ const CertInfo({
+ required this.id,
+ required this.certType,
+ required this.certNo,
+ required this.issueAuthority,
+ required this.issueDate,
+ required this.expireDate,
+ required this.certImageUrl,
+ required this.status,
+ });
+
+ final int id;
+ final String certType;
+ final String certNo;
+ final String issueAuthority;
+ final String issueDate;
+ final String expireDate;
+ final String certImageUrl;
+
+ /// 有效期状态:0 正常 / 1 即将到期 / 2 已过期
+ final int status;
+
+ factory CertInfo.fromJson(Map json) => CertInfo(
+ id: toIntOr(json['id'], 0),
+ certType: json['certType'] as String? ?? '',
+ certNo: json['certNo'] as String? ?? '',
+ issueAuthority: json['issueAuthority'] as String? ?? '',
+ issueDate: json['issueDate'] as String? ?? '',
+ expireDate: json['expireDate'] as String? ?? '',
+ certImageUrl: json['certImageUrl'] as String? ?? '',
+ status: toIntOr(json['status'], 0),
+ );
+}
+
+/// 个人资料,对应 `GET /api/worker-app/profile`。
+class WorkerProfile {
+ const WorkerProfile({
+ required this.basic,
+ required this.idCard,
+ required this.auditRemark,
+ required this.certStatus,
+ required this.certs,
+ });
+
+ final WorkerInfo basic;
+ final String idCard;
+ final String auditRemark;
+
+ /// 资质汇总状态:none / valid / expiring / expired
+ final String certStatus;
+ final List certs;
+
+ /// 「我的」页证书区取首张证书展示(源页面只展示一张「电工职业资格证」)。
+ CertInfo? get primaryCert => certs.isEmpty ? null : certs.first;
+
+ factory WorkerProfile.fromJson(Map json) => WorkerProfile(
+ basic: WorkerInfo.fromJson(json),
+ idCard: json['idCard'] as String? ?? '',
+ auditRemark: json['auditRemark'] as String? ?? '',
+ certStatus: json['certStatus'] as String? ?? 'none',
+ certs:
+ (json['certs'] as List?)
+ ?.map((e) => CertInfo.fromJson((e as Map).cast()))
+ .toList() ??
+ const [],
+ );
+}
diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart
new file mode 100644
index 0000000..1ace9a8
--- /dev/null
+++ b/lib/features/auth/presentation/login_page.dart
@@ -0,0 +1,717 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/env.dart';
+import '../../../core/providers.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_dimens.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../application/session_controller.dart';
+
+/// 是否在 release 包里也显示「服务端设置」入口。
+///
+/// 默认关闭,正式包不暴露改地址的口子;打内部测试包时用
+/// `--dart-define=ENABLE_SERVER_SETTINGS=true` 打开。
+const _serverSettingsEnabled = bool.fromEnvironment('ENABLE_SERVER_SETTINGS');
+
+/// 登录页,对应源 `pages/login/index.vue`。
+///
+/// **与源的差异(已与用户确认)**:源为「手机号 + 验证码」,后端只支持
+/// 「手机号 + 密码」,故此处改为手机号 + 密码,并新增注册入口。
+class LoginPage extends ConsumerStatefulWidget {
+ const LoginPage({super.key});
+
+ @override
+ ConsumerState createState() => _LoginPageState();
+}
+
+class _LoginPageState extends ConsumerState {
+ final _phone = TextEditingController();
+ final _password = TextEditingController();
+
+ bool _agree = false;
+ bool _obscure = true;
+ bool _loading = false;
+ String _error = '';
+
+ @override
+ void initState() {
+ super.initState();
+ _phone.addListener(_onChanged);
+ _password.addListener(_onChanged);
+ }
+
+ @override
+ void dispose() {
+ _phone.dispose();
+ _password.dispose();
+ super.dispose();
+ }
+
+ void _onChanged() => setState(() {});
+
+ bool get _canLogin =>
+ _phone.text.length == 11 && _password.text.length >= 6 && _agree;
+
+ Future _handleLogin() async {
+ if (!_canLogin) {
+ if (!_agree) _error = '请先阅读并同意服务协议与隐私政策';
+ setState(() {});
+ return;
+ }
+
+ setState(() {
+ _loading = true;
+ _error = '';
+ });
+ try {
+ await ref
+ .read(sessionProvider.notifier)
+ .login(phone: _phone.text.trim(), password: _password.text);
+ if (!mounted) return;
+ context.go('/tabs');
+ } on AuthException catch (e) {
+ if (mounted) setState(() => _error = e.message);
+ } catch (_) {
+ if (mounted) setState(() => _error = '网络异常,请检查服务端地址');
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Container(
+ decoration: const BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [Color(0xFFF0F4FF), Color(0xFFFAF9FE)],
+ ),
+ ),
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(24, 80, 24, 60),
+ child: Column(
+ children: [
+ _buildLogoSection(),
+ _buildFormSection(),
+ _buildOtherLogin(),
+ _buildAgreement(),
+ const SizedBox(height: 24),
+ _buildRegisterEntry(),
+ ],
+ ),
+ ),
+ ),
+ // 测试用「服务端设置」入口:调试版常显,release 需显式开关
+ if (kDebugMode || _serverSettingsEnabled)
+ Positioned(
+ top: 0,
+ right: 0,
+ child: SafeArea(
+ child: GestureDetector(
+ onTap: _openServerSettings,
+ behavior: HitTestBehavior.opaque,
+ child: const Padding(
+ padding: EdgeInsets.all(12),
+ child: XIcon(
+ 'settings',
+ size: 22,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Future _openServerSettings() => showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: AppColors.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
+ ),
+ builder: (_) => const _ServerSettingsSheet(),
+ );
+
+ // ---- 各区块 ----
+
+ Widget _buildLogoSection() {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 48),
+ child: Column(
+ children: [
+ // .logo 96×96 / #0052ff / radius 20 / 0 8px 24px rgba(0,82,255,.3)
+ Container(
+ width: 96,
+ height: 96,
+ margin: const EdgeInsets.only(bottom: 24),
+ decoration: BoxDecoration(
+ color: AppColors.primary,
+ borderRadius: BorderRadius.circular(20),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x4D0052FF),
+ blurRadius: 24,
+ offset: Offset(0, 8),
+ ),
+ ],
+ ),
+ alignment: Alignment.center,
+ child: const XIcon('shandian', size: 48, color: Colors.white),
+ ),
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Text(
+ 'VoltLink',
+ style: AppTypography.displayMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ const Text(
+ '欢迎回到 VoltLink',
+ style: TextStyle(fontSize: 16, color: AppColors.textMuted),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildFormSection() {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 48),
+ child: Column(
+ children: [
+ // .input-group:白底 / radius 16 / padding 1px
+ Container(
+ decoration: BoxDecoration(
+ color: AppColors.surfaceContainerLowest,
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ ),
+ child: Column(
+ children: [
+ _buildPhoneRow(),
+ _buildPasswordRow(),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+ _buildLoginButton(),
+ if (_error.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 14),
+ child: Text(
+ _error,
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 13, color: AppColors.error),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 手机号行:+86 国家码 + 分隔线 + 输入框。
+ Widget _buildPhoneRow() {
+ return Container(
+ height: 56,
+ decoration: const BoxDecoration(
+ border: Border(bottom: BorderSide(color: AppColors.surfaceContainerHighest)),
+ ),
+ child: Row(
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ child: Row(
+ children: [
+ Text(
+ '+86',
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(width: 4),
+ const Text(
+ '▼',
+ style: TextStyle(fontSize: 12, color: AppColors.textMuted),
+ ),
+ ],
+ ),
+ ),
+ Container(width: 1, height: 32, color: AppColors.surfaceContainerHighest),
+ Expanded(
+ child: TextField(
+ controller: _phone,
+ keyboardType: TextInputType.phone,
+ maxLength: 11,
+ style: const TextStyle(fontSize: 17, color: AppColors.onSurface),
+ cursorColor: AppColors.primary,
+ decoration: _inputDecoration('请输入手机号码'),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 密码行:源为「验证码 + 获取验证码」,后端改为密码,右侧按钮换成明文切换。
+ Widget _buildPasswordRow() {
+ return SizedBox(
+ height: 56,
+ child: Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _password,
+ obscureText: _obscure,
+ style: const TextStyle(fontSize: 17, color: AppColors.onSurface),
+ cursorColor: AppColors.primary,
+ decoration: _inputDecoration('请输入密码(至少 6 位)'),
+ ),
+ ),
+ GestureDetector(
+ onTap: () => setState(() => _obscure = !_obscure),
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 20,
+ vertical: 12,
+ ),
+ child: Text(
+ _obscure ? '显示' : '隐藏',
+ style: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w500,
+ color: AppColors.primary,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ InputDecoration _inputDecoration(String hint) => InputDecoration(
+ isDense: true,
+ border: InputBorder.none,
+ counterText: '',
+ contentPadding: const EdgeInsets.symmetric(horizontal: 20),
+ hintText: hint,
+ hintStyle: const TextStyle(fontSize: 17, color: AppColors.textPlaceholder),
+ );
+
+ Widget _buildLoginButton() {
+ final enabled = _canLogin && !_loading;
+ return GestureDetector(
+ onTap: enabled ? _handleLogin : null,
+ behavior: HitTestBehavior.opaque,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 120),
+ height: 56,
+ decoration: BoxDecoration(
+ color: enabled ? AppColors.primary : AppColors.textPlaceholder,
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ boxShadow: enabled
+ ? const [
+ BoxShadow(
+ color: Color(0x4D0052FF),
+ blurRadius: 20,
+ offset: Offset(0, 8),
+ ),
+ ]
+ : null,
+ ),
+ alignment: Alignment.center,
+ child: _loading
+ ? const SizedBox(
+ width: 22,
+ height: 22,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ valueColor: AlwaysStoppedAnimation(Colors.white),
+ ),
+ )
+ : Row(
+ mainAxisSize: MainAxisSize.min,
+ children: const [
+ Text(
+ '立即登录',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: Colors.white,
+ ),
+ ),
+ SizedBox(width: 8),
+ Text(
+ '→',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w300,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildOtherLogin() {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 48),
+ child: Column(
+ children: [
+ const Padding(
+ padding: EdgeInsets.only(bottom: 24),
+ child: Row(
+ children: [
+ Expanded(
+ child: SizedBox(
+ height: 1,
+ child: ColoredBox(color: AppColors.surfaceContainerHighest),
+ ),
+ ),
+ Padding(
+ padding: EdgeInsets.symmetric(horizontal: 16),
+ child: Text(
+ '微信快捷登录',
+ style: TextStyle(fontSize: 13, color: AppColors.textMuted),
+ ),
+ ),
+ Expanded(
+ child: SizedBox(
+ height: 1,
+ child: ColoredBox(color: AppColors.surfaceContainerHighest),
+ ),
+ ),
+ ],
+ ),
+ ),
+ GestureDetector(
+ onTap: () => showAppToast('微信登录暂未开放'),
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: 56,
+ height: 56,
+ decoration: const BoxDecoration(
+ color: Color(0xFF07C160),
+ shape: BoxShape.circle,
+ ),
+ alignment: Alignment.center,
+ child: const XIcon('weixin', size: 32, color: Colors.white),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildAgreement() {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ GestureDetector(
+ onTap: () => setState(() => _agree = !_agree),
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: 20,
+ height: 20,
+ margin: const EdgeInsets.only(top: 1),
+ decoration: BoxDecoration(
+ color: _agree ? AppColors.primary : Colors.transparent,
+ border: Border.all(
+ color: _agree ? AppColors.primary : AppColors.textPlaceholder,
+ width: 2,
+ ),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ alignment: Alignment.center,
+ child: _agree
+ ? const Text(
+ '✓',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ color: Colors.white,
+ height: 1,
+ ),
+ )
+ : null,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: RichText(
+ text: TextSpan(
+ style: const TextStyle(
+ fontSize: 13,
+ height: 1.6,
+ color: AppColors.textMuted,
+ ),
+ children: [
+ const TextSpan(text: '我已阅读并同意'),
+ _link('《VoltLink服务协议》'),
+ const TextSpan(text: '与'),
+ _link('《隐私政策》'),
+ const TextSpan(text: ',并授权 VoltLink 在登录时获取我的相关信息。'),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ TextSpan _link(String text) => TextSpan(
+ text: text,
+ style: const TextStyle(color: AppColors.primary),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () => showAppToast('$text 暂未开放'),
+ );
+
+ Widget _buildRegisterEntry() {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text(
+ '还没有账号?',
+ style: TextStyle(fontSize: 14, color: AppColors.textMuted),
+ ),
+ GestureDetector(
+ onTap: () => context.push('/register'),
+ behavior: HitTestBehavior.opaque,
+ child: const Padding(
+ padding: EdgeInsets.symmetric(vertical: 6, horizontal: 4),
+ child: Text(
+ '立即注册',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: AppColors.primary,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// 调试版「服务端设置」弹窗:只让改 IP 与端口,
+/// 协议与路径沿用 [Env.apiBaseUrl] 的编译期配置。
+class _ServerSettingsSheet extends ConsumerStatefulWidget {
+ const _ServerSettingsSheet();
+
+ @override
+ ConsumerState<_ServerSettingsSheet> createState() =>
+ _ServerSettingsSheetState();
+}
+
+class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> {
+ late final TextEditingController _host;
+ late final TextEditingController _port;
+ String _error = '';
+
+ @override
+ void initState() {
+ super.initState();
+ final saved = ref.read(serverAddressProvider);
+ _host = TextEditingController(
+ text: saved.host.isEmpty ? Env.defaultHost : saved.host,
+ );
+ _port = TextEditingController(
+ text: saved.port.isEmpty ? '${Env.defaultPort}' : saved.port,
+ );
+ }
+
+ @override
+ void dispose() {
+ _host.dispose();
+ _port.dispose();
+ super.dispose();
+ }
+
+ String get _preview {
+ final host = _host.text.trim();
+ return Env.buildBaseUrl(
+ host.isEmpty ? Env.defaultHost : host,
+ _port.text.trim(),
+ );
+ }
+
+ Future _save() async {
+ final host = _host.text.trim();
+ final port = _port.text.trim();
+
+ if (host.isEmpty) {
+ setState(() => _error = '请输入服务端 IP');
+ return;
+ }
+ if (host.contains('://') || host.contains('/') || host.contains(':')) {
+ setState(() => _error = '只填 IP 或域名,不要带 http:// 和端口');
+ return;
+ }
+ final parsedPort = int.tryParse(port);
+ if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
+ setState(() => _error = '端口需为 1 - 65535 的整数');
+ return;
+ }
+
+ // 保存后 serverAddressProvider 变化会重建 ApiClient,立即生效
+ await ref.read(serverAddressProvider.notifier).save(host, port);
+ if (!mounted) return;
+ Navigator.of(context).pop();
+ showAppToast('已切换到 ${Env.buildBaseUrl(host, port)}', success: true);
+ }
+
+ Future _reset() async {
+ await ref.read(serverAddressProvider.notifier).reset();
+ if (!mounted) return;
+ Navigator.of(context).pop();
+ showAppToast('已恢复默认地址', success: true);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(24, 20, 24, 28),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ '服务端设置',
+ style: AppTypography.headlineMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(height: 6),
+ const Text(
+ '保存后立即生效,无需重启 App。',
+ style: TextStyle(fontSize: 13, color: AppColors.textMuted),
+ ),
+ const SizedBox(height: 20),
+ _buildField(
+ label: '服务端 IP',
+ controller: _host,
+ hintText: '如 192.168.3.29',
+ keyboardType: TextInputType.url,
+ ),
+ const SizedBox(height: 16),
+ _buildField(
+ label: '端口',
+ controller: _port,
+ hintText: '如 7001',
+ keyboardType: TextInputType.number,
+ ),
+ const SizedBox(height: 14),
+ // 保存前实时预览拼接结果,避免地址拼错
+ ListenableBuilder(
+ listenable: Listenable.merge([_host, _port]),
+ builder: (context, _) => Text(
+ '生效地址:$_preview',
+ style: AppTypography.dataMono.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ if (_error.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 12),
+ child: Text(
+ _error,
+ style: const TextStyle(fontSize: 13, color: AppColors.error),
+ ),
+ ),
+ const SizedBox(height: 24),
+ Row(
+ children: [
+ Expanded(
+ child: XPrimaryButton(
+ label: '恢复默认',
+ onPressed: _reset,
+ background: AppColors.surfaceContainerHigh,
+ foreground: AppColors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: XPrimaryButton(label: '保存', onPressed: _save),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildField({
+ required String label,
+ required TextEditingController controller,
+ required String hintText,
+ required TextInputType keyboardType,
+ }) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: AppTypography.bodySm.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Container(
+ height: 48,
+ padding: const EdgeInsets.symmetric(horizontal: 14),
+ decoration: BoxDecoration(
+ color: AppColors.surfaceBright,
+ border: Border.all(color: AppColors.borderSoft),
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ ),
+ child: TextField(
+ controller: controller,
+ keyboardType: keyboardType,
+ autocorrect: false,
+ style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
+ cursorColor: AppColors.primary,
+ decoration: InputDecoration(
+ isDense: true,
+ border: InputBorder.none,
+ hintText: hintText,
+ hintStyle: const TextStyle(
+ fontSize: 15,
+ color: AppColors.textPlaceholder,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/features/auth/presentation/register_page.dart b/lib/features/auth/presentation/register_page.dart
new file mode 100644
index 0000000..1e5aaf5
--- /dev/null
+++ b/lib/features/auth/presentation/register_page.dart
@@ -0,0 +1,252 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/network/api_response.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_dimens.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../application/session_controller.dart';
+
+/// 注册页。
+///
+/// 源工程登录页只有「手机号 + 验证码」,没有注册页;后端 `/auth/register`
+/// 支持手机号 + 密码开户,因此这里补一个与登录页同一套视觉语言的注册表单。
+class RegisterPage extends ConsumerStatefulWidget {
+ const RegisterPage({super.key});
+
+ @override
+ ConsumerState createState() => _RegisterPageState();
+}
+
+class _RegisterPageState extends ConsumerState {
+ final _name = TextEditingController();
+ final _phone = TextEditingController();
+ final _password = TextEditingController();
+ final _idCard = TextEditingController();
+ final _title = TextEditingController();
+
+ bool _loading = false;
+ String _error = '';
+
+ @override
+ void initState() {
+ super.initState();
+ for (final c in [_name, _phone, _password]) {
+ c.addListener(_onChanged);
+ }
+ }
+
+ @override
+ void dispose() {
+ _name.dispose();
+ _phone.dispose();
+ _password.dispose();
+ _idCard.dispose();
+ _title.dispose();
+ super.dispose();
+ }
+
+ void _onChanged() => setState(() {});
+
+ bool get _canSubmit =>
+ _name.text.trim().isNotEmpty &&
+ _phone.text.length == 11 &&
+ _password.text.length >= 6;
+
+ Future _handleRegister() async {
+ if (!_canSubmit) {
+ setState(() => _error = '请填写姓名、11 位手机号与至少 6 位密码');
+ return;
+ }
+
+ setState(() {
+ _loading = true;
+ _error = '';
+ });
+ try {
+ final res = await ref
+ .read(authApiProvider)
+ .register(
+ workerName: _name.text.trim(),
+ phone: _phone.text.trim(),
+ password: _password.text,
+ idCard: _idCard.text.trim(),
+ workerTitle: _title.text.trim(),
+ );
+ if (!mounted) return;
+ if (!res.isOk) {
+ setState(() => _error = res.message.isEmpty ? '注册失败' : res.message);
+ return;
+ }
+ showAppToast('注册成功,请登录', success: true);
+ context.pop();
+ } on ApiException catch (e) {
+ if (mounted) setState(() => _error = e.message);
+ } catch (_) {
+ if (mounted) setState(() => _error = '网络异常,请稍后重试');
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: XPageShell(
+ topBar: XTopBar.nav(title: '注册账号', onBack: () => context.pop()),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(16, 20, 16, 40),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ XCard(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _Field(
+ label: '姓名',
+ hint: '请输入真实姓名',
+ controller: _name,
+ required: true,
+ ),
+ const SizedBox(height: 18),
+ _Field(
+ label: '手机号',
+ hint: '请输入 11 位手机号',
+ controller: _phone,
+ required: true,
+ keyboardType: TextInputType.phone,
+ maxLength: 11,
+ ),
+ const SizedBox(height: 18),
+ _Field(
+ label: '密码',
+ hint: '至少 6 位',
+ controller: _password,
+ required: true,
+ obscure: true,
+ ),
+ const SizedBox(height: 18),
+ _Field(
+ label: '身份证号',
+ hint: '选填',
+ controller: _idCard,
+ ),
+ const SizedBox(height: 18),
+ _Field(
+ label: '工种 / 职称',
+ hint: '选填,如 高压电工',
+ controller: _title,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 20),
+ XPrimaryButton(
+ label: '提交注册',
+ height: 56,
+ radius: AppRadius.xl,
+ loading: _loading,
+ onPressed: _canSubmit ? _handleRegister : null,
+ shadows: AppShadows.primaryButton,
+ ),
+ if (_error.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 14),
+ child: Text(
+ _error,
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 13, color: AppColors.error),
+ ),
+ ),
+ const SizedBox(height: 16),
+ const Text(
+ '注册后需等待平台审核通过,方可接单。',
+ textAlign: TextAlign.center,
+ style: TextStyle(fontSize: 13, color: AppColors.textMuted),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _Field extends StatelessWidget {
+ const _Field({
+ required this.label,
+ required this.hint,
+ required this.controller,
+ this.required = false,
+ this.obscure = false,
+ this.keyboardType,
+ this.maxLength,
+ });
+
+ final String label;
+ final String hint;
+ final TextEditingController controller;
+ final bool required;
+ final bool obscure;
+ final TextInputType? keyboardType;
+ final int? maxLength;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ label,
+ style: AppTypography.bodySm.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ if (required)
+ const Text(
+ ' *',
+ style: TextStyle(fontSize: 13, color: AppColors.error),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Container(
+ height: 48,
+ padding: const EdgeInsets.symmetric(horizontal: 14),
+ decoration: BoxDecoration(
+ color: AppColors.surfaceBright,
+ border: Border.all(color: AppColors.borderSoft),
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ ),
+ child: TextField(
+ controller: controller,
+ obscureText: obscure,
+ keyboardType: keyboardType,
+ maxLength: maxLength,
+ style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
+ cursorColor: AppColors.primary,
+ decoration: InputDecoration(
+ isDense: true,
+ border: InputBorder.none,
+ counterText: '',
+ hintText: hint,
+ hintStyle: const TextStyle(
+ fontSize: 15,
+ color: AppColors.textPlaceholder,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/features/earnings/application/earnings_providers.dart b/lib/features/earnings/application/earnings_providers.dart
new file mode 100644
index 0000000..d35bf42
--- /dev/null
+++ b/lib/features/earnings/application/earnings_providers.dart
@@ -0,0 +1,18 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import '../data/earnings_api.dart';
+import '../data/earnings_models.dart';
+
+final earningsApiProvider = Provider(
+ (ref) => EarningsApi(ref.watch(apiClientProvider)),
+);
+
+final earningsSummaryProvider =
+ FutureProvider.autoDispose((ref) async {
+ final res = await ref.watch(earningsApiProvider).summary();
+ if (!res.isOk) throw ApiException(res.message);
+ if (res.data == null) throw const ApiException('收益数据为空');
+ return res.data!;
+ });
diff --git a/lib/features/earnings/data/earnings_api.dart b/lib/features/earnings/data/earnings_api.dart
new file mode 100644
index 0000000..c6dc2ba
--- /dev/null
+++ b/lib/features/earnings/data/earnings_api.dart
@@ -0,0 +1,16 @@
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import 'earnings_models.dart';
+
+/// 收益接口,对应后端 `/api/worker-app/earnings/summary`。
+class EarningsApi {
+ EarningsApi(this._client);
+
+ final ApiClient _client;
+
+ Future> summary() =>
+ _client.get(
+ '/worker-app/earnings/summary',
+ parse: (d) => EarningsSummary.fromJson((d as Map).cast()),
+ );
+}
diff --git a/lib/features/earnings/data/earnings_models.dart b/lib/features/earnings/data/earnings_models.dart
new file mode 100644
index 0000000..6b7cd1d
--- /dev/null
+++ b/lib/features/earnings/data/earnings_models.dart
@@ -0,0 +1,94 @@
+import '../../../core/utils/id_util.dart';
+
+/// 收益流水,对应后端 `earningsSummary().data.transactions[]`。
+///
+/// 说明:后端没有提现记录来源,因此实际只会返回 `type == 'earning'` 的入账流水。
+class EarningTransaction {
+ const EarningTransaction({
+ required this.id,
+ required this.type,
+ required this.title,
+ required this.subtitle,
+ required this.amount,
+ required this.status,
+ required this.icon,
+ });
+
+ final String id;
+
+ /// earning | withdrawal
+ final String type;
+ final String title;
+ final String subtitle;
+ final double amount;
+
+ /// settled | pending | completed
+ final String status;
+ final String icon;
+
+ bool get isWithdrawal => type == 'withdrawal';
+
+ String get statusText => switch (status) {
+ 'settled' => '已结算',
+ 'pending' => '处理中',
+ 'completed' => '已完成',
+ _ => status,
+ };
+
+ factory EarningTransaction.fromJson(Map json) =>
+ EarningTransaction(
+ id: json['id'] as String? ?? '',
+ type: json['type'] as String? ?? 'earning',
+ title: json['title'] as String? ?? '',
+ subtitle: json['subtitle'] as String? ?? '',
+ amount: toDoubleOr(json['amount'], 0),
+ status: json['status'] as String? ?? 'settled',
+ icon: json['icon'] as String? ?? '',
+ );
+}
+
+/// 收益汇总,对应 `GET /api/worker-app/earnings/summary`。
+class EarningsSummary {
+ const EarningsSummary({
+ required this.totalBalance,
+ required this.withdrawableAmount,
+ required this.monthlyEarnings,
+ required this.weeklyTrend,
+ required this.transactions,
+ });
+
+ final double totalBalance;
+ final double withdrawableAmount;
+ final double monthlyEarnings;
+
+ /// 近 7 天每日收益(index 6 = 今天)。
+ final List weeklyTrend;
+ final List transactions;
+
+ /// 柱状图高度百分比:以 7 日峰值为 100%,全 0 时给一个最小可见高度。
+ List get trendPercents {
+ final max = weeklyTrend.isEmpty
+ ? 0.0
+ : weeklyTrend.reduce((a, b) => a > b ? a : b);
+ if (max <= 0) return List.filled(weeklyTrend.length, 8);
+ return weeklyTrend.map((v) => (v / max * 100).clamp(8, 100).toDouble()).toList();
+ }
+
+ factory EarningsSummary.fromJson(Map json) => EarningsSummary(
+ totalBalance: toDoubleOr(json['totalBalance'], 0),
+ withdrawableAmount: toDoubleOr(json['withdrawableAmount'], 0),
+ monthlyEarnings: toDoubleOr(json['monthlyEarnings'], 0),
+ weeklyTrend:
+ (json['weeklyTrend'] as List?)?.map((e) => toDoubleOr(e, 0)).toList() ??
+ const [0, 0, 0, 0, 0, 0, 0],
+ transactions:
+ (json['transactions'] as List?)
+ ?.map(
+ (e) => EarningTransaction.fromJson(
+ (e as Map).cast(),
+ ),
+ )
+ .toList() ??
+ const [],
+ );
+}
diff --git a/lib/features/earnings/presentation/earnings_tab.dart b/lib/features/earnings/presentation/earnings_tab.dart
new file mode 100644
index 0000000..5417c05
--- /dev/null
+++ b/lib/features/earnings/presentation/earnings_tab.dart
@@ -0,0 +1,482 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/providers.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_dimens.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../application/earnings_providers.dart';
+import '../data/earnings_models.dart';
+
+/// rgba(255,255,255,0.8) —— 余额卡内的次级文字。
+const _white80 = Color(0xCCFFFFFF);
+
+/// 柱状图绘图区高度(趋势卡总高 160,余下留给 8px 间距与周标签)。
+const _barPlotHeight = 130.0;
+
+/// 「收益」Tab,逐像素复刻源 `src/pages/earnings/index.vue`。
+///
+/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 统一提供,故此处不渲染导航。
+class EarningsTab extends ConsumerWidget {
+ const EarningsTab({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final summary = ref.watch(earningsSummaryProvider);
+ final unread = ref.watch(noticeUnreadCountProvider);
+
+ return XPageShell(
+ topBar: XTopBar.brand(
+ height: 56,
+ logoSize: 28,
+ titleSize: 18,
+ titleWeight: FontWeight.w600,
+ trailingBadge: unread,
+ onTrailingTap: () => context.push('/notice'),
+ ),
+ child: RefreshIndicator(
+ color: AppColors.primary,
+ onRefresh: () async {
+ ref.invalidate(earningsSummaryProvider);
+ await ref.read(earningsSummaryProvider.future);
+ },
+ child: summary.when(
+ loading: () => _placeholder('加载中...'),
+ error: (error, _) => _placeholder(error.toString()),
+ data: (data) => ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.only(bottom: 96),
+ children: [
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
+ child: XPageTitle(
+ title: '收益中心',
+ subtitle: '管理您的职业收入与提现详情',
+ ),
+ ),
+ _balanceCard(data),
+ _trendSection(data),
+ _transactionsSection(data),
+ _referralCard(),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ /// 加载 / 错误占位,保持可下拉(`AlwaysScrollableScrollPhysics`)。
+ Widget _placeholder(String text) => ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ children: [XPlaceholder(text: text)],
+ );
+
+ /// 余额卡:渐变底 + 总余额 + 两列指标 + 白色提现按钮。
+ Widget _balanceCard(EarningsSummary data) {
+ return Container(
+ margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
+ padding: const EdgeInsets.all(24),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [AppColors.primaryDeep, AppColors.primary],
+ ),
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '总余额',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w500,
+ color: _white80,
+ letterSpacing: 0.24,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ '¥ ${_formatAmount(data.totalBalance)}',
+ style: AppTypography.displayLg.copyWith(
+ color: AppColors.onPrimary,
+ letterSpacing: -0.8,
+ ),
+ ),
+ const SizedBox(height: 24),
+ Row(
+ children: [
+ Expanded(
+ child: _balanceItem(
+ '可提现金额',
+ _formatAmount(data.withdrawableAmount),
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: _balanceItem(
+ '本月累计收益',
+ _formatAmount(data.monthlyEarnings),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+ SizedBox(
+ height: 44,
+ width: double.infinity,
+ child: Material(
+ color: AppColors.surfaceContainerLowest,
+ borderRadius: BorderRadius.circular(10),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(10),
+ onTap: () => showAppToast('提现功能'),
+ child: const Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ XIcon('earnings', size: 20, color: AppColors.primary),
+ SizedBox(width: 8),
+ Text(
+ '立即提现',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _balanceItem(String label, String value) => Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
+ const SizedBox(height: 4),
+ Text(
+ '¥ $value',
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onPrimary,
+ ),
+ ),
+ ],
+ );
+
+ /// 最近 7 天趋势:柱高取 `trendPercents`,高亮今天(末位)。
+ Widget _trendSection(EarningsSummary data) {
+ final percents = data.trendPercents;
+ final labels = _weekdayLabels();
+ final activeIndex = percents.isEmpty ? -1 : percents.length - 1;
+
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text(
+ '最近7天趋势',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ GestureDetector(
+ onTap: () => showAppToast('近一周'),
+ behavior: HitTestBehavior.opaque,
+ child: const Row(
+ children: [
+ Text(
+ '近一周',
+ style: TextStyle(
+ fontSize: 14,
+ color: AppColors.primary,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ SizedBox(width: 4),
+ XIcon('arrow-down', size: 14, color: AppColors.primary),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ XCard(
+ radius: AppRadius.lg,
+ padding: const EdgeInsets.all(24),
+ child: SizedBox(
+ height: 160,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ for (var i = 0; i < percents.length; i++)
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ FractionallySizedBox(
+ widthFactor: 0.8,
+ child: Container(
+ height: percents[i] / 100 * _barPlotHeight,
+ decoration: BoxDecoration(
+ color: i == activeIndex
+ ? AppColors.primary
+ : AppColors.primaryFixedDim,
+ borderRadius: const BorderRadius.vertical(
+ top: Radius.circular(4),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ labels[i % labels.length],
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: i == activeIndex
+ ? FontWeight.w600
+ : FontWeight.w400,
+ color: i == activeIndex
+ ? AppColors.primary
+ : AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 收益明细:入账 / 提现两种配色 + 金额与状态。
+ Widget _transactionsSection(EarningsSummary data) {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text(
+ '收益明细',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ GestureDetector(
+ onTap: () => showAppToast('查看全部'),
+ behavior: HitTestBehavior.opaque,
+ child: const Text(
+ '查看全部',
+ style: TextStyle(
+ fontSize: 15,
+ color: AppColors.primary,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ for (var i = 0; i < data.transactions.length; i++) ...[
+ if (i > 0) const SizedBox(height: 8),
+ _transactionItem(data.transactions[i]),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _transactionItem(EarningTransaction tx) {
+ final withdrawal = tx.isWithdrawal;
+ final settled = tx.status == 'settled' || tx.status == 'completed';
+ final statusColor = settled ? AppColors.primary : AppColors.textMuted;
+
+ return XCard(
+ radius: AppRadius.lg,
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ Container(
+ width: 40,
+ height: 40,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: withdrawal
+ ? AppColors.surfaceVariant
+ : AppColors.secondaryFixed,
+ shape: BoxShape.circle,
+ ),
+ child: XIcon(
+ 'lightning',
+ size: 20,
+ color: withdrawal
+ ? AppColors.onSurfaceVariant
+ : AppColors.onSecondaryFixed,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ tx.title,
+ style: const TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ tx.subtitle,
+ style: const TextStyle(
+ fontSize: 13,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 12),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ '${withdrawal ? '-' : '+'}¥${_formatAmount(tx.amount.abs())}',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: withdrawal
+ ? AppColors.onSurface
+ : AppColors.tertiaryContainer,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 6,
+ height: 6,
+ decoration: BoxDecoration(
+ color: statusColor,
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 4),
+ Text(
+ tx.statusText,
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w500,
+ color: statusColor,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 邀请好友卡(源里无实际跳转,点击仅提示)。
+ Widget _referralCard() {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: GestureDetector(
+ onTap: () => showAppToast('邀请好友加入'),
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ color: AppColors.secondaryFixed,
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ border: Border.all(color: AppColors.borderSoft),
+ boxShadow: AppShadows.card,
+ ),
+ child: const Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '邀请好友加入',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSecondaryFixed,
+ ),
+ ),
+ SizedBox(height: 4),
+ Text(
+ '每成功邀请一位专业电工,您将获得 ¥100 额外奖励。',
+ style: TextStyle(
+ fontSize: 15,
+ color: Color(0xB30C006A),
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: 16),
+ XIcon('star', size: 48, color: AppColors.primary),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// `formatNumber`:千分位 + 两位小数,空值 / 0 回退 `0.00`(与源实现一致)。
+String _formatAmount(double? value) {
+ if (value == null || value == 0) return '0.00';
+ final parts = value.toStringAsFixed(2).split('.');
+ final grouped = parts[0].replaceAllMapped(
+ RegExp(r'\B(?=(\d{3})+(?!\d))'),
+ (_) => ',',
+ );
+ return '$grouped.${parts[1]}';
+}
+
+/// 周标签:index 6 为今天,向前回溯 6 天,保证标签与真实星期一致。
+List _weekdayLabels() {
+ const names = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
+ final today = DateTime.now();
+ return [
+ for (var i = 6; i >= 0; i--)
+ names[today.subtract(Duration(days: i)).weekday - 1],
+ ];
+}
diff --git a/lib/features/notice/data/notice_api.dart b/lib/features/notice/data/notice_api.dart
new file mode 100644
index 0000000..8a59fd4
--- /dev/null
+++ b/lib/features/notice/data/notice_api.dart
@@ -0,0 +1,149 @@
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import '../../../core/utils/id_util.dart';
+
+/// 站内消息。对应后端 `sys_notice` 的电工端视图。
+///
+/// 后端 `toJSON()` 返回原始列名,时间字段为下划线命名 `created_at`,
+/// `is_read` 为 0/1。
+class Notice {
+ const Notice({
+ required this.id,
+ required this.noticeType,
+ required this.title,
+ required this.content,
+ required this.bizType,
+ required this.bizId,
+ required this.receiverType,
+ required this.ruleId,
+ required this.batchId,
+ required this.isRead,
+ required this.createdAt,
+ });
+
+ final int id;
+
+ /// 通知类型,如 `cert_expire`。
+ final String noticeType;
+
+ final String title;
+ final String content;
+
+ /// 业务类型,如 `worker_cert`。
+ final String bizType;
+
+ /// 业务主键。
+ final int? bizId;
+
+ /// `user` 管理端 / `worker` 电工端。
+ final String receiverType;
+
+ /// 来源通知规则 / 群发批次,可能为空。
+ final int? ruleId;
+ final int? batchId;
+
+ final bool isRead;
+
+ /// 原始 `created_at`,交给 `format_util` 渲染。
+ final Object? createdAt;
+
+ factory Notice.fromJson(Map json) => Notice(
+ id: toIntOr(json['id'], 0),
+ noticeType: json['noticeType'] as String? ?? '',
+ title: json['title'] as String? ?? '',
+ content: json['content'] as String? ?? '',
+ bizType: json['bizType'] as String? ?? '',
+ bizId: toIntOrNull(json['bizId']),
+ receiverType: json['receiverType'] as String? ?? '',
+ ruleId: toIntOrNull(json['ruleId']),
+ batchId: toIntOrNull(json['batchId']),
+ isRead: _toBool(json['isRead']),
+ createdAt: json['created_at'],
+ );
+
+ /// 复制为已读,用于标记成功后本地更新,避免整页重拉。
+ Notice copiedWithRead() => Notice(
+ id: id,
+ noticeType: noticeType,
+ title: title,
+ content: content,
+ bizType: bizType,
+ bizId: bizId,
+ receiverType: receiverType,
+ ruleId: ruleId,
+ batchId: batchId,
+ isRead: true,
+ createdAt: createdAt,
+ );
+}
+
+/// 消息分页结果。
+class NoticePage {
+ const NoticePage({
+ required this.list,
+ required this.total,
+ required this.page,
+ required this.pageSize,
+ });
+
+ final List list;
+ final int total;
+ final int page;
+ final int pageSize;
+
+ factory NoticePage.fromJson(Map json) => NoticePage(
+ list: (json['list'] as List? ?? const [])
+ .map((e) => Notice.fromJson((e as Map).cast()))
+ .toList(),
+ total: toIntOr(json['total'], 0),
+ page: toIntOr(json['page'], 1),
+ pageSize: toIntOr(json['pageSize'], 10),
+ );
+}
+
+/// 站内消息中心接口,对应后端 `/api/worker-app/notices*`。
+class NoticeApi {
+ NoticeApi(this._client);
+
+ final ApiClient _client;
+
+ /// 消息列表。`isRead` 传 true/false 时按已读状态筛选,null 取全部。
+ ///
+ /// 后端 `Number(query.isRead)` 解析,故这里显式传 1 / 0。
+ Future> list({
+ int page = 1,
+ int pageSize = 10,
+ bool? isRead,
+ }) => _client.get(
+ '/worker-app/notices',
+ query: {
+ 'page': page,
+ 'pageSize': pageSize,
+ if (isRead != null) 'isRead': isRead ? 1 : 0,
+ },
+ parse: (d) => NoticePage.fromJson((d as Map).cast()),
+ );
+
+ /// 未读消息数。
+ Future> unreadCount() => _client.get(
+ '/worker-app/notices/unread-count',
+ parse: (d) => toIntOr((d as Map)['count'], 0),
+ );
+
+ /// 全部标记已读,返回更新条数。
+ Future> readAll() => _client.put(
+ '/worker-app/notices/read-all',
+ parse: (d) => toIntOr((d as Map)['updated'], 0),
+ );
+
+ /// 单条标记已读。
+ Future> markRead(int id) =>
+ _client.put('/worker-app/notices/$id/read');
+}
+
+bool _toBool(Object? v) {
+ if (v is bool) return v;
+ if (v is num) return v != 0;
+ final s = v?.toString().toLowerCase();
+ return s == '1' || s == 'true';
+}
diff --git a/lib/features/notice/presentation/notice_center_page.dart b/lib/features/notice/presentation/notice_center_page.dart
new file mode 100644
index 0000000..1ec8088
--- /dev/null
+++ b/lib/features/notice/presentation/notice_center_page.dart
@@ -0,0 +1,358 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../core/utils/format_util.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../data/notice_api.dart';
+
+/// 消息中心:站内通知列表 + 已读标记 + 分页加载。
+///
+/// 列表在页面内维护(分页游标语义强于 Riverpod 缓存),未读数经
+/// [noticeUnreadCountProvider] 统一刷新。
+class NoticeCenterPage extends ConsumerStatefulWidget {
+ const NoticeCenterPage({super.key});
+
+ @override
+ ConsumerState createState() => _NoticeCenterPageState();
+}
+
+class _NoticeCenterPageState extends ConsumerState {
+ static const _pageSize = 10;
+
+ final _scroll = ScrollController();
+ final _items = [];
+
+ int _page = 0;
+ bool _loading = false;
+ bool _loadingMore = false;
+ bool _hasMore = true;
+ String? _error;
+
+ bool get _hasUnread => _items.any((n) => !n.isRead);
+
+ @override
+ void initState() {
+ super.initState();
+ _scroll.addListener(_onScroll);
+ _load(reset: true);
+ }
+
+ @override
+ void dispose() {
+ _scroll
+ ..removeListener(_onScroll)
+ ..dispose();
+ super.dispose();
+ }
+
+ void _onScroll() {
+ if (!_scroll.hasClients) return;
+ final position = _scroll.position;
+ if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
+ }
+
+ /// 加载一页。`reset` 为真时回到第 1 页(下拉刷新 / 重试)。
+ Future _load({bool reset = false}) async {
+ if (reset) {
+ if (_loading) return;
+ setState(() {
+ _loading = true;
+ _error = null;
+ });
+ } else {
+ if (_loading || _loadingMore || !_hasMore) return;
+ setState(() => _loadingMore = true);
+ }
+
+ final nextPage = reset ? 1 : _page + 1;
+ try {
+ final res = await ref
+ .read(noticeApiProvider)
+ .list(page: nextPage, pageSize: _pageSize);
+ if (!mounted) return;
+ if (!res.isOk || res.data == null) {
+ setState(() {
+ _loading = false;
+ _loadingMore = false;
+ if (reset) _error = res.message.isEmpty ? '消息加载失败' : res.message;
+ });
+ return;
+ }
+ final data = res.data!;
+ setState(() {
+ if (reset) _items.clear();
+ _items.addAll(data.list);
+ _page = data.page;
+ _hasMore = data.list.isNotEmpty && _items.length < data.total;
+ _loading = false;
+ _loadingMore = false;
+ _error = null;
+ });
+ } on ApiException catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _loading = false;
+ _loadingMore = false;
+ if (reset) _error = e.message;
+ });
+ }
+ }
+
+ Future _loadMore() => _load();
+
+ Future _markRead(Notice notice) async {
+ if (notice.isRead) return;
+ try {
+ final res = await ref.read(noticeApiProvider).markRead(notice.id);
+ if (!mounted) return;
+ if (!res.isOk) {
+ showAppToast(res.message.isEmpty ? '操作失败' : res.message);
+ return;
+ }
+ final index = _items.indexWhere((n) => n.id == notice.id);
+ if (index >= 0) {
+ setState(() => _items[index] = _items[index].copiedWithRead());
+ }
+ ref.read(noticeUnreadCountProvider.notifier).refresh();
+ } on ApiException catch (e) {
+ if (mounted) showAppToast(e.message);
+ }
+ }
+
+ Future _markAllRead() async {
+ if (!_hasUnread) return;
+ try {
+ final res = await ref.read(noticeApiProvider).readAll();
+ if (!mounted) return;
+ if (!res.isOk) {
+ showAppToast(res.message.isEmpty ? '操作失败' : res.message);
+ return;
+ }
+ setState(() {
+ for (var i = 0; i < _items.length; i++) {
+ if (!_items[i].isRead) _items[i] = _items[i].copiedWithRead();
+ }
+ });
+ showAppToast('已全部标记为已读', success: true);
+ ref.read(noticeUnreadCountProvider.notifier).refresh();
+ } on ApiException catch (e) {
+ if (mounted) showAppToast(e.message);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: AppColors.surfaceContainerLow,
+ body: XPageShell(
+ backgroundColor: AppColors.surfaceContainerLow,
+ topBar: XTopBar.nav(
+ title: '消息中心',
+ onBack: () => context.pop(),
+ trailing: _hasUnread
+ ? GestureDetector(
+ onTap: _markAllRead,
+ behavior: HitTestBehavior.opaque,
+ child: const Padding(
+ padding: EdgeInsets.symmetric(vertical: 8, horizontal: 4),
+ child: Text(
+ '全部已读',
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w500,
+ color: AppColors.primary,
+ ),
+ ),
+ ),
+ )
+ : const SizedBox.shrink(),
+ ),
+ child: _buildBody(),
+ ),
+ );
+ }
+
+ Widget _buildBody() {
+ if (_loading && _items.isEmpty) {
+ return const Center(
+ child: SizedBox(
+ width: 26,
+ height: 26,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.4,
+ valueColor: AlwaysStoppedAnimation(AppColors.primary),
+ ),
+ ),
+ );
+ }
+
+ if (_items.isEmpty) {
+ return RefreshIndicator(
+ color: AppColors.primary,
+ onRefresh: () => _load(reset: true),
+ child: ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ children: [
+ if (_error != null)
+ XPlaceholder(text: '消息加载失败\n$_error')
+ else
+ const XPlaceholder(text: '暂无消息'),
+ if (_error != null)
+ Center(
+ child: TextButton(
+ onPressed: () => _load(reset: true),
+ child: const Text('点击重试'),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return RefreshIndicator(
+ color: AppColors.primary,
+ onRefresh: () => _load(reset: true),
+ child: ListView.separated(
+ controller: _scroll,
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+ itemCount: _items.length + 1,
+ separatorBuilder: (_, _) => const SizedBox(height: 12),
+ itemBuilder: (context, index) {
+ if (index == _items.length) return _buildFooter();
+ final notice = _items[index];
+ return _NoticeTile(
+ notice: notice,
+ onTap: notice.isRead ? null : () => _markRead(notice),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildFooter() {
+ if (_loadingMore) {
+ return const Padding(
+ padding: EdgeInsets.symmetric(vertical: 16),
+ child: Center(
+ child: SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ valueColor: AlwaysStoppedAnimation(AppColors.primary),
+ ),
+ ),
+ ),
+ );
+ }
+ if (_hasMore) {
+ return GestureDetector(
+ onTap: () => _loadMore(),
+ behavior: HitTestBehavior.opaque,
+ child: const Padding(
+ padding: EdgeInsets.symmetric(vertical: 16),
+ child: Center(
+ child: Text(
+ '加载更多',
+ style: TextStyle(fontSize: 14, color: AppColors.primary),
+ ),
+ ),
+ ),
+ );
+ }
+ return const Padding(
+ padding: EdgeInsets.symmetric(vertical: 16),
+ child: Center(
+ child: Text(
+ '没有更多了',
+ style: TextStyle(fontSize: 13, color: AppColors.textMuted),
+ ),
+ ),
+ );
+ }
+}
+
+/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。
+class _NoticeTile extends StatelessWidget {
+ const _NoticeTile({required this.notice, this.onTap});
+
+ final Notice notice;
+ final VoidCallback? onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final unread = !notice.isRead;
+ return XCard(
+ padding: const EdgeInsets.all(16),
+ background: unread
+ ? AppColors.accentSubtle
+ : AppColors.surfaceContainerLowest,
+ onTap: onTap,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (unread)
+ Padding(
+ padding: const EdgeInsets.only(top: 7, right: 8),
+ child: Container(
+ width: 8,
+ height: 8,
+ decoration: const BoxDecoration(
+ color: AppColors.error,
+ shape: BoxShape.circle,
+ ),
+ ),
+ ),
+ Expanded(
+ child: Text(
+ notice.title.isEmpty ? '系统通知' : notice.title,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: unread ? FontWeight.w600 : FontWeight.w500,
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: Text(
+ formatDateTime(notice.createdAt),
+ style: AppTypography.labelCaps.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ ],
+ ),
+ if (notice.content.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Padding(
+ padding: EdgeInsets.only(left: unread ? 16 : 0),
+ child: Text(
+ notice.content,
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurfaceVariant,
+ height: 1.5,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/order/application/order_providers.dart b/lib/features/order/application/order_providers.dart
new file mode 100644
index 0000000..af95191
--- /dev/null
+++ b/lib/features/order/application/order_providers.dart
@@ -0,0 +1,34 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import '../data/order_api.dart';
+import '../data/order_models.dart';
+
+final orderApiProvider = Provider(
+ (ref) => OrderApi(ref.watch(apiClientProvider)),
+);
+
+/// 订单池(待接订单)。接单成功后由调用方 `invalidate` 触发刷新。
+final orderPoolProvider = FutureProvider.autoDispose>((ref) async {
+ final res = await ref.watch(orderApiProvider).pool();
+ if (!res.isOk) throw ApiException(res.message);
+ return res.data ?? const [];
+});
+
+/// 我的订单,`status` 为 `pending` / `completed` / null(全部)。
+final myOrdersProvider = FutureProvider.autoDispose
+ .family, String?>((ref, status) async {
+ final res = await ref.watch(orderApiProvider).myOrders(status: status);
+ if (!res.isOk) throw ApiException(res.message);
+ return res.data ?? const [];
+ });
+
+/// 订单详情。参数为 `(source, id)`。
+final orderDetailProvider = FutureProvider.autoDispose
+ .family((ref, arg) async {
+ final res = await ref.watch(orderApiProvider).detail(arg.source, arg.id);
+ if (!res.isOk) throw ApiException(res.message);
+ if (res.data == null) throw const ApiException('订单不存在');
+ return res.data!;
+ });
diff --git a/lib/features/order/data/order_api.dart b/lib/features/order/data/order_api.dart
new file mode 100644
index 0000000..243180f
--- /dev/null
+++ b/lib/features/order/data/order_api.dart
@@ -0,0 +1,46 @@
+import '../../../core/network/api_client.dart';
+import '../../../core/network/api_response.dart';
+import 'order_models.dart';
+
+/// 订单接口,对应后端 `/api/worker-app/orders*`。
+class OrderApi {
+ OrderApi(this._client);
+
+ final ApiClient _client;
+
+ /// 订单池:四类未被接单的订单合并。
+ Future>> pool() => _client.get>(
+ '/worker-app/orders/pool',
+ parse: _parseList,
+ );
+
+ /// 我的订单。`status` 传 `pending` / `completed`,留空取全部。
+ Future>> myOrders({String? status}) =>
+ _client.get>(
+ '/worker-app/orders',
+ query: {'status': status},
+ parse: _parseList,
+ );
+
+ /// 订单详情。
+ Future> detail(String source, int id) =>
+ _client.get(
+ '/worker-app/orders/$source/$id',
+ parse: (d) => Order.fromJson((d as Map).cast()),
+ );
+
+ /// 接单。订单被他人接走时后端返回 409。
+ Future> grab(String source, int id) =>
+ _client.post('/worker-app/orders/$source/$id/grab');
+
+ /// 完成订单。非本人订单后端返回 403。
+ Future> complete(String source, int id) =>
+ _client.put('/worker-app/orders/$source/$id/complete');
+
+ static List _parseList(dynamic data) {
+ final map = (data as Map).cast();
+ return (map['list'] as List? ?? const [])
+ .map((e) => Order.fromJson((e as Map).cast()))
+ .toList();
+ }
+}
diff --git a/lib/features/order/data/order_models.dart b/lib/features/order/data/order_models.dart
new file mode 100644
index 0000000..274fc2b
--- /dev/null
+++ b/lib/features/order/data/order_models.dart
@@ -0,0 +1,113 @@
+import '../../../core/utils/id_util.dart';
+
+/// 统一订单视图,对应后端 `WorkerAppService.toOrderView()`。
+///
+/// `id` 形如 `repair-12` / `install-5`,`source` 为四类来源之一。
+class Order {
+ const Order({
+ required this.id,
+ required this.source,
+ required this.status,
+ required this.jobId,
+ required this.title,
+ required this.type,
+ required this.amount,
+ required this.distance,
+ required this.schedule,
+ required this.description,
+ required this.customerName,
+ required this.customerPhone,
+ required this.photos,
+ required this.address,
+ required this.laborFee,
+ required this.materialFee,
+ required this.estimatedDuration,
+ });
+
+ /// `repair-12`
+ final String id;
+
+ /// repair | install | maintenance | survey
+ final String source;
+
+ final int status;
+
+ /// 业务单号(repairNo / installNo / maintenanceNo / surveyNo)
+ final String jobId;
+
+ final String title;
+
+ /// urgent | standard | regular
+ final String type;
+
+ final double amount;
+ final double distance;
+ final String schedule;
+ final String description;
+ final String customerName;
+ final String customerPhone;
+ final List photos;
+ final String address;
+ final double laborFee;
+ final double materialFee;
+ final String estimatedDuration;
+
+ /// 来源中文名,用于收益明细副标题等场景。
+ String get sourceName => switch (source) {
+ 'repair' => '家庭电路维修',
+ 'install' => '设备安装',
+ 'maintenance' => '设备维保',
+ 'survey' => '现场勘察',
+ _ => '服务订单',
+ };
+
+ /// 列表页类型标签:紧急呼叫 / 标准预约 / 常规巡检。
+ String get listTypeText => switch (type) {
+ 'urgent' => '紧急呼叫',
+ 'standard' => '标准预约',
+ _ => '常规巡检',
+ };
+
+ /// 详情页类型标签:紧急 / 标准 / 常规。
+ String get detailTypeText => switch (type) {
+ 'urgent' => '紧急',
+ 'standard' => '标准',
+ _ => '常规',
+ };
+
+ /// 客户头像占位文字:后端无头像字段,用姓名首字(与「我的」页一致)。
+ String get customerAvatarText => customerName.isEmpty
+ ? '客'
+ : String.fromCharCode(customerName.runes.first);
+
+ /// 卡片上展示的订单号:后端 `id` 前缀即来源,取 `jobId` 更贴近源 `#EL-8821` 的观感。
+ String get displayNo => jobId.isNotEmpty ? jobId : id;
+
+ /// 后端接口里的数字主键:`repair-12` → 12。
+ int get intId {
+ final idx = id.lastIndexOf('-');
+ return toIntOr(idx >= 0 ? id.substring(idx + 1) : id, 0);
+ }
+
+ factory Order.fromJson(Map json) => Order(
+ id: json['id'] as String? ?? '',
+ source: json['source'] as String? ?? '',
+ status: toIntOr(json['status'], 0),
+ jobId: json['jobId'] as String? ?? '',
+ title: json['title'] as String? ?? '',
+ type: json['type'] as String? ?? 'regular',
+ amount: toDoubleOr(json['amount'], 0),
+ distance: toDoubleOr(json['distance'], 0),
+ schedule: json['schedule'] as String? ?? '',
+ description: json['description'] as String? ?? '',
+ customerName: json['customerName'] as String? ?? '',
+ customerPhone: json['customerPhone'] as String? ?? '',
+ photos:
+ (json['photos'] as List?)?.map((e) => e.toString()).toList() ??
+ const [],
+ address: json['address'] as String? ?? '',
+ laborFee: toDoubleOr(json['laborFee'], 0),
+ materialFee: toDoubleOr(json['materialFee'], 0),
+ estimatedDuration: json['estimatedDuration'] as String? ?? '',
+ );
+}
diff --git a/lib/features/order/presentation/order_detail_page.dart b/lib/features/order/presentation/order_detail_page.dart
new file mode 100644
index 0000000..1a1b9e3
--- /dev/null
+++ b/lib/features/order/presentation/order_detail_page.dart
@@ -0,0 +1,739 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_dimens.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../core/utils/format_util.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_chip.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../application/order_providers.dart';
+import '../data/order_models.dart';
+
+/// 金额展示:整数不带小数,否则保留两位。
+String _money(num v) =>
+ v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
+
+/// 订单详情页,对应源 `pages/orderdetail/index.vue`。
+///
+/// **与源的差异(后端无对应字段)**:客户卡片省略评分与任务数,改为展示电话;
+/// 服务地点只有一行地址(后端无 `addressDetail`);现场照片为空时只显示「添加」。
+class OrderDetailPage extends ConsumerWidget {
+ const OrderDetailPage({super.key, required this.source, required this.id});
+
+ /// repair | install | maintenance | survey
+ final String source;
+ final int id;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final detail = ref.watch(orderDetailProvider((source: source, id: id)));
+
+ return Scaffold(
+ body: XPageShell(
+ backgroundColor: AppColors.surfaceContainerLow,
+ topBar: XTopBar.nav(
+ title: '订单详情',
+ onBack: () => context.pop(),
+ trailing: GestureDetector(
+ onTap: () => showAppToast('分享功能'),
+ behavior: HitTestBehavior.opaque,
+ child: const Padding(
+ padding: EdgeInsets.all(8),
+ child: XIcon('share', size: 24, color: AppColors.primary),
+ ),
+ ),
+ ),
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: switch (detail) {
+ AsyncData(:final value) => _buildContent(context, value),
+ AsyncError(:final error) => XPlaceholder(text: '订单加载失败\n$error'),
+ _ => const Center(
+ child: SizedBox(
+ width: 26,
+ height: 26,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.4,
+ valueColor: AlwaysStoppedAnimation(AppColors.primary),
+ ),
+ ),
+ ),
+ },
+ ),
+ // 底部固定操作栏,对应源 `.bottom-action`
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: _BottomAction(
+ onStart: () => showAppToast('已开始出发', success: true),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildContent(BuildContext context, Order order) {
+ return SingleChildScrollView(
+ padding: const EdgeInsets.only(bottom: 120),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _MapSection(address: order.address),
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _JobCard(order: order),
+ const SizedBox(height: 12),
+ _CustomerCard(order: order),
+ const SizedBox(height: 16),
+ _PhotosSection(photos: order.photos),
+ const SizedBox(height: 16),
+ _FinancialCard(order: order),
+ const SizedBox(height: 16),
+ _LocationCard(address: order.address),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// 顶部地图占位(176 高)+ 右下角「导航」按钮。
+class _MapSection extends StatelessWidget {
+ const _MapSection({required this.address});
+
+ final String address;
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(
+ height: 176,
+ child: Stack(
+ children: [
+ const Positioned.fill(
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [Color(0xFFDDE1FF), Color(0xFFF4F3F8)],
+ ),
+ ),
+ ),
+ ),
+ const Positioned.fill(
+ child: CustomPaint(painter: _DotPatternPainter()),
+ ),
+ Center(
+ child: XIcon(
+ 'location',
+ size: 48,
+ color: AppColors.primary.withValues(alpha: 0.6),
+ ),
+ ),
+ Positioned(
+ right: 16,
+ bottom: 12,
+ child: GestureDetector(
+ onTap: () => showAppToast('打开导航'),
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 20,
+ vertical: 10,
+ ),
+ decoration: BoxDecoration(
+ color: AppColors.primary,
+ borderRadius: BorderRadius.circular(25),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x4D0052FF),
+ blurRadius: 12,
+ offset: Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: const [
+ XIcon('navigation', size: 20, color: Colors.white),
+ SizedBox(width: 8),
+ Text(
+ '导航',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// 工单卡片,对应源 `.job-card`。
+class _JobCard extends StatelessWidget {
+ const _JobCard({required this.order});
+
+ final Order order;
+
+ @override
+ Widget build(BuildContext context) {
+ return XCard(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(bottom: 4),
+ child: Text(
+ 'JOB ID: #${order.jobId}',
+ style: AppTypography.labelCaps.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ Text(
+ order.title,
+ style: AppTypography.headlineMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 12),
+ XTypeChip(
+ type: order.type,
+ text: order.detailTypeText,
+ compact: false,
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ // 描述块
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: AppColors.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ border: Border.all(color: AppColors.borderSoft),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Padding(
+ padding: EdgeInsets.only(top: 2),
+ child: XIcon('warning', size: 20, color: AppColors.error),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ '"${order.description}"',
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurfaceVariant,
+ height: 1.5,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: _MetaItem(label: '预约时间', value: order.schedule),
+ ),
+ Container(
+ width: 1,
+ height: 40,
+ color: AppColors.borderSoft,
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.only(left: 16),
+ child: _MetaItem(
+ label: '预计时长',
+ value: order.estimatedDuration,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _MetaItem extends StatelessWidget {
+ const _MetaItem({required this.label, required this.value});
+
+ final String label;
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(bottom: 4),
+ child: Text(
+ label,
+ style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
+ ),
+ ),
+ Text(
+ value.isEmpty ? '-' : value,
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// 客户卡片,对应源 `.customer-card`。
+class _CustomerCard extends StatelessWidget {
+ const _CustomerCard({required this.order});
+
+ final Order order;
+
+ @override
+ Widget build(BuildContext context) {
+ return XCard(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ Expanded(
+ child: Row(
+ children: [
+ // 后端无客户头像,用姓名首字占位(与「我的」页头像一致)
+ Container(
+ width: 48,
+ height: 48,
+ decoration: const BoxDecoration(
+ color: AppColors.surfaceContainerHighest,
+ shape: BoxShape.circle,
+ ),
+ alignment: Alignment.center,
+ child: Text(
+ order.customerAvatarText,
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ order.customerName.isEmpty ? '未知客户' : order.customerName,
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ order.customerPhone.isEmpty
+ ? '暂无联系方式'
+ : order.customerPhone,
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 12),
+ GestureDetector(
+ onTap: () => showAppToast('拨打电话'),
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: 40,
+ height: 40,
+ decoration: const BoxDecoration(
+ color: AppColors.primary,
+ shape: BoxShape.circle,
+ ),
+ alignment: Alignment.center,
+ child: const XIcon('call', size: 18, color: Colors.white),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// 现场照片横向列表。
+class _PhotosSection extends StatelessWidget {
+ const _PhotosSection({required this.photos});
+
+ final List photos;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Text(
+ '现场照片',
+ style: AppTypography.bodySm.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.textMuted,
+ letterSpacing: 0.26,
+ ),
+ ),
+ ),
+ SizedBox(
+ height: 128,
+ child: ListView(
+ scrollDirection: Axis.horizontal,
+ children: [
+ for (final url in photos) ...[
+ ClipRRect(
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ child: Image.network(
+ url,
+ width: 128,
+ height: 128,
+ fit: BoxFit.cover,
+ errorBuilder: (_, _, _) => Container(
+ width: 128,
+ height: 128,
+ color: AppColors.surfaceContainerHigh,
+ child: const XIcon(
+ 'camera',
+ size: 28,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ ],
+ GestureDetector(
+ onTap: () => showAppToast('添加现场照片'),
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: 128,
+ height: 128,
+ decoration: BoxDecoration(
+ color: AppColors.surfaceContainerLowest,
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ border: Border.all(
+ color: AppColors.textPlaceholder,
+ width: 2,
+ ),
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: const [
+ XIcon('add-photo', size: 32, color: AppColors.primary),
+ SizedBox(height: 4),
+ Text(
+ '添加',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w500,
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// 费用卡片,对应源 `.financial-card`。
+class _FinancialCard extends StatelessWidget {
+ const _FinancialCard({required this.order});
+
+ final Order order;
+
+ @override
+ Widget build(BuildContext context) {
+ return XCard(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: Text(
+ '预计收益',
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ _row('人工费', '¥${_money(order.laborFee)}'),
+ const SizedBox(height: 12),
+ _row('预计材料费', '¥${_money(order.materialFee)}'),
+ const SizedBox(height: 8),
+ Container(
+ padding: const EdgeInsets.only(top: 12),
+ decoration: const BoxDecoration(
+ border: Border(top: BorderSide(color: AppColors.borderSoft)),
+ ),
+ child: Row(
+ children: [
+ Text(
+ '总计预估',
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ const Spacer(),
+ Text(
+ '¥${_money(order.amount)}',
+ style: AppTypography.titleLg.copyWith(
+ fontWeight: FontWeight.w700,
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: AppColors.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const XIcon('info', size: 18, color: AppColors.textMuted),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ '最终金额将根据现场核查和实际材料使用情况而定。',
+ style: AppTypography.labelCaps.copyWith(
+ color: AppColors.textMuted,
+ height: 1.5,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _row(String label, String value) => Row(
+ children: [
+ Text(
+ label,
+ style: AppTypography.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
+ ),
+ const Spacer(),
+ Text(
+ value,
+ style: AppTypography.bodyMd.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ );
+}
+
+/// 服务地点卡片。
+class _LocationCard extends StatelessWidget {
+ const _LocationCard({required this.address});
+
+ final String address;
+
+ @override
+ Widget build(BuildContext context) {
+ return XCard(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Text(
+ '服务地点',
+ style: AppTypography.bodySm.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.textMuted,
+ letterSpacing: 0.26,
+ ),
+ ),
+ ),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Padding(
+ padding: EdgeInsets.only(top: 2),
+ child: XIcon('location', size: 20, color: AppColors.primary),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ address.isEmpty ? '地址待确认' : address,
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w600,
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// 底部固定「开始出发」按钮。
+class _BottomAction extends StatelessWidget {
+ const _BottomAction({required this.onStart});
+
+ final VoidCallback onStart;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: EdgeInsets.fromLTRB(
+ 16,
+ 16,
+ 16,
+ 16 + MediaQuery.paddingOf(context).bottom,
+ ),
+ decoration: const BoxDecoration(
+ color: AppColors.headerGlass,
+ border: Border(top: BorderSide(color: AppColors.borderSoft)),
+ ),
+ child: GestureDetector(
+ onTap: onStart,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ height: 54,
+ decoration: BoxDecoration(
+ color: AppColors.primary,
+ borderRadius: BorderRadius.circular(14),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x4D0052FF),
+ blurRadius: 12,
+ offset: Offset(0, 4),
+ ),
+ ],
+ ),
+ alignment: Alignment.center,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: const [
+ XIcon('bolt', size: 24, color: Colors.white),
+ SizedBox(width: 10),
+ Text(
+ '开始出发',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
+class _DotPatternPainter extends CustomPainter {
+ const _DotPatternPainter();
+
+ static const _dots = <(double, double, double)>[
+ (11, 11, 7),
+ (59, 36, 7),
+ (16, 33, 3),
+ (79, 64, 3),
+ (34, 87, 3),
+ (90, 11, 3),
+ (12, 82, 4),
+ (40, 17, 4),
+ (63, 5, 5),
+ (57, 66, 4),
+ (86, 87, 5),
+ (32, 58, 5),
+ (89, 45, 5),
+ (80, 27, 2),
+ (60, 89, 2),
+ (35, 39, 2),
+ (12, 58, 2),
+ ];
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final paint = Paint()..color = const Color(0x140052FF);
+ for (var ox = 0.0; ox < size.width; ox += 100) {
+ for (var oy = 0.0; oy < size.height; oy += 100) {
+ for (final (cx, cy, r) in _dots) {
+ canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
+ }
+ }
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
+}
diff --git a/lib/features/order/presentation/order_tab.dart b/lib/features/order/presentation/order_tab.dart
new file mode 100644
index 0000000..48d5e13
--- /dev/null
+++ b/lib/features/order/presentation/order_tab.dart
@@ -0,0 +1,647 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_dimens.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../core/utils/format_util.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_chip.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../application/order_providers.dart';
+import '../data/order_models.dart';
+
+/// 金额展示:整数不带小数,否则保留两位(源 mock 里金额是整数)。
+String _money(num v) =>
+ v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
+
+/// 订单 Tab(待接订单),对应源 `pages/order/index.vue`。
+///
+/// 顶栏与地图区固定,只有下方订单列表滚动;沿用源 `.orders-section`
+/// 24px 顶部圆角「上移压住地图」的观感(把地图高度相应减 24,视觉等价)。
+class OrderTab extends ConsumerStatefulWidget {
+ const OrderTab({super.key});
+
+ @override
+ ConsumerState createState() => _OrderTabState();
+}
+
+class _OrderTabState extends ConsumerState {
+ /// 本会话内已接单的订单 id(接单后列表会刷新移除,这里用于渲染「已接单」态)。
+ final _grabbed = {};
+ String? _grabbing;
+
+ Future _handleGrab(Order order) async {
+ if (_grabbing != null || _grabbed.contains(order.id)) return;
+ setState(() => _grabbing = order.id);
+ try {
+ final res = await ref
+ .read(orderApiProvider)
+ .grab(order.source, order.intId);
+ if (!mounted) return;
+ if (res.isOk) {
+ setState(() => _grabbed.add(order.id));
+ showAppToast('接单成功', success: true);
+ ref.invalidate(orderPoolProvider);
+ } else {
+ showAppToast(res.message.isEmpty ? '接单失败' : res.message);
+ }
+ } on ApiException catch (e) {
+ if (mounted) showAppToast(e.message);
+ } finally {
+ if (mounted) setState(() => _grabbing = null);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final pool = ref.watch(orderPoolProvider);
+ final unread = ref.watch(noticeUnreadCountProvider);
+
+ return XPageShell(
+ topBar: XTopBar.brand(
+ height: 64,
+ logoSize: 28,
+ titleSize: 20,
+ titleWeight: FontWeight.w700,
+ trailingBadge: unread,
+ onTrailingTap: () => context.push('/notice'),
+ ),
+ child: RefreshIndicator(
+ color: AppColors.primary,
+ onRefresh: () async {
+ ref.invalidate(orderPoolProvider);
+ await ref.read(orderPoolProvider.future);
+ },
+ child: ListView(
+ padding: EdgeInsets.zero,
+ physics: const AlwaysScrollableScrollPhysics(),
+ children: [
+ // 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296
+ SizedBox(height: 296, child: const _MapPlaceholder()),
+ Container(
+ decoration: const BoxDecoration(
+ color: AppColors.bgPage,
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(AppRadius.xxl),
+ ),
+ ),
+ padding: const EdgeInsets.fromLTRB(16, 24, 16, 120),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildSectionHeader(pool),
+ const SizedBox(height: 16),
+ ..._buildBody(pool),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildSectionHeader(AsyncValue> pool) {
+ final count = pool.value?.length;
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '待接订单',
+ style: AppTypography.navLargeTitle.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ count == null ? '正在获取附近订单…' : '附近有 $count 个订单',
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ GestureDetector(
+ onTap: () => showAppToast('筛选功能'),
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ const Text(
+ '筛选',
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w500,
+ color: AppColors.primary,
+ ),
+ ),
+ const SizedBox(width: 6),
+ const XIcon('filter', size: 18, color: AppColors.primary),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ List _buildBody(AsyncValue> pool) {
+ if (pool.isLoading && !pool.hasValue) {
+ return const [
+ Padding(
+ padding: EdgeInsets.symmetric(vertical: 60),
+ child: Center(
+ child: SizedBox(
+ width: 26,
+ height: 26,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.4,
+ valueColor: AlwaysStoppedAnimation(AppColors.primary),
+ ),
+ ),
+ ),
+ ),
+ ];
+ }
+ if (pool.hasError && !pool.hasValue) {
+ return [
+ XPlaceholder(text: '订单加载失败\n${pool.error}'),
+ Center(
+ child: TextButton(
+ onPressed: () => ref.invalidate(orderPoolProvider),
+ child: const Text('点击重试'),
+ ),
+ ),
+ ];
+ }
+
+ final orders = pool.value ?? const [];
+ if (orders.isEmpty) {
+ return const [XPlaceholder(text: '附近暂无待接订单')];
+ }
+ return [
+ for (final order in orders)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: _OrderCard(
+ order: order,
+ grabbing: _grabbing == order.id,
+ grabbed: _grabbed.contains(order.id),
+ onGrab: () => _handleGrab(order),
+ onTap: () => context.push('/order/${order.source}/${order.intId}'),
+ ),
+ ),
+ ];
+ }
+}
+
+/// 单张待接订单卡片,对应源 `.order-card`。
+class _OrderCard extends StatelessWidget {
+ const _OrderCard({
+ required this.order,
+ required this.grabbing,
+ required this.grabbed,
+ required this.onGrab,
+ required this.onTap,
+ });
+
+ final Order order;
+ final bool grabbing;
+ final bool grabbed;
+ final VoidCallback onGrab;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ color: AppColors.surfaceContainerLowest,
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ child: Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ border: Border.all(color: AppColors.borderSoft),
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ boxShadow: AppShadows.orderCard,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Row(
+ children: [
+ Text(
+ '#${order.displayNo}',
+ style: AppTypography.labelCaps.copyWith(
+ color: AppColors.textMuted,
+ ),
+ ),
+ const SizedBox(width: 12),
+ XTypeChip(type: order.type, text: order.listTypeText),
+ ],
+ ),
+ ),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ '¥${_money(order.amount)}',
+ style: AppTypography.headlineMd.copyWith(
+ color: AppColors.primary,
+ ),
+ ),
+ const Text(
+ '预估收益',
+ style: TextStyle(
+ fontSize: 11,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Text(
+ order.title,
+ style: AppTypography.headlineMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ const XIcon(
+ 'location',
+ size: 16,
+ color: AppColors.onSurfaceVariant,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ '${order.distance}km',
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(width: 24),
+ const XIcon(
+ 'clock',
+ size: 16,
+ color: AppColors.onSurfaceVariant,
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ order.schedule,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ _buildGrabButton(),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildGrabButton() {
+ final (Color bg, Color fg, BoxShadow? shadow, Widget icon) = switch ((
+ grabbing,
+ grabbed,
+ order.type,
+ )) {
+ (true, _, _) => (
+ AppColors.primary,
+ Colors.white,
+ _primaryShadow,
+ const _SpinningRefresh(),
+ ),
+ (_, true, _) => (
+ AppColors.tertiaryFixed,
+ AppColors.onTertiaryFixedVariant,
+ null,
+ const XIcon('check', size: 20, color: AppColors.onTertiaryFixedVariant),
+ ),
+ (_, _, 'urgent') => (
+ AppColors.primary,
+ Colors.white,
+ _primaryShadow,
+ const XIcon('forward', size: 20, color: Colors.white),
+ ),
+ _ => (
+ AppColors.surfaceContainerLow,
+ AppColors.onSurface,
+ null,
+ const XIcon('forward', size: 20, color: AppColors.onSurface),
+ ),
+ };
+
+ final label = grabbing
+ ? '接单中...'
+ : grabbed
+ ? '已接单'
+ : (order.type == 'urgent' ? '立即接单' : '接单');
+ final outlined = !grabbing && !grabbed && order.type != 'urgent';
+
+ return Opacity(
+ opacity: grabbing ? 0.7 : 1,
+ child: GestureDetector(
+ onTap: (grabbing || grabbed) ? null : onGrab,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ height: 48,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: bg,
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ border: outlined
+ ? Border.all(color: const Color(0x80C3C5D9))
+ : null,
+ boxShadow: shadow == null ? null : [shadow],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ icon,
+ const SizedBox(width: 8),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: fg,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ static const _primaryShadow = BoxShadow(
+ color: Color(0x4D0052FF),
+ blurRadius: 12,
+ offset: Offset(0, 4),
+ );
+}
+
+/// 接单中的旋转刷新图标。
+class _SpinningRefresh extends StatefulWidget {
+ const _SpinningRefresh();
+
+ @override
+ State<_SpinningRefresh> createState() => _SpinningRefreshState();
+}
+
+class _SpinningRefreshState extends State<_SpinningRefresh>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _c = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 1),
+ )..repeat();
+
+ @override
+ void dispose() {
+ _c.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => RotationTransition(
+ turns: _c,
+ child: const XIcon('refresh', size: 20, color: Colors.white),
+ );
+}
+
+/// 地图占位:源 `.map-bg` 的圆点纹理 + 两个标记 + 右侧控制按钮。
+class _MapPlaceholder extends StatelessWidget {
+ const _MapPlaceholder();
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ children: [
+ const Positioned.fill(
+ child: ColoredBox(color: AppColors.surfaceContainerHighest),
+ ),
+ const Positioned.fill(child: CustomPaint(painter: _DotPatternPainter())),
+ // 紧急标记(带脉冲光环)
+ const Positioned(
+ top: 40,
+ left: 60,
+ child: _PulsingMarker(),
+ ),
+ // 普通标记
+ Positioned(
+ top: 120,
+ right: 80,
+ child: Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: AppColors.primary,
+ shape: BoxShape.circle,
+ border: Border.all(color: Colors.white, width: 2),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x1A000000),
+ blurRadius: 8,
+ offset: Offset(0, 2),
+ ),
+ ],
+ ),
+ child: const XIcon('shandian', size: 18, color: Colors.white),
+ ),
+ ),
+ Positioned(
+ right: 16,
+ bottom: 48,
+ child: Column(
+ children: [
+ _MapControlButton(
+ icon: 'location',
+ onTap: () => showAppToast('定位中...'),
+ ),
+ const SizedBox(height: 8),
+ _MapControlButton(
+ icon: 'map',
+ onTap: () => showAppToast('图层切换'),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// 紧急标记:红色胶囊 + 呼吸光环,对应源 `.marker.urgent`。
+class _PulsingMarker extends StatefulWidget {
+ const _PulsingMarker();
+
+ @override
+ State<_PulsingMarker> createState() => _PulsingMarkerState();
+}
+
+class _PulsingMarkerState extends State<_PulsingMarker>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _c = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 2),
+ )..repeat(reverse: true);
+
+ @override
+ void dispose() {
+ _c.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ alignment: Alignment.center,
+ children: [
+ AnimatedBuilder(
+ animation: _c,
+ builder: (context, _) => Container(
+ width: 76 + 24 * _c.value,
+ height: 32 + 24 * _c.value,
+ decoration: BoxDecoration(
+ color: AppColors.secondaryContainer.withValues(
+ alpha: 0.4 * (1 - _c.value),
+ ),
+ borderRadius: BorderRadius.circular(AppRadius.full),
+ ),
+ ),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: AppColors.tertiaryContainer,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: Colors.white, width: 2),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x1A000000),
+ blurRadius: 8,
+ offset: Offset(0, 2),
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: const [
+ XIcon('shandian', size: 14, color: Colors.white),
+ SizedBox(width: 6),
+ Text(
+ '紧急',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w500,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _MapControlButton extends StatelessWidget {
+ const _MapControlButton({required this.icon, required this.onTap});
+
+ final String icon;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ color: const Color(0xE6FFFFFF),
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x14000000),
+ blurRadius: 8,
+ offset: Offset(0, 2),
+ ),
+ ],
+ ),
+ alignment: Alignment.center,
+ child: XIcon(icon, size: 20, color: AppColors.primary),
+ ),
+ );
+ }
+}
+
+/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
+class _DotPatternPainter extends CustomPainter {
+ const _DotPatternPainter();
+
+ /// 取自源 SVG 里的 (cx, cy, r) 列表。
+ static const _dots = <(double, double, double)>[
+ (11, 11, 7),
+ (59, 36, 7),
+ (16, 33, 3),
+ (79, 64, 3),
+ (34, 87, 3),
+ (90, 11, 3),
+ (12, 82, 4),
+ (40, 17, 4),
+ (63, 5, 5),
+ (57, 66, 4),
+ (86, 87, 5),
+ (32, 58, 5),
+ (89, 45, 5),
+ (80, 27, 2),
+ (60, 89, 2),
+ (35, 39, 2),
+ (12, 58, 2),
+ ];
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final paint = Paint()..color = const Color(0x140052FF);
+ for (var ox = 0.0; ox < size.width; ox += 100) {
+ for (var oy = 0.0; oy < size.height; oy += 100) {
+ for (final (cx, cy, r) in _dots) {
+ canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
+ }
+ }
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
+}
diff --git a/lib/features/profile/presentation/profile_tab.dart b/lib/features/profile/presentation/profile_tab.dart
new file mode 100644
index 0000000..905a39a
--- /dev/null
+++ b/lib/features/profile/presentation/profile_tab.dart
@@ -0,0 +1,425 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../core/network/api_response.dart';
+import '../../../core/providers.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_dimens.dart';
+import '../../../core/theme/app_typography.dart';
+import '../../../shared/widgets/app_feedback.dart';
+import '../../../shared/widgets/x_card.dart';
+import '../../../shared/widgets/x_common.dart';
+import '../../../shared/widgets/x_icon.dart';
+import '../../../shared/widgets/x_top_bar.dart';
+import '../../auth/application/session_controller.dart';
+import '../../auth/data/auth_models.dart';
+
+/// 渐变卡内各级白色透明度,对应源 `rgba(255,255,255,x)`。
+const _white80 = Color(0xCCFFFFFF);
+const _white60 = Color(0x99FFFFFF);
+const _white20 = Color(0x33FFFFFF);
+const _white15 = Color(0x26FFFFFF);
+const _white10 = Color(0x1AFFFFFF);
+
+/// 个人资料接口。后端未提供独立 provider,故在此局部声明。
+/// 源页面「我的」只展示一张证书,这里取 `WorkerProfile.primaryCert`。
+final _profileProvider = FutureProvider.autoDispose((ref) async {
+ final res = await ref.watch(authApiProvider).profile();
+ if (!res.isOk || res.data == null) throw ApiException(res.message);
+ return res.data!;
+});
+
+/// 「我的」Tab,逐像素复刻源 `src/pages/profile/index.vue`。
+///
+/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 提供。
+/// 「专业技能」区块与「从业年限」统计项后端无对应字段,已按约定省略。
+class ProfileTab extends ConsumerWidget {
+ const ProfileTab({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final session = ref.watch(sessionProvider);
+ final profileAsync = ref.watch(_profileProvider);
+ final unread = ref.watch(noticeUnreadCountProvider);
+ final profile = profileAsync.value;
+ final worker = profile?.basic ?? session.worker;
+
+ return XPageShell(
+ topBar: XTopBar.brand(
+ height: 56,
+ logoSize: 28,
+ titleSize: 18,
+ titleWeight: FontWeight.w600,
+ trailingBadge: unread,
+ onTrailingTap: () => context.push('/notice'),
+ ),
+ child: worker == null
+ ? _placeholder(profileAsync)
+ : ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.only(bottom: 96),
+ children: [
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
+ child: XPageTitle(
+ title: '我的',
+ subtitle: '管理您的个人资料与账户设置',
+ ),
+ ),
+ _profileCard(worker, profile?.primaryCert),
+ _menuSection(),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: _logoutButton(ref),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 无缓存资料时展示加载 / 错误态。
+ Widget _placeholder(AsyncValue async) {
+ final text = async.when(
+ loading: () => '加载中...',
+ error: (error, _) => error.toString(),
+ data: (_) => '加载中...',
+ );
+ return ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ children: [XPlaceholder(text: text)],
+ );
+ }
+
+ /// 资料卡:渐变底 + 头像 + 姓名 / ID + 证书区 + 两项统计。
+ Widget _profileCard(WorkerInfo worker, CertInfo? cert) {
+ final certType = cert?.certType ?? '';
+ final certTitle = certType.isEmpty ? '电工职业资格证' : certType;
+
+ return Container(
+ margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
+ padding: const EdgeInsets.all(24),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [AppColors.primaryDeep, AppColors.primary],
+ ),
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ SizedBox(
+ width: 64,
+ height: 64,
+ child: Stack(
+ children: [
+ Container(
+ width: 64,
+ height: 64,
+ alignment: Alignment.center,
+ decoration: const BoxDecoration(
+ color: AppColors.onPrimary,
+ shape: BoxShape.circle,
+ ),
+ child: Text(
+ worker.avatarText,
+ style: const TextStyle(
+ fontSize: 28,
+ fontWeight: FontWeight.w700,
+ color: AppColors.primary,
+ ),
+ ),
+ ),
+ Positioned(
+ right: 2,
+ bottom: 2,
+ child: Container(
+ width: 16,
+ height: 16,
+ decoration: BoxDecoration(
+ color: AppColors.success,
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: AppColors.primary,
+ width: 2,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ worker.displayName,
+ style: AppTypography.titleLg.copyWith(
+ color: AppColors.onPrimary,
+ letterSpacing: -0.44,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: _white20,
+ borderRadius: BorderRadius.circular(AppRadius.regular),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text(
+ 'ID',
+ style: TextStyle(
+ fontSize: 11,
+ color: _white80,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ '${worker.id}',
+ style: const TextStyle(
+ fontSize: 13,
+ color: AppColors.onPrimary,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 20),
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: _white15,
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const XIcon('cert', size: 18, color: AppColors.onPrimary),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ certTitle,
+ style: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onPrimary,
+ ),
+ ),
+ ),
+ _certBadge(worker),
+ ],
+ ),
+ const SizedBox(height: 12),
+ if (cert == null)
+ const Padding(
+ padding: EdgeInsets.symmetric(vertical: 8),
+ child: Text(
+ '暂无资质证照',
+ style: TextStyle(fontSize: 13, color: _white60),
+ ),
+ )
+ else ...[
+ _certRow('证书编号', cert.certNo),
+ _certRow('有效期至', cert.expireDate, divider: false),
+ ],
+ ],
+ ),
+ ),
+ const SizedBox(height: 20),
+ Row(
+ children: [
+ Expanded(child: _statItem('${worker.completedCount}', '已完成订单')),
+ Container(width: 1, height: 40, color: _white20),
+ Expanded(
+ child: _statItem(worker.rating.toStringAsFixed(1), '评分'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 审核通过显示绿色「已认证」,否则以弱化样式显示审核状态文案。
+ Widget _certBadge(WorkerInfo worker) {
+ final approved = worker.isApproved;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: approved ? AppColors.success : _white20,
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Text(
+ approved ? '已认证' : worker.auditStatusText,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ color: approved ? AppColors.onPrimary : _white80,
+ ),
+ ),
+ );
+ }
+
+ Widget _certRow(String label, String value, {bool divider = true}) {
+ return Container(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ decoration: BoxDecoration(
+ border: divider ? const Border(bottom: BorderSide(color: _white10)) : null,
+ ),
+ child: Row(
+ children: [
+ Text(label, style: const TextStyle(fontSize: 13, color: _white80)),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ value,
+ textAlign: TextAlign.right,
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w500,
+ color: AppColors.onPrimary,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _statItem(String value, String label) => Column(
+ children: [
+ Text(
+ value,
+ style: const TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.w700,
+ color: AppColors.onPrimary,
+ letterSpacing: -0.48,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
+ ],
+ );
+
+ /// 两组 `ios-card` 菜单,行点击仅提示(同源实现)。
+ Widget _menuSection() {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
+ child: Column(
+ children: [
+ _menuGroup(const [
+ ('order', '我的订单'),
+ ('earnings', '收益记录'),
+ ('notify', '账户设置'),
+ ]),
+ const SizedBox(height: 12),
+ _menuGroup(const [('star', '帮助中心'), ('shandian', '关于我们')]),
+ ],
+ ),
+ );
+ }
+
+ Widget _menuGroup(List<(String, String)> items) {
+ return XCard(
+ radius: AppRadius.lg,
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: Column(
+ children: [
+ for (var i = 0; i < items.length; i++) ...[
+ if (i > 0)
+ Container(
+ height: 1,
+ margin: const EdgeInsets.symmetric(horizontal: 16),
+ color: AppColors.borderSoft,
+ ),
+ _menuRow(items[i].$1, items[i].$2),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _menuRow(String icon, String label) {
+ return GestureDetector(
+ onTap: () => showAppToast(label),
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ XIcon(icon, size: 22, color: AppColors.onSurfaceVariant),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ label,
+ style: AppTypography.bodyLg.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ ),
+ const XIcon(
+ 'arrow-right',
+ size: 16,
+ color: AppColors.textPlaceholder,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _logoutButton(WidgetRef ref) {
+ return XCard(
+ radius: AppRadius.lg,
+ padding: EdgeInsets.zero,
+ onTap: () => _handleLogout(ref),
+ child: const SizedBox(
+ height: 48,
+ width: double.infinity,
+ child: Center(
+ child: Text(
+ '退出登录',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: AppColors.tertiaryContainer,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future _handleLogout(WidgetRef ref) async {
+ final confirmed = await showAppConfirm(
+ title: '提示',
+ content: '确定要退出登录吗?',
+ confirmText: '确定',
+ cancelText: '取消',
+ );
+ if (!confirmed) return;
+ await ref.read(sessionProvider.notifier).logout();
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..368ce17
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,20 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import 'app/app.dart';
+import 'core/providers.dart';
+import 'core/storage/token_storage.dart';
+
+Future main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+
+ // SharedPreferences 必须在 runApp 之前完成异步读取,再通过 override 注入。
+ final storage = await TokenStorage.open();
+
+ runApp(
+ ProviderScope(
+ overrides: [tokenStorageProvider.overrideWithValue(storage)],
+ child: const VoltLinkApp(),
+ ),
+ );
+}
diff --git a/lib/shared/widgets/app_feedback.dart b/lib/shared/widgets/app_feedback.dart
new file mode 100644
index 0000000..8477cdf
--- /dev/null
+++ b/lib/shared/widgets/app_feedback.dart
@@ -0,0 +1,143 @@
+import 'package:flutter/material.dart';
+
+import '../../core/app_keys.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/theme/app_dimens.dart';
+import '../../core/theme/app_typography.dart';
+
+/// `uni.showToast({ title, icon })` 的等价实现。
+/// 不依赖调用方的 BuildContext,可在任意异步回调里安全使用。
+void showAppToast(String title, {bool success = false}) {
+ final messenger = AppKeys.scaffoldMessenger.currentState;
+ if (messenger == null) return;
+ messenger
+ ..clearSnackBars()
+ ..showSnackBar(
+ SnackBar(
+ content: Row(
+ children: [
+ if (success) ...[
+ const Icon(
+ Icons.check_circle,
+ size: 18,
+ color: AppColors.success,
+ ),
+ const SizedBox(width: 8),
+ ],
+ Expanded(
+ child: Text(
+ title,
+ style: AppTypography.bodyMd.copyWith(color: AppColors.surface),
+ ),
+ ),
+ ],
+ ),
+ backgroundColor: AppColors.inverseSurface,
+ behavior: SnackBarBehavior.floating,
+ margin: const EdgeInsets.all(AppSpacing.md),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ ),
+ duration: Duration(milliseconds: success ? 1500 : 2200),
+ ),
+ );
+}
+
+/// `uni.showLoading({ title })` 的等价实现,返回关闭函数。
+VoidCallback showAppLoading([String title = '加载中...']) {
+ final navigator = AppKeys.rootNavigator.currentState;
+ if (navigator == null) return () {};
+ var closed = false;
+ showDialog(
+ context: navigator.context,
+ barrierDismissible: false,
+ builder: (_) => PopScope(
+ canPop: false,
+ child: Center(
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
+ decoration: BoxDecoration(
+ color: const Color(0xCC000000),
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SizedBox(
+ width: 26,
+ height: 26,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.4,
+ valueColor: AlwaysStoppedAnimation(Colors.white),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ title,
+ style: AppTypography.bodyMd.copyWith(color: Colors.white),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ return () {
+ if (closed) return;
+ closed = true;
+ final current = AppKeys.rootNavigator.currentState;
+ if (current != null && current.canPop()) current.pop();
+ };
+}
+
+/// `uni.showModal({ title, content })` 的等价实现,返回用户是否点了确认。
+Future showAppConfirm({
+ required String title,
+ required String content,
+ String confirmText = '确定',
+ String cancelText = '取消',
+ bool destructive = false,
+}) async {
+ final navigator = AppKeys.rootNavigator.currentState;
+ if (navigator == null) return false;
+ final result = await showDialog(
+ context: navigator.context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: AppColors.surfaceContainerLowest,
+ surfaceTintColor: Colors.transparent,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(AppRadius.xl),
+ ),
+ title: Text(
+ title,
+ textAlign: TextAlign.center,
+ style: AppTypography.bodyLg.copyWith(fontWeight: FontWeight.w700),
+ ),
+ content: Text(
+ content,
+ textAlign: TextAlign.center,
+ style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
+ ),
+ actionsAlignment: MainAxisAlignment.spaceEvenly,
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: Text(
+ cancelText,
+ style: AppTypography.bodyLg.copyWith(color: AppColors.textMuted),
+ ),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(true),
+ child: Text(
+ confirmText,
+ style: AppTypography.bodyLg.copyWith(
+ color: destructive ? AppColors.tertiaryContainer : AppColors.primary,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ return result ?? false;
+}
diff --git a/lib/shared/widgets/x_bottom_nav.dart b/lib/shared/widgets/x_bottom_nav.dart
new file mode 100644
index 0000000..52cf8ea
--- /dev/null
+++ b/lib/shared/widgets/x_bottom_nav.dart
@@ -0,0 +1,85 @@
+import 'package:flutter/material.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/theme/app_dimens.dart';
+import 'x_icon.dart';
+
+/// 底部导航,对应 `components/CustomTabBar.vue`。
+///
+/// 高 64,毛玻璃底色 rgba(250,249,254,0.95),上边框 + 轻微上投影,
+/// 三个 Tab 均分,激活态整体变品牌蓝。底部安全区一并覆盖。
+class XBottomNav extends StatelessWidget {
+ const XBottomNav({super.key, required this.current, required this.onChanged});
+
+ /// `order` | `earnings` | `profile`
+ final String current;
+ final ValueChanged onChanged;
+
+ static const items = <({String key, String label, String icon})>[
+ (key: 'order', label: '订单', icon: 'dingdan'),
+ (key: 'earnings', label: '收益', icon: 'shouyi'),
+ (key: 'profile', label: '我的', icon: 'wode'),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ final bottomInset = MediaQuery.paddingOf(context).bottom;
+
+ return Container(
+ padding: EdgeInsets.only(bottom: bottomInset),
+ decoration: const BoxDecoration(
+ color: AppColors.navGlass,
+ border: Border(top: BorderSide(color: AppColors.borderSoft)),
+ boxShadow: AppShadows.topBar,
+ ),
+ child: SizedBox(
+ height: 64,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ for (final item in items)
+ GestureDetector(
+ onTap: () {
+ if (item.key == current) return;
+ onChanged(item.key);
+ },
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 6,
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ XIcon(
+ item.icon,
+ size: 24,
+ color: item.key == current
+ ? AppColors.primary
+ : AppColors.textMuted,
+ ),
+ const SizedBox(height: 2),
+ Text(
+ item.label,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w500,
+ color: item.key == current
+ ? AppColors.primary
+ : AppColors.textMuted,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/shared/widgets/x_card.dart b/lib/shared/widgets/x_card.dart
new file mode 100644
index 0000000..53c267c
--- /dev/null
+++ b/lib/shared/widgets/x_card.dart
@@ -0,0 +1,59 @@
+import 'package:flutter/material.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/theme/app_dimens.dart';
+
+/// 各页面 `.ios-card` 的等价实现:白底 + 浅描边 + 圆角 + 极淡阴影。
+class XCard extends StatelessWidget {
+ const XCard({
+ super.key,
+ required this.child,
+ this.padding,
+ this.margin,
+ this.radius = AppRadius.xl,
+ this.background = AppColors.surfaceContainerLowest,
+ this.borderColor = AppColors.borderSoft,
+ this.shadows = AppShadows.card,
+ this.onTap,
+ });
+
+ final Widget child;
+ final EdgeInsetsGeometry? padding;
+ final EdgeInsetsGeometry? margin;
+ final double radius;
+ final Color background;
+ final Color borderColor;
+
+ /// 订单卡片用 [AppShadows.orderCard](0 2px 8px),其余用 [AppShadows.card]。
+ final List shadows;
+ final VoidCallback? onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final r = BorderRadius.circular(radius);
+ final content = Container(
+ padding: padding,
+ decoration: BoxDecoration(
+ color: background,
+ border: Border.all(color: borderColor),
+ borderRadius: r,
+ boxShadow: shadows,
+ ),
+ child: child,
+ );
+
+ return Padding(
+ padding: margin ?? EdgeInsets.zero,
+ child: onTap == null
+ ? content
+ : Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: r,
+ child: content,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/shared/widgets/x_chip.dart b/lib/shared/widgets/x_chip.dart
new file mode 100644
index 0000000..7a150af
--- /dev/null
+++ b/lib/shared/widgets/x_chip.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+
+import '../../core/theme/app_colors.dart';
+
+/// 订单类型标签。对应源里的 `.order-type` / `.job-type`:
+/// - `urgent` 紧急:浅红底 + 深红字
+/// - `standard` 标准 / `regular` 常规:浅灰底 + 深灰字
+///
+/// 列表页字号 10,详情页 13,用 [compact] 区分。
+class XTypeChip extends StatelessWidget {
+ const XTypeChip({
+ super.key,
+ required this.type,
+ required this.text,
+ this.compact = true,
+ });
+
+ /// `urgent` | `standard` | `regular`
+ final String type;
+ final String text;
+ final bool compact;
+
+ @override
+ Widget build(BuildContext context) {
+ final urgent = type == 'urgent';
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: urgent ? AppColors.errorContainer : AppColors.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Text(
+ text,
+ style: TextStyle(
+ fontSize: compact ? 10 : 13,
+ fontWeight: FontWeight.w600,
+ color: urgent
+ ? (compact ? AppColors.onErrorContainer : AppColors.error)
+ : AppColors.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/shared/widgets/x_common.dart b/lib/shared/widgets/x_common.dart
new file mode 100644
index 0000000..5197188
--- /dev/null
+++ b/lib/shared/widgets/x_common.dart
@@ -0,0 +1,190 @@
+import 'package:flutter/material.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/theme/app_dimens.dart';
+import '../../core/theme/app_typography.dart';
+
+/// 页面骨架:固定顶栏 + 内容区。对应源「根容器固定、滚动只发生在内容区」的布局。
+class XPageShell extends StatelessWidget {
+ const XPageShell({
+ super.key,
+ required this.topBar,
+ required this.child,
+ this.backgroundColor = AppColors.bgPage,
+ });
+
+ final Widget topBar;
+ final Widget child;
+ final Color backgroundColor;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ color: backgroundColor,
+ child: Column(
+ children: [topBar, Expanded(child: child)],
+ ),
+ );
+ }
+}
+
+/// 页面大标题 + 副标题,对应 `.page-header` / `.section-title`(28px/w700)。
+class XPageTitle extends StatelessWidget {
+ const XPageTitle({super.key, required this.title, this.subtitle});
+
+ final String title;
+ final String? subtitle;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: AppTypography.navLargeTitle.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ if (subtitle != null) ...[
+ const SizedBox(height: 4),
+ Text(
+ subtitle!,
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ],
+ );
+ }
+}
+
+/// 空态 / 加载中占位。
+class XPlaceholder extends StatelessWidget {
+ const XPlaceholder({
+ super.key,
+ required this.text,
+ this.padding = const EdgeInsets.symmetric(vertical: 60),
+ });
+
+ final String text;
+ final EdgeInsetsGeometry padding;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: padding,
+ child: Center(
+ child: Text(
+ text,
+ textAlign: TextAlign.center,
+ style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
+ ),
+ ),
+ );
+ }
+}
+
+/// 实心主按钮,对应 `.login-btn` / `.start-btn` 等。
+class XPrimaryButton extends StatelessWidget {
+ const XPrimaryButton({
+ super.key,
+ required this.label,
+ required this.onPressed,
+ this.leading,
+ this.loading = false,
+ this.height = 48,
+ this.radius = AppRadius.lg,
+ this.background = AppColors.primary,
+ this.foreground = AppColors.onPrimary,
+ this.fontSize = 17,
+ this.shadows,
+ });
+
+ final String label;
+ final VoidCallback? onPressed;
+ final Widget? leading;
+ final bool loading;
+ final double height;
+ final double radius;
+ final Color background;
+ final Color foreground;
+ final double fontSize;
+ final List? shadows;
+
+ @override
+ Widget build(BuildContext context) {
+ final disabled = onPressed == null || loading;
+
+ return Opacity(
+ opacity: disabled && !loading ? 0.55 : 1,
+ child: SizedBox(
+ height: height,
+ width: double.infinity,
+ child: Material(
+ color: background,
+ borderRadius: BorderRadius.circular(radius),
+ child: InkWell(
+ onTap: disabled ? null : onPressed,
+ borderRadius: BorderRadius.circular(radius),
+ child: Container(
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(radius),
+ boxShadow: shadows,
+ ),
+ child: loading
+ ? SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ valueColor: AlwaysStoppedAnimation(foreground),
+ ),
+ )
+ : Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (leading != null) ...[
+ leading!,
+ const SizedBox(width: 8),
+ ],
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: fontSize,
+ fontWeight: FontWeight.w600,
+ color: foreground,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// 区块小标题(20/w600),对应 `.chart-title` / `.section-title`。
+class XSectionTitle extends StatelessWidget {
+ const XSectionTitle(this.text, {super.key, this.trailing});
+
+ final String text;
+ final Widget? trailing;
+
+ @override
+ Widget build(BuildContext context) {
+ final title = Text(
+ text,
+ style: AppTypography.headlineMd.copyWith(color: AppColors.onSurface),
+ );
+ if (trailing == null) return title;
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [title, trailing!],
+ );
+ }
+}
diff --git a/lib/shared/widgets/x_icon.dart b/lib/shared/widgets/x_icon.dart
new file mode 100644
index 0000000..68b35f2
--- /dev/null
+++ b/lib/shared/widgets/x_icon.dart
@@ -0,0 +1,158 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+import '../../core/theme/app_colors.dart';
+
+/// 图标组件,对应 uni-app 版页面里的 ``。
+///
+/// **为什么是混合方案**:源工程只提供了 8 个真实字形(`iconfont.ttf`),而页面里
+/// 引用的 `icon-lightning` / `icon-location` / `icon-star` 等**在字体里并不存在**,
+/// 源工程实际渲染为空白。为保证复刻后的界面可读可用,这里:
+/// - 8 个真实字形继续用搬运过来的 `iconfont.ttf` 渲染,保持逐像素一致;
+/// - 其余语义图标改用 SVG 路径绘制,线条风格与源设计语言保持一致。
+class XIcon extends StatelessWidget {
+ const XIcon(
+ this.name, {
+ super.key,
+ this.size = 20,
+ this.color = AppColors.textMuted,
+ });
+
+ final String name;
+ final double size;
+ final Color color;
+
+ /// `iconfont.ttf` 中真实存在的字形 -> Unicode 码点(取自源 `iconfont.css`)。
+ static const Map glyphs = {
+ 'shandian': 0xe6a4,
+ 'tongzhi': 0xe60a,
+ 'qianbao': 0xe60b,
+ 'yaoqing': 0x100a5,
+ 'dingdan': 0xe62a,
+ 'weixin': 0xe665,
+ 'wode': 0xe607,
+ 'shouyi': 0x10186,
+ };
+
+ /// 语义图标的 SVG 路径(24×24 视图框,`currentColor` 会被替换成实际色值)。
+ static const Map paths = {
+ 'bolt': ' ',
+ 'location':
+ ' ',
+ 'clock':
+ ' ',
+ 'filter': ' ',
+ 'map':
+ ' ',
+ 'refresh':
+ ' ',
+ 'check':
+ ' ',
+ 'forward':
+ ' ',
+ 'back':
+ ' ',
+ 'arrow-down':
+ ' ',
+ 'star': ' ',
+ 'verified':
+ ' ',
+ 'notification':
+ ' ',
+ 'share':
+ ' ',
+ 'navigation':
+ ' ',
+ 'call':
+ ' ',
+ 'add-photo':
+ ' ',
+ 'info':
+ ' ',
+ 'warning': ' ',
+ 'wallet':
+ ' ',
+ 'list':
+ ' ',
+ 'settings':
+ ' ',
+ 'trend-up':
+ ' ',
+ 'close':
+ ' ',
+ 'person': ' ',
+ 'camera':
+ ' ',
+ };
+
+ /// 源里两种命名风格混用(uni-app 的 `icon-xxx` 与 Material Symbols 名),
+ /// 这里统一做别名收敛,调用方两种写法都能渲染。
+ static const Map _aliases = {
+ 'lightning': 'bolt',
+ 'time': 'clock',
+ 'cert': 'verified',
+ 'notify': 'notification',
+ 'arrow-right': 'forward',
+ 'order': 'list',
+ 'earnings': 'wallet',
+ 'chevron_left': 'back',
+ 'location_on': 'location',
+ 'directions': 'navigation',
+ 'add_a_photo': 'add-photo',
+ };
+
+ static final Map _svgCache = {};
+
+ static String _hex(Color color) =>
+ '#${(color.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}';
+
+ /// 归一化后的语义名:别名 -> 目标名。
+ static String resolve(String name) => _aliases[name] ?? name;
+
+ /// 生成 (图标, 颜色) 组合对应的 SVG 字符串,带缓存避免重复拼接。
+ static String svgFor(String name, Color color) {
+ final key = '$name|${color.toARGB32()}';
+ return _svgCache.putIfAbsent(key, () {
+ final body = (paths[resolve(name)] ?? paths['close']!).replaceAll(
+ 'currentColor',
+ _hex(color),
+ );
+ return '$body ';
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final glyph = glyphs[name];
+ if (glyph != null) {
+ // 源里的彩色字形:直接交给 iconfont.ttf 渲染,保留原字形配色。
+ return SizedBox(
+ width: size,
+ height: size,
+ child: Center(
+ child: Text(
+ String.fromCharCode(glyph),
+ style: TextStyle(
+ fontFamily: 'iconfont',
+ fontSize: size,
+ height: 1,
+ color: color,
+ ),
+ ),
+ ),
+ );
+ }
+
+ return SizedBox(
+ width: size,
+ height: size,
+ child: SvgPicture.string(
+ svgFor(name, color),
+ width: size,
+ height: size,
+ fit: BoxFit.contain,
+ ),
+ );
+ }
+}
diff --git a/lib/shared/widgets/x_top_bar.dart b/lib/shared/widgets/x_top_bar.dart
new file mode 100644
index 0000000..9820db9
--- /dev/null
+++ b/lib/shared/widgets/x_top_bar.dart
@@ -0,0 +1,214 @@
+import 'package:flutter/material.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/theme/app_dimens.dart';
+import '../../core/theme/app_typography.dart';
+import 'x_icon.dart';
+
+/// 固定顶栏,对应各页面 `.header.ios-blur` + `.header-content`。
+///
+/// 源里三种形态:
+/// - 订单页 [XTopBar.brand]:高 64,闪电 + VoltLink(20/w700) + 通知
+/// - 收益/我的 [XTopBar.brand]:高 56,闪电 + VoltLink(18/w600) + 通知
+/// - 订单详情 [XTopBar.nav]:高 44,返回 + 居中标题 + 分享
+///
+/// 顶栏自带状态栏内边距,毛玻璃底色与源 `rgba(250,249,254,0.85)` 一致。
+class XTopBar extends StatelessWidget {
+ const XTopBar._({
+ required this.height,
+ required this.leading,
+ required this.title,
+ required this.trailing,
+ required this.centerTitle,
+ });
+
+ /// 品牌顶栏(一级 Tab 页)。
+ ///
+ /// [trailingBadge] 大于 0 时在铃铛右上角显示未读红点角标(>99 显示 99+)。
+ factory XTopBar.brand({
+ Key? key,
+ double height = 64,
+ double logoSize = 28,
+ double titleSize = 20,
+ FontWeight titleWeight = FontWeight.w700,
+ String title = 'VoltLink',
+ String trailingIcon = 'tongzhi',
+ VoidCallback? onTrailingTap,
+ int trailingBadge = 0,
+ }) {
+ return XTopBar._(
+ height: height,
+ leading: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ XIcon('shandian', size: logoSize, color: AppColors.primary),
+ const SizedBox(width: 8),
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: titleSize,
+ fontWeight: titleWeight,
+ color: AppColors.onSurface,
+ letterSpacing: -0.2,
+ ),
+ ),
+ ],
+ ),
+ title: '',
+ centerTitle: false,
+ trailing: GestureDetector(
+ onTap: onTrailingTap,
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: _TrailingIcon(icon: trailingIcon, badge: trailingBadge),
+ ),
+ ),
+ );
+ }
+
+ /// 二级页顶栏:返回 + 居中标题 + 右侧操作位。
+ factory XTopBar.nav({
+ Key? key,
+ required String title,
+ double height = 44,
+ VoidCallback? onBack,
+ Widget? trailing,
+ }) {
+ return XTopBar._(
+ height: height,
+ centerTitle: true,
+ title: title,
+ leading: GestureDetector(
+ onTap: onBack,
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const XIcon('chevron_left', size: 26, color: AppColors.primary),
+ Text(
+ '返回',
+ style: AppTypography.bodyLg.copyWith(
+ color: AppColors.primary,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ trailing: trailing ?? const SizedBox.shrink(),
+ );
+ }
+
+ final double height;
+ final Widget leading;
+ final String title;
+ final Widget trailing;
+ final bool centerTitle;
+
+ @override
+ Widget build(BuildContext context) {
+ final topInset = MediaQuery.paddingOf(context).top;
+
+ return Container(
+ padding: EdgeInsets.only(top: topInset),
+ decoration: const BoxDecoration(
+ color: AppColors.headerGlass,
+ border: Border(bottom: BorderSide(color: AppColors.borderSoft)),
+ boxShadow: AppShadows.topBar,
+ ),
+ child: SizedBox(
+ height: height,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: centerTitle
+ ? Row(
+ children: [
+ Expanded(
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: leading,
+ ),
+ ),
+ Text(
+ title,
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ Expanded(
+ child: Align(
+ alignment: Alignment.centerRight,
+ child: trailing,
+ ),
+ ),
+ ],
+ )
+ : Row(
+ children: [
+ leading,
+ const Spacer(),
+ trailing,
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// 顶栏右侧图标 + 可选未读角标。
+class _TrailingIcon extends StatelessWidget {
+ const _TrailingIcon({required this.icon, required this.badge});
+
+ final String icon;
+ final int badge;
+
+ @override
+ Widget build(BuildContext context) {
+ final base = XIcon(icon, size: 24, color: AppColors.onSurfaceVariant);
+ if (badge <= 0) return base;
+
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [
+ base,
+ Positioned(top: -3, right: -6, child: _UnreadBadge(count: badge)),
+ ],
+ );
+ }
+}
+
+/// 未读红色角标:显示条数,超过 99 显示 `99+`。
+class _UnreadBadge extends StatelessWidget {
+ const _UnreadBadge({required this.count});
+
+ final int count;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ constraints: const BoxConstraints(minWidth: 16),
+ height: 16,
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: AppColors.error,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: AppColors.onPrimary, width: 1.5),
+ ),
+ child: Text(
+ count > 99 ? '99+' : '$count',
+ style: const TextStyle(
+ fontSize: 10,
+ height: 1,
+ fontWeight: FontWeight.w600,
+ color: AppColors.onPrimary,
+ ),
+ ),
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
new file mode 100644
index 0000000..7e743df
--- /dev/null
+++ b/pubspec.lock
@@ -0,0 +1,743 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ _fe_analyzer_shared:
+ dependency: transitive
+ description:
+ name: _fe_analyzer_shared
+ sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "93.0.0"
+ analyzer:
+ dependency: transitive
+ description:
+ name: analyzer
+ sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
+ url: "https://pub.dev"
+ source: hosted
+ version: "10.0.1"
+ args:
+ dependency: transitive
+ description:
+ name: args
+ sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.7.0"
+ async:
+ dependency: transitive
+ description:
+ name: async
+ sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.13.1"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ characters:
+ dependency: transitive
+ description:
+ name: characters
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ cli_config:
+ dependency: transitive
+ description:
+ name: cli_config
+ sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.0"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.19.1"
+ convert:
+ dependency: transitive
+ description:
+ name: convert
+ sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.2"
+ coverage:
+ dependency: transitive
+ description:
+ name: coverage
+ sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.15.1"
+ crypto:
+ dependency: transitive
+ description:
+ name: crypto
+ sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.7"
+ cupertino_icons:
+ dependency: "direct main"
+ description:
+ name: cupertino_icons
+ sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.9"
+ dio:
+ dependency: "direct main"
+ description:
+ name: dio
+ sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.11.1"
+ dio_web_adapter:
+ dependency: transitive
+ description:
+ name: dio_web_adapter
+ sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.2"
+ fake_async:
+ dependency: transitive
+ description:
+ name: fake_async
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.3"
+ ffi:
+ dependency: transitive
+ description:
+ name: ffi
+ sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
+ flutter:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.0"
+ flutter_localizations:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_riverpod:
+ dependency: "direct main"
+ description:
+ name: flutter_riverpod
+ sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.3.2"
+ flutter_svg:
+ dependency: "direct main"
+ description:
+ name: flutter_svg
+ sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.0"
+ flutter_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_web_plugins:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ frontend_server_client:
+ dependency: transitive
+ description:
+ name: frontend_server_client
+ sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.0"
+ glob:
+ dependency: transitive
+ description:
+ name: glob
+ sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ go_router:
+ dependency: "direct main"
+ description:
+ name: go_router
+ sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea
+ url: "https://pub.dev"
+ source: hosted
+ version: "17.5.0"
+ http:
+ dependency: transitive
+ description:
+ name: http
+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.6.0"
+ http_multi_server:
+ dependency: transitive
+ description:
+ name: http_multi_server
+ sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.2"
+ http_parser:
+ dependency: transitive
+ description:
+ name: http_parser
+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.2"
+ intl:
+ dependency: transitive
+ description:
+ name: intl
+ sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.20.2"
+ io:
+ dependency: transitive
+ description:
+ name: io
+ sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.0.2"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.10"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.0"
+ logging:
+ dependency: transitive
+ description:
+ name: logging
+ sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.19"
+ material_color_utilities:
+ dependency: transitive
+ description:
+ name: material_color_utilities
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.0"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.17.0"
+ mime:
+ dependency: transitive
+ description:
+ name: mime
+ sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
+ node_preamble:
+ dependency: transitive
+ description:
+ name: node_preamble
+ sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.2"
+ package_config:
+ dependency: transitive
+ description:
+ name: package_config
+ sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.1"
+ path_parsing:
+ dependency: transitive
+ description:
+ name: path_parsing
+ sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ path_provider_linux:
+ dependency: transitive
+ description:
+ name: path_provider_linux
+ sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.2"
+ path_provider_platform_interface:
+ dependency: transitive
+ description:
+ name: path_provider_platform_interface
+ sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.3"
+ path_provider_windows:
+ dependency: transitive
+ description:
+ name: path_provider_windows
+ sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.0"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.2"
+ platform:
+ dependency: transitive
+ description:
+ name: platform
+ sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.6"
+ plugin_platform_interface:
+ dependency: transitive
+ description:
+ name: plugin_platform_interface
+ sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.8"
+ pool:
+ dependency: transitive
+ description:
+ name: pool
+ sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.5.3"
+ pub_semver:
+ dependency: transitive
+ description:
+ name: pub_semver
+ sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.1"
+ riverpod:
+ dependency: transitive
+ description:
+ name: riverpod
+ sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.3.2"
+ shared_preferences:
+ dependency: "direct main"
+ description:
+ name: shared_preferences
+ sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.5"
+ shared_preferences_android:
+ dependency: transitive
+ description:
+ name: shared_preferences_android
+ sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.23"
+ shared_preferences_foundation:
+ dependency: transitive
+ description:
+ name: shared_preferences_foundation
+ sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.7"
+ shared_preferences_linux:
+ dependency: transitive
+ description:
+ name: shared_preferences_linux
+ sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shared_preferences_platform_interface:
+ dependency: transitive
+ description:
+ name: shared_preferences_platform_interface
+ sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ shared_preferences_web:
+ dependency: transitive
+ description:
+ name: shared_preferences_web
+ sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ shared_preferences_windows:
+ dependency: transitive
+ description:
+ name: shared_preferences_windows
+ sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shelf:
+ dependency: transitive
+ description:
+ name: shelf
+ sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.2"
+ shelf_packages_handler:
+ dependency: transitive
+ description:
+ name: shelf_packages_handler
+ sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ shelf_static:
+ dependency: transitive
+ description:
+ name: shelf_static
+ sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.3"
+ shelf_web_socket:
+ dependency: transitive
+ description:
+ name: shelf_web_socket
+ sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.0"
+ sky_engine:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ source_map_stack_trace:
+ dependency: transitive
+ description:
+ name: source_map_stack_trace
+ sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ source_maps:
+ dependency: transitive
+ description:
+ name: source_maps
+ sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.10.14"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.10.2"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.12.1"
+ state_notifier:
+ dependency: transitive
+ description:
+ name: state_notifier
+ sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ test:
+ dependency: transitive
+ description:
+ name: test
+ sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.30.0"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.10"
+ test_core:
+ dependency: transitive
+ description:
+ name: test_core
+ sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.6.16"
+ typed_data:
+ dependency: transitive
+ description:
+ name: typed_data
+ sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ uuid:
+ dependency: transitive
+ description:
+ name: uuid
+ sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.6.0"
+ vector_graphics:
+ dependency: transitive
+ description:
+ name: vector_graphics
+ sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.3"
+ vector_graphics_codec:
+ dependency: transitive
+ description:
+ name: vector_graphics_codec
+ sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.13"
+ vector_graphics_compiler:
+ dependency: transitive
+ description:
+ name: vector_graphics_compiler
+ sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
+ vector_math:
+ dependency: transitive
+ description:
+ name: vector_math
+ sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "15.3.0"
+ watcher:
+ dependency: transitive
+ description:
+ name: watcher
+ sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ web:
+ dependency: transitive
+ description:
+ name: web
+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
+ web_socket:
+ dependency: transitive
+ description:
+ name: web_socket
+ sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.1"
+ web_socket_channel:
+ dependency: transitive
+ description:
+ name: web_socket_channel
+ sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.3"
+ webkit_inspection_protocol:
+ dependency: transitive
+ description:
+ name: webkit_inspection_protocol
+ sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ xdg_directories:
+ dependency: transitive
+ description:
+ name: xdg_directories
+ sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
+ yaml:
+ dependency: transitive
+ description:
+ name: yaml
+ sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.4"
+sdks:
+ dart: ">=3.11.5 <4.0.0"
+ flutter: ">=3.38.0"
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..44a9358
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,36 @@
+name: smart_grid_iot_app_flutter
+description: "VoltLink 共享电工平台 - 电工端 App"
+publish_to: 'none'
+
+version: 1.0.0+100
+
+environment:
+ sdk: ^3.11.5
+
+dependencies:
+ flutter:
+ sdk: flutter
+ flutter_localizations:
+ sdk: flutter
+
+ cupertino_icons: ^1.0.8
+ flutter_riverpod: ^3.3.2
+ go_router: ^17.5.0
+ dio: ^5.11.1
+ shared_preferences: ^2.5.5
+ flutter_svg: ^2.3.0
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+ flutter_lints: ^6.0.0
+
+flutter:
+ uses-material-design: true
+
+ # 源工程 iconfont(8 个字形)原样搬运,Tab 栏/品牌闪电/通知/微信图标逐像素一致。
+ fonts:
+ - family: iconfont
+ fonts:
+ - asset: assets/fonts/iconfont.ttf