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