diff --git a/apps/service_app/.gitignore b/apps/service_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/service_app/.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/apps/service_app/.metadata b/apps/service_app/.metadata new file mode 100644 index 0000000..3a28996 --- /dev/null +++ b/apps/service_app/.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: "058e0af2c2b57e369d905a03ac9748b0ebf543c6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: android + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: ios + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + + # 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/apps/service_app/README.md b/apps/service_app/README.md new file mode 100644 index 0000000..79364e7 --- /dev/null +++ b/apps/service_app/README.md @@ -0,0 +1,23 @@ +# 瓶安芯服务工作台 + +配送员、安装维修员和安检员共用的 Android/iOS Flutter 客户端。首期一账号一角色,统一访问 `/heqi/client/v1/staff`。 + +## 本地运行 + +```bash +flutter pub get +flutter run --dart-define=API_BASE_URL=http://10.0.2.2:12426 +``` + +Android 模拟器访问宿主机使用 `10.0.2.2`;iOS Simulator 使用 `http://127.0.0.1:12426`。Release 环境必须注入 HTTPS 地址。 + +现场照片、签名和表单先按账号使用 AES-GCM 加密暂存。离线只表示“已暂存”,打卡、开始、异常、恢复、到达、签收与最终提交都必须获得服务端在线确认。 + +## 质量检查 + +```bash +flutter analyze +flutter test +flutter build apk --debug +flutter build ios --simulator --no-codesign +``` diff --git a/apps/service_app/analysis_options.yaml b/apps/service_app/analysis_options.yaml new file mode 100644 index 0000000..c645960 --- /dev/null +++ b/apps/service_app/analysis_options.yaml @@ -0,0 +1,16 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + avoid_print: true + use_super_parameters: true + +formatter: + page_width: 100 + trailing_commas: preserve diff --git a/apps/service_app/android/.gitignore b/apps/service_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/service_app/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/apps/service_app/android/app/build.gradle.kts b/apps/service_app/android/app/build.gradle.kts new file mode 100644 index 0000000..90b856f --- /dev/null +++ b/apps/service_app/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.heqi.service_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.heqi.service_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + 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") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/apps/service_app/android/app/src/main/AndroidManifest.xml b/apps/service_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6daaa75 --- /dev/null +++ b/apps/service_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/service_app/android/app/src/main/kotlin/com/heqi/service_app/MainActivity.kt b/apps/service_app/android/app/src/main/kotlin/com/heqi/service_app/MainActivity.kt new file mode 100644 index 0000000..666fd58 --- /dev/null +++ b/apps/service_app/android/app/src/main/kotlin/com/heqi/service_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.heqi.service_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/service_app/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/service_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/service_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/service_app/android/app/src/main/res/drawable/launch_background.xml b/apps/service_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/service_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/service_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/service_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/apps/service_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/service_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/service_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/apps/service_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/service_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/service_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/apps/service_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/service_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/service_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/apps/service_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/service_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/service_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/apps/service_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/service_app/android/app/src/main/res/values-night/styles.xml b/apps/service_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/service_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/service_app/android/app/src/main/res/values/styles.xml b/apps/service_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/service_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/service_app/android/app/src/profile/AndroidManifest.xml b/apps/service_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/service_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/service_app/android/build.gradle.kts b/apps/service_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/service_app/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/apps/service_app/android/gradle.properties b/apps/service_app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/apps/service_app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/apps/service_app/android/gradle/wrapper/gradle-wrapper.properties b/apps/service_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/apps/service_app/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-9.1.0-all.zip diff --git a/apps/service_app/android/settings.gradle.kts b/apps/service_app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/apps/service_app/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 "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/apps/service_app/ios/.gitignore b/apps/service_app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/apps/service_app/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/apps/service_app/ios/Flutter/AppFrameworkInfo.plist b/apps/service_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/apps/service_app/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/apps/service_app/ios/Flutter/Debug.xcconfig b/apps/service_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/service_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/service_app/ios/Flutter/Release.xcconfig b/apps/service_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/service_app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/service_app/ios/Runner.xcodeproj/project.pbxproj b/apps/service_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ef96dd3 --- /dev/null +++ b/apps/service_app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*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 */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 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 = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; 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 = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 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; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + 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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + 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.heqi.serviceApp; + 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.heqi.serviceApp.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.heqi.serviceApp.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.heqi.serviceApp.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.heqi.serviceApp; + 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.heqi.serviceApp; + 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 */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/service_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/service_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/service_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/apps/service_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/service_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/service_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/apps/service_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/service_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/service_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/service_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/service_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/service_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/service_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/service_app/ios/Runner/AppDelegate.swift b/apps/service_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/apps/service_app/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/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/apps/service_app/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/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/service_app/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/apps/service_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/apps/service_app/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/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/service_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/apps/service_app/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/apps/service_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/service_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/apps/service_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/service_app/ios/Runner/Base.lproj/Main.storyboard b/apps/service_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/apps/service_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/service_app/ios/Runner/Info.plist b/apps/service_app/ios/Runner/Info.plist new file mode 100644 index 0000000..f766505 --- /dev/null +++ b/apps/service_app/ios/Runner/Info.plist @@ -0,0 +1,76 @@ + + + + + NSCameraUsageDescription + 用于配送签收、安装维修和安检现场取证。 + NSLocationWhenInUseUsageDescription + 用于上班打卡、配送轨迹和到达服务地址校验。 + NSPhotoLibraryUsageDescription + 用于选择现场作业凭证。 + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 瓶安芯服务工作台 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + service_app + 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/apps/service_app/ios/Runner/Runner-Bridging-Header.h b/apps/service_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/apps/service_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/service_app/ios/Runner/SceneDelegate.swift b/apps/service_app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/apps/service_app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/apps/service_app/ios/RunnerTests/RunnerTests.swift b/apps/service_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/apps/service_app/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/apps/service_app/lib/app/app.dart b/apps/service_app/lib/app/app.dart new file mode 100644 index 0000000..97651a3 --- /dev/null +++ b/apps/service_app/lib/app/app.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../ui/core/app_theme.dart'; +import 'dependencies.dart'; +import 'router.dart'; + +class ServiceClientApp extends StatefulWidget { + const ServiceClientApp({required this.dependencies, super.key}); + + final AppDependencies dependencies; + + @override + State createState() => _ServiceClientAppState(); +} + +class _ServiceClientAppState extends State { + late final _router = createRouter(widget.dependencies); + + @override + Widget build(BuildContext context) => MaterialApp.router( + title: '瓶安芯服务工作台', + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + routerConfig: _router, + ); +} diff --git a/apps/service_app/lib/app/dependencies.dart b/apps/service_app/lib/app/dependencies.dart new file mode 100644 index 0000000..d679d57 --- /dev/null +++ b/apps/service_app/lib/app/dependencies.dart @@ -0,0 +1,96 @@ +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; + +import '../data/offline/encrypted_draft_store.dart'; +import '../data/repositories/service_repository.dart'; +import '../data/services/api_client.dart'; +import '../data/services/location_service.dart'; +import '../data/services/secure_session_store.dart'; + +class AppDependencies { + AppDependencies._({ + required this.session, + required this.repository, + required this.drafts, + }); + + final StaffSession session; + final ServiceRepository repository; + final EncryptedDraftStore drafts; + + static Future create() async { + final store = SecureSessionStore(); + final session = StaffSession(store); + await session.restore(); + final api = ApiClient(() => session.token); + return AppDependencies._( + session: session, + repository: ServiceRepository(api, GeolocatorLocationService()), + drafts: EncryptedDraftStore(), + ); + } +} + +class StaffSession extends ChangeNotifier { + StaffSession(this._store); + + static const _root = '/heqi/client/v1/staff'; + static const _tokenKey = 'service_app_access_token'; + static const _identityKey = 'service_app_identity'; + static const _roleKey = 'service_app_role'; + static const _deviceKey = 'service_app_device'; + final SecureSessionStore _store; + + String _token = ''; + String _identity = ''; + String _roleCode = ''; + String _deviceIdentity = ''; + + String get token => _token; + String get identity => _identity; + String get roleCode => _roleCode; + String get deviceIdentity => _deviceIdentity; + bool get isAuthenticated => _token.isNotEmpty; + + Future restore() async { + _token = await _store.read(_tokenKey) ?? ''; + _identity = await _store.read(_identityKey) ?? ''; + _roleCode = await _store.read(_roleKey) ?? ''; + _deviceIdentity = await _store.read(_deviceKey) ?? ''; + if (_deviceIdentity.isEmpty) { + _deviceIdentity = const Uuid().v7(); + await _store.write(_deviceKey, _deviceIdentity); + } + } + + Future login(String phone, String password) async { + final api = ApiClient(() => ''); + final details = jsonMap( + await api.post( + '$_root/auth/login', + authenticated: false, + body: {'phone': phone, 'mode': 'password', 'password': password}, + ), + ); + _token = details['access_token'] as String? ?? ''; + _identity = details['identity'] as String? ?? ''; + _roleCode = details['role_code'] as String? ?? ''; + if (_token.isEmpty || _identity.isEmpty || _roleCode.isEmpty) { + throw const ApiException(500, '工作人员登录上下文缺失'); + } + await _store.write(_tokenKey, _token); + await _store.write(_identityKey, _identity); + await _store.write(_roleKey, _roleCode); + notifyListeners(); + } + + Future logout() async { + _token = ''; + _identity = ''; + _roleCode = ''; + await _store.delete(_tokenKey); + await _store.delete(_identityKey); + await _store.delete(_roleKey); + notifyListeners(); + } +} diff --git a/apps/service_app/lib/app/router.dart b/apps/service_app/lib/app/router.dart new file mode 100644 index 0000000..62c2e01 --- /dev/null +++ b/apps/service_app/lib/app/router.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../ui/features/auth/login_page.dart'; +import '../ui/features/evidence/evidence_page.dart'; +import '../ui/features/preflight/preflight_page.dart'; +import '../ui/features/profile/profile_page.dart'; +import '../ui/features/work/work_detail_page.dart'; +import '../ui/features/work/work_list_page.dart'; +import 'dependencies.dart'; + +GoRouter createRouter(AppDependencies dependencies) => GoRouter( + initialLocation: '/preflight', + refreshListenable: dependencies.session, + redirect: (context, state) { + final login = state.matchedLocation == '/login'; + if (!dependencies.session.isAuthenticated && !login) return '/login'; + if (dependencies.session.isAuthenticated && login) return '/preflight'; + return null; + }, + routes: [ + GoRoute( + path: '/login', + builder: (context, state) => LoginPage(session: dependencies.session), + ), + GoRoute( + path: '/preflight', + builder: (context, state) => PreflightPage( + session: dependencies.session, + repository: dependencies.repository, + ), + ), + GoRoute( + path: '/tasks/:identity', + builder: (context, state) => WorkDetailPage( + session: dependencies.session, + repository: dependencies.repository, + drafts: dependencies.drafts, + identity: state.pathParameters['identity']!, + ), + routes: [ + GoRoute( + path: 'evidence', + builder: (context, state) => EvidencePage( + session: dependencies.session, + repository: dependencies.repository, + drafts: dependencies.drafts, + taskIdentity: state.pathParameters['identity']!, + ), + ), + ], + ), + StatefulShellRoute.indexedStack( + builder: (context, state, shell) => _ServiceShell( + navigationShell: shell, + roleCode: dependencies.session.roleCode, + ), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/work', + builder: (context, state) => WorkListPage( + session: dependencies.session, + repository: dependencies.repository, + completed: false, + ), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/records', + builder: (context, state) => WorkListPage( + session: dependencies.session, + repository: dependencies.repository, + completed: true, + ), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/me', + builder: (context, state) => ProfilePage( + session: dependencies.session, + repository: dependencies.repository, + drafts: dependencies.drafts, + ), + ), + ], + ), + ], + ), + ], +); + +class _ServiceShell extends StatelessWidget { + const _ServiceShell({required this.navigationShell, required this.roleCode}); + + final StatefulNavigationShell navigationShell; + final String roleCode; + + @override + Widget build(BuildContext context) => Scaffold( + body: navigationShell, + bottomNavigationBar: NavigationBar( + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (index) => navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ), + destinations: [ + NavigationDestination( + icon: Icon( + roleCode == 'delivery' ? Icons.local_shipping_outlined : Icons.assignment_outlined, + ), + selectedIcon: Icon(roleCode == 'delivery' ? Icons.local_shipping : Icons.assignment), + label: roleCode == 'delivery' + ? '配送' + : roleCode == 'operations' + ? '安检' + : '工单', + ), + const NavigationDestination( + icon: Icon(Icons.history), + selectedIcon: Icon(Icons.history_toggle_off), + label: '记录', + ), + const NavigationDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: '我的', + ), + ], + ), + ); +} diff --git a/apps/service_app/lib/data/offline/encrypted_draft_store.dart b/apps/service_app/lib/data/offline/encrypted_draft_store.dart new file mode 100644 index 0000000..ea2a5cd --- /dev/null +++ b/apps/service_app/lib/data/offline/encrypted_draft_store.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:path_provider/path_provider.dart'; + +class EncryptedDraftStore { + EncryptedDraftStore({ + FlutterSecureStorage? storage, + AesGcm? algorithm, + }) : _storage = storage ?? const FlutterSecureStorage(), + _algorithm = algorithm ?? AesGcm.with256bits(); + + static const _keyPrefix = 'service_draft_key_'; + final FlutterSecureStorage _storage; + final AesGcm _algorithm; + + Future saveDraft({ + required String accountIdentity, + required String taskIdentity, + required Map value, + }) async { + final directory = await _accountDirectory(accountIdentity); + final key = await _key(accountIdentity); + final nonce = List.generate(12, (_) => Random.secure().nextInt(256)); + final box = await _algorithm.encrypt( + utf8.encode(jsonEncode(value)), + secretKey: key, + nonce: nonce, + ); + final payload = jsonEncode({ + 'nonce': base64Encode(box.nonce), + 'cipherText': base64Encode(box.cipherText), + 'mac': base64Encode(box.mac.bytes), + }); + await File('${directory.path}/$taskIdentity.draft').writeAsString(payload, flush: true); + } + + Future sealAttachment({ + required String accountIdentity, + required String taskIdentity, + required String stage, + required String sourcePath, + }) async { + final source = File(sourcePath); + final bytes = await source.readAsBytes(); + final box = await _encrypt(accountIdentity, bytes); + final name = '$taskIdentity-$stage-${DateTime.now().microsecondsSinceEpoch}.evidence'; + final file = File('${(await _accountDirectory(accountIdentity)).path}/$name'); + await file.writeAsString(_boxJson(box), flush: true); + if (source.existsSync()) await source.delete(); + return name; + } + + Future materializeAttachment({ + required String accountIdentity, + required String sealedName, + }) async { + final source = File('${(await _accountDirectory(accountIdentity)).path}/$sealedName'); + final clear = await _decrypt(accountIdentity, await source.readAsString()); + final temporary = await getTemporaryDirectory(); + final file = File('${temporary.path}/${sealedName.replaceAll('.evidence', '.jpg')}'); + await file.writeAsBytes(clear, flush: true); + return file.path; + } + + Future?> readDraft(String accountIdentity, String taskIdentity) async { + final file = File('${(await _accountDirectory(accountIdentity)).path}/$taskIdentity.draft'); + if (!file.existsSync()) return null; + final payload = jsonDecode(await file.readAsString()); + if (payload is! Map) return null; + final clear = await _decrypt(accountIdentity, await file.readAsString()); + final decoded = jsonDecode(utf8.decode(clear)); + if (decoded is! Map) return null; + return decoded.map((key, value) => MapEntry(key.toString(), value)); + } + + Future count(String accountIdentity) async { + final directory = await _accountDirectory(accountIdentity); + return directory + .listSync() + .whereType() + .where((file) => file.path.endsWith('.draft')) + .length; + } + + Future deleteDraft(String accountIdentity, String taskIdentity) async { + final directory = await _accountDirectory(accountIdentity); + final file = File('${directory.path}/$taskIdentity.draft'); + if (file.existsSync()) await file.delete(); + for (final evidence in directory.listSync().whereType().where( + (item) => item.uri.pathSegments.last.startsWith('$taskIdentity-'), + )) { + await evidence.delete(); + } + } + + Future discardAccount(String accountIdentity) async { + final directory = await _accountDirectory(accountIdentity); + if (directory.existsSync()) await directory.delete(recursive: true); + await _storage.delete(key: '$_keyPrefix$accountIdentity'); + } + + Future _accountDirectory(String accountIdentity) async { + final root = await getApplicationSupportDirectory(); + final directory = Directory('${root.path}/drafts/$accountIdentity'); + if (!directory.existsSync()) await directory.create(recursive: true); + return directory; + } + + Future _key(String accountIdentity) async { + final storageKey = '$_keyPrefix$accountIdentity'; + var encoded = await _storage.read(key: storageKey); + if (encoded == null) { + encoded = base64Encode(await (await _algorithm.newSecretKey()).extractBytes()); + await _storage.write(key: storageKey, value: encoded); + } + return SecretKey(base64Decode(encoded)); + } + + Future _encrypt(String accountIdentity, List clear) async { + final nonce = List.generate(12, (_) => Random.secure().nextInt(256)); + return _algorithm.encrypt(clear, secretKey: await _key(accountIdentity), nonce: nonce); + } + + Future> _decrypt(String accountIdentity, String encodedPayload) async { + final payload = jsonDecode(encodedPayload); + if (payload is! Map) throw const FormatException('Invalid encrypted draft'); + return _algorithm.decrypt( + SecretBox( + base64Decode(payload['cipherText'] as String), + nonce: base64Decode(payload['nonce'] as String), + mac: Mac(base64Decode(payload['mac'] as String)), + ), + secretKey: await _key(accountIdentity), + ); + } + + String _boxJson(SecretBox box) => jsonEncode({ + 'nonce': base64Encode(box.nonce), + 'cipherText': base64Encode(box.cipherText), + 'mac': base64Encode(box.mac.bytes), + }); +} diff --git a/apps/service_app/lib/data/repositories/service_repository.dart b/apps/service_app/lib/data/repositories/service_repository.dart new file mode 100644 index 0000000..2a3aaf0 --- /dev/null +++ b/apps/service_app/lib/data/repositories/service_repository.dart @@ -0,0 +1,166 @@ +import 'package:uuid/uuid.dart'; + +import '../../domain/models/service_models.dart'; +import '../services/api_client.dart'; +import '../services/location_service.dart'; + +class ServiceRepository { + ServiceRepository(this._api, this._location); + + static const root = '/heqi/client/v1/staff'; + final ApiClient _api; + final LocationService _location; + + Future profile() async => + StaffProfile.fromJson(jsonMap(await _api.get('$root/auth/profile'))); + + Future preflight() async => + PreflightResult.fromJson(jsonMap(await _api.get('$root/preflight'))); + + Future> wallet() async => jsonMap(await _api.get('$root/wallet')); + + Future attendance(String action, String deviceIdentity) async { + final location = await _location.current(); + await _api.post( + '$root/attendance', + body: { + 'action': action, + 'occurred_at': location.occurredAt.toUtc().toIso8601String(), + 'longitude': location.longitude, + 'latitude': location.latitude, + 'device_identity': deviceIdentity, + 'request_no': const Uuid().v7(), + }, + ); + } + + Future> tasks(String roleCode) async { + if (roleCode == 'delivery') { + return jsonList( + await _api.get('$root/delivery/orders'), + ).map(WorkItem.delivery).toList(growable: false); + } + return jsonList( + await _api.get('$root/tickets'), + ).map(WorkItem.ticket).toList(growable: false); + } + + Future deliveryDetail(String identity) async { + final details = jsonMap(await _api.get('$root/delivery/orders/$identity')); + return WorkItem.delivery(jsonMap(details['order'])); + } + + Future ticketDetail(String identity) async => + WorkItem.ticket(jsonMap(await _api.get('$root/tickets/$identity'))); + + Future start(WorkItem item, String roleCode) async { + final path = roleCode == 'delivery' + ? '$root/delivery/orders/${item.identity}/start' + : '$root/tickets/${item.identity}/start'; + await _api.post(path, body: {'reason': '工作人员开始执行'}); + } + + Future exception(WorkItem item, String roleCode, String reason) async { + final path = roleCode == 'delivery' + ? '$root/delivery/orders/${item.identity}/exception' + : '$root/tickets/${item.identity}/exception'; + await _api.post(path, body: {'reason': reason}); + } + + Future recover(WorkItem item, String roleCode, String reason) async { + final path = roleCode == 'delivery' + ? '$root/delivery/orders/${item.identity}/recover' + : '$root/tickets/${item.identity}/recover'; + await _api.post(path, body: {'reason': reason}); + } + + Future appendCurrentTrack(String identity) async { + final point = await _location.current(); + await _api.post( + '$root/delivery/orders/$identity/tracks', + body: { + 'points': [_pointJson(point)], + }, + ); + } + + Future arrive(String identity) async { + final point = await _location.current(); + await _api.post('$root/delivery/orders/$identity/arrive', body: _pointJson(point)); + } + + Future submitDeliveryReceipt({ + required String identity, + required String recipientName, + required String recipientPhone, + required String proofFile, + }) async { + final proofUri = await _api.upload(proofFile); + await _api.post( + '$root/delivery/orders/$identity/submit-receipt', + body: { + 'request_no': const Uuid().v7(), + 'confirm_type': 'signature', + 'recipient_name': recipientName, + 'recipient_phone': recipientPhone, + 'proof_uri': proofUri, + }, + ); + } + + Future submitTicketResult({ + required String identity, + required String result, + required String conclusion, + required List evidence, + }) async { + final location = await _location.current(); + final uploaded = >[]; + for (final item in evidence) { + final uri = await _api.upload( + item.filePath, + contentType: item.mediaType == 'video' ? 'video/mp4' : 'image/jpeg', + ); + uploaded.add({ + 'evidence_type': item.evidenceType, + 'media_type': item.mediaType, + 'file_uri': uri, + 'captured_at': item.capturedAt.toUtc().toIso8601String(), + 'longitude': location.longitude, + 'latitude': location.latitude, + 'request_no': item.requestNo, + }); + } + await _api.post( + '$root/tickets/$identity/submit-result', + body: {'result': result, 'conclusion': conclusion, 'evidences': uploaded}, + ); + } + + Map _pointJson(LocationPoint point) => { + 'request_no': const Uuid().v7(), + 'longitude': point.longitude, + 'latitude': point.latitude, + 'occurred_at': point.occurredAt.toUtc().toIso8601String(), + 'source': 'gps', + 'accuracy': point.accuracy, + 'speed': '', + 'direction': '', + }; +} + +class EvidenceInput { + const EvidenceInput({ + required this.evidenceType, + required this.mediaType, + required this.filePath, + required this.capturedAt, + required this.requestNo, + }); + + final String evidenceType; + final String mediaType; + final String filePath; + final DateTime capturedAt; + final String requestNo; +} diff --git a/apps/service_app/lib/data/services/api_client.dart b/apps/service_app/lib/data/services/api_client.dart new file mode 100644 index 0000000..ca00154 --- /dev/null +++ b/apps/service_app/lib/data/services/api_client.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +class ApiException implements Exception { + const ApiException(this.code, this.message); + + final int code; + final String message; + + @override + String toString() => message; +} + +class ApiClient { + ApiClient(this._tokenProvider, {http.Client? client, String? baseUrl}) + : _client = client ?? http.Client(), + baseUrl = + baseUrl ?? + const String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://10.0.2.2:12426', + ); + + final String baseUrl; + final String Function() _tokenProvider; + final http.Client _client; + + Future get(String path, {bool authenticated = true}) => + _send('GET', path, authenticated: authenticated); + + Future post( + String path, { + Map? body, + bool authenticated = true, + }) => _send('POST', path, body: body, authenticated: authenticated); + + Future put(String path, {Map? body}) => _send('PUT', path, body: body); + + Future upload(String filePath, {String contentType = 'image/jpeg'}) async { + final request = http.MultipartRequest('POST', Uri.parse('$baseUrl/upload/file')); + request.headers[HttpHeaders.authorizationHeader] = _tokenProvider(); + request.fields['declared_content_type'] = contentType; + request.files.add(await http.MultipartFile.fromPath('file', filePath)); + final response = await http.Response.fromStream(await request.send()); + final details = _decode(response); + return jsonMap(details)['uri'] as String? ?? ''; + } + + Future _send( + String method, + String path, { + Map? body, + bool authenticated = true, + }) async { + final request = http.Request(method, Uri.parse('$baseUrl$path')); + request.headers[HttpHeaders.acceptHeader] = 'application/json'; + if (authenticated && _tokenProvider().isNotEmpty) { + request.headers[HttpHeaders.authorizationHeader] = _tokenProvider(); + } + if (body != null) { + request.headers[HttpHeaders.contentTypeHeader] = 'application/json; charset=UTF-8'; + request.body = jsonEncode(body); + } + final response = await http.Response.fromStream(await _client.send(request)); + return _decode(response); + } + + Object? _decode(http.Response response) { + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException(response.statusCode, '网络请求失败(${response.statusCode})'); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const ApiException(500, '服务端响应格式错误'); + } + final code = (decoded['code'] as num?)?.toInt() ?? 500; + if (code != 0) throw ApiException(code, decoded['message'] as String? ?? '操作失败'); + return decoded['details']; + } +} + +Map jsonMap(Object? value) { + if (value is Map) return value; + if (value is Map) return value.map((key, item) => MapEntry(key.toString(), item)); + throw const ApiException(500, '服务端数据格式错误'); +} + +List> jsonList(Object? value) { + if (value is! List) return const []; + return value.map>(jsonMap).toList(growable: false); +} diff --git a/apps/service_app/lib/data/services/location_service.dart b/apps/service_app/lib/data/services/location_service.dart new file mode 100644 index 0000000..6cc48a6 --- /dev/null +++ b/apps/service_app/lib/data/services/location_service.dart @@ -0,0 +1,42 @@ +import 'package:geolocator/geolocator.dart'; + +class LocationPoint { + const LocationPoint({ + required this.longitude, + required this.latitude, + required this.accuracy, + required this.occurredAt, + }); + + final String longitude; + final String latitude; + final String accuracy; + final DateTime occurredAt; +} + +abstract interface class LocationService { + Future current(); +} + +class GeolocatorLocationService implements LocationService { + @override + Future current() async { + if (!await Geolocator.isLocationServiceEnabled()) { + throw StateError('请先开启系统定位服务'); + } + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + throw StateError('定位权限未授权,无法完成该在线动作'); + } + final position = await Geolocator.getCurrentPosition(); + return LocationPoint( + longitude: position.longitude.toStringAsFixed(7), + latitude: position.latitude.toStringAsFixed(7), + accuracy: position.accuracy.toStringAsFixed(1), + occurredAt: position.timestamp, + ); + } +} diff --git a/apps/service_app/lib/data/services/secure_session_store.dart b/apps/service_app/lib/data/services/secure_session_store.dart new file mode 100644 index 0000000..ce2dff1 --- /dev/null +++ b/apps/service_app/lib/data/services/secure_session_store.dart @@ -0,0 +1,12 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class SecureSessionStore { + SecureSessionStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _storage; + + Future read(String key) => _storage.read(key: key); + Future write(String key, String value) => _storage.write(key: key, value: value); + Future delete(String key) => _storage.delete(key: key); +} diff --git a/apps/service_app/lib/domain/models/service_models.dart b/apps/service_app/lib/domain/models/service_models.dart new file mode 100644 index 0000000..c77af47 --- /dev/null +++ b/apps/service_app/lib/domain/models/service_models.dart @@ -0,0 +1,125 @@ +class StaffProfile { + const StaffProfile({ + required this.identity, + required this.name, + required this.phone, + required this.roleCode, + required this.workStatus, + }); + + final String identity; + final String name; + final String phone; + final String roleCode; + final String workStatus; + + factory StaffProfile.fromJson(Map json) => StaffProfile( + identity: json['identity'] as String? ?? '', + name: json['name'] as String? ?? '', + phone: json['phone'] as String? ?? '', + roleCode: json['role_code'] as String? ?? '', + workStatus: json['work_status'] as String? ?? 'off_duty', + ); +} + +class PreflightResult { + const PreflightResult({ + required this.roleCode, + required this.workStatus, + required this.canWork, + required this.checks, + }); + + final String roleCode; + final String workStatus; + final bool canWork; + final Map checks; + + factory PreflightResult.fromJson(Map json) => PreflightResult( + roleCode: json['role_code'] as String? ?? '', + workStatus: json['work_status'] as String? ?? 'off_duty', + canWork: json['can_work'] as bool? ?? false, + checks: _map(json['checks']), + ); +} + +class WorkItem { + const WorkItem({ + required this.identity, + required this.number, + required this.title, + required this.address, + required this.status, + required this.allowedActions, + required this.raw, + }); + + final String identity; + final String number; + final String title; + final String address; + final int status; + final List allowedActions; + final Map raw; + + factory WorkItem.delivery(Map json) => WorkItem( + identity: json['identity'] as String? ?? '', + number: json['order_no'] as String? ?? '', + title: '燃气配送', + address: json['address'] as String? ?? '', + status: (json['order_status'] as num?)?.toInt() ?? 0, + allowedActions: (json['allowed_actions'] as List? ?? const []) + .map((item) => item.toString()) + .toList(growable: false), + raw: json, + ); + + factory WorkItem.ticket(Map json) => WorkItem( + identity: json['identity'] as String? ?? '', + number: json['ticket_no'] as String? ?? '', + title: _ticketTitle(json['category'] as String? ?? ''), + address: json['address'] as String? ?? '', + status: (json['ticket_status'] as num?)?.toInt() ?? 0, + allowedActions: _ticketActions((json['ticket_status'] as num?)?.toInt() ?? 0), + raw: json, + ); +} + +Map _map(Object? value) { + if (value is Map) return value; + if (value is Map) return value.map((key, item) => MapEntry(key.toString(), item)); + return const {}; +} + +String _ticketTitle(String category) => switch (category) { + 'installation' => '安装任务', + 'repair' => '维修任务', + 'inspection' => '安全检查', + 'reinspection' => '复检任务', + _ => '服务任务', +}; + +List _ticketActions(int status) => switch (status) { + 18 => const ['start'], + 11 => const ['submit_result', 'exception'], + 21 => const ['recover'], + _ => const [], +}; + +String roleName(String role) => switch (role) { + 'delivery' => '配送员', + 'installer' => '安装维修员', + 'operations' => '安检员', + _ => '工作人员', +}; + +String statusName(int status) => switch (status) { + 11 => '处理中', + 18 => '已分派', + 20 => '已就绪', + 21 => '异常', + 23 => '已完成', + 33 => '配送中', + 34 => '待确认', + _ => '状态 $status', +}; diff --git a/apps/service_app/lib/main.dart b/apps/service_app/lib/main.dart new file mode 100644 index 0000000..0253f4b --- /dev/null +++ b/apps/service_app/lib/main.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'app/app.dart'; +import 'app/dependencies.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final dependencies = await AppDependencies.create(); + runApp(ServiceClientApp(dependencies: dependencies)); +} diff --git a/apps/service_app/lib/ui/core/app_theme.dart b/apps/service_app/lib/ui/core/app_theme.dart new file mode 100644 index 0000000..d2004b9 --- /dev/null +++ b/apps/service_app/lib/ui/core/app_theme.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static ThemeData light() { + final scheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF6C4CF1), + primary: const Color(0xFF6C4CF1), + secondary: const Color(0xFF16A66A), + surface: Colors.white, + ); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: const Color(0xFFF5F4FA), + cardTheme: CardThemeData( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + ); + } +} diff --git a/apps/service_app/lib/ui/features/auth/login_page.dart b/apps/service_app/lib/ui/features/auth/login_page.dart new file mode 100644 index 0000000..00f6011 --- /dev/null +++ b/apps/service_app/lib/ui/features/auth/login_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +import '../../../app/dependencies.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({required this.session, super.key}); + + final StaffSession session; + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _phone = TextEditingController(); + final _password = TextEditingController(); + bool _busy = false; + String? _error; + + Future _login() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.session.login(_phone.text.trim(), _password.text); + } catch (error) { + if (mounted) setState(() => _error = error.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _phone.dispose(); + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.engineering_rounded, + size: 72, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 20), + Text( + '瓶安芯服务工作台', + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 8), + const Text('配送、安装维修与安检共用入口', textAlign: TextAlign.center), + const SizedBox(height: 34), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: '工作人员手机号', + prefixIcon: Icon(Icons.phone_outlined), + ), + ), + const SizedBox(height: 14), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration( + labelText: '登录密码', + prefixIcon: Icon(Icons.lock_outline), + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _busy ? null : _login, + child: _busy + ? const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('进入工作台'), + ), + const SizedBox(height: 12), + const Text( + '账号与角色由平台审核分配,不提供自助注册', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/apps/service_app/lib/ui/features/evidence/evidence_page.dart b/apps/service_app/lib/ui/features/evidence/evidence_page.dart new file mode 100644 index 0000000..d9d6746 --- /dev/null +++ b/apps/service_app/lib/ui/features/evidence/evidence_page.dart @@ -0,0 +1,225 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app/dependencies.dart'; +import '../../../data/offline/encrypted_draft_store.dart'; +import '../../../data/repositories/service_repository.dart'; + +class EvidencePage extends StatefulWidget { + const EvidencePage({ + required this.session, + required this.repository, + required this.drafts, + required this.taskIdentity, + super.key, + }); + + final StaffSession session; + final ServiceRepository repository; + final EncryptedDraftStore drafts; + final String taskIdentity; + + @override + State createState() => _EvidencePageState(); +} + +class _EvidencePageState extends State { + final _picker = ImagePicker(); + final _result = TextEditingController(); + final Map> _evidence = {}; + bool _busy = false; + String _conclusion = 'qualified'; + + List get _requiredStages => widget.session.roleCode == 'operations' + ? const ['inspection', 'signature'] + : const ['before', 'during', 'after', 'signature']; + + @override + void initState() { + super.initState(); + _restore(); + } + + Future _restore() async { + final draft = await widget.drafts.readDraft(widget.session.identity, widget.taskIdentity); + if (draft == null || !mounted) return; + final values = draft['evidence']; + if (values is List) { + for (final value in values.whereType>()) { + final mapped = value.map((key, item) => MapEntry(key.toString(), item)); + final stage = mapped['stage'] as String? ?? ''; + if (stage.isNotEmpty) _evidence[stage] = mapped; + } + } + _result.text = draft['result'] as String? ?? ''; + _conclusion = draft['conclusion'] as String? ?? 'qualified'; + setState(() {}); + } + + Future _capture(String stage) async { + final image = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 82, + maxWidth: 1800, + ); + if (image == null) return; + final sealedName = await widget.drafts.sealAttachment( + accountIdentity: widget.session.identity, + taskIdentity: widget.taskIdentity, + stage: stage, + sourcePath: image.path, + ); + _evidence[stage] = { + 'stage': stage, + 'sealed_name': sealedName, + 'captured_at': DateTime.now().toUtc().toIso8601String(), + 'request_no': const Uuid().v7(), + 'media_type': stage == 'signature' ? 'signature' : 'image', + }; + await _save(); + if (mounted) setState(() {}); + } + + Future _save() => widget.drafts.saveDraft( + accountIdentity: widget.session.identity, + taskIdentity: widget.taskIdentity, + value: { + 'task_identity': widget.taskIdentity, + 'result': _result.text, + 'conclusion': _conclusion, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + 'evidence': _evidence.values.toList(), + }, + ); + + Future _submit() async { + if (!_requiredStages.every(_evidence.containsKey) || _result.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请完成结果说明和全部必需取证项'))); + return; + } + setState(() => _busy = true); + final temporaryFiles = []; + try { + await _save(); + final inputs = []; + for (final item in _evidence.values) { + final path = await widget.drafts.materializeAttachment( + accountIdentity: widget.session.identity, + sealedName: item['sealed_name'] as String, + ); + temporaryFiles.add(path); + inputs.add( + EvidenceInput( + evidenceType: item['stage'] as String, + mediaType: item['media_type'] as String, + filePath: path, + capturedAt: DateTime.parse(item['captured_at'] as String), + requestNo: item['request_no'] as String, + ), + ); + } + await widget.repository.submitTicketResult( + identity: widget.taskIdentity, + result: _result.text.trim(), + conclusion: _conclusion, + evidence: inputs, + ); + await widget.drafts.deleteDraft(widget.session.identity, widget.taskIdentity); + if (mounted) Navigator.pop(context, true); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('提交失败,草稿仍安全保留:$error'))); + } + } finally { + for (final path in temporaryFiles) { + final file = File(path); + if (file.existsSync()) await file.delete(); + } + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _result.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('现场取证')), + body: ListView( + padding: const EdgeInsets.all(18), + children: [ + const Card( + child: Padding( + padding: EdgeInsets.all(18), + child: Row( + children: [ + Icon(Icons.lock_outline), + SizedBox(width: 12), + Expanded(child: Text('照片与签名先按当前账号加密暂存;上传成功前仅显示“已暂存”。')), + ], + ), + ), + ), + const SizedBox(height: 10), + ..._requiredStages.map( + (stage) => Card( + child: ListTile( + leading: Icon( + _evidence.containsKey(stage) ? Icons.check_circle : Icons.camera_alt_outlined, + color: _evidence.containsKey(stage) ? Colors.green : null, + ), + title: Text(_stageName(stage)), + subtitle: Text(_evidence.containsKey(stage) ? '已加密暂存' : '尚未采集'), + trailing: TextButton( + onPressed: _busy ? null : () => _capture(stage), + child: Text(_evidence.containsKey(stage) ? '重拍' : '拍摄'), + ), + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _result, + maxLines: 4, + onChanged: (_) => _save(), + decoration: const InputDecoration(labelText: '现场结果说明'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _conclusion, + decoration: const InputDecoration(labelText: '结论'), + items: const [ + DropdownMenuItem(value: 'qualified', child: Text('合格')), + DropdownMenuItem(value: 'noncompliant', child: Text('不合格')), + DropdownMenuItem(value: 'high_risk', child: Text('高风险')), + ], + onChanged: (value) { + if (value == null) return; + setState(() => _conclusion = value); + _save(); + }, + ), + const SizedBox(height: 22), + ElevatedButton( + onPressed: _busy ? null : _submit, + child: Text(_busy ? '正在上传并等待服务端确认…' : '在线提交结果'), + ), + ], + ), + ); + + String _stageName(String stage) => switch (stage) { + 'before' => '作业前照片', + 'during' => '作业中照片', + 'after' => '作业后照片', + 'inspection' => '检查现场照片', + 'signature' => '用户签名图片', + _ => stage, + }; +} diff --git a/apps/service_app/lib/ui/features/preflight/preflight_page.dart b/apps/service_app/lib/ui/features/preflight/preflight_page.dart new file mode 100644 index 0000000..9c35a02 --- /dev/null +++ b/apps/service_app/lib/ui/features/preflight/preflight_page.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../app/dependencies.dart'; +import '../../../data/repositories/service_repository.dart'; +import '../../../domain/models/service_models.dart'; + +class PreflightPage extends StatefulWidget { + const PreflightPage({required this.session, required this.repository, super.key}); + + final StaffSession session; + final ServiceRepository repository; + + @override + State createState() => _PreflightPageState(); +} + +class _PreflightPageState extends State { + late Future _future; + bool _busy = false; + + @override + void initState() { + super.initState(); + _future = widget.repository.preflight(); + } + + Future _attendance(String action) async { + setState(() => _busy = true); + try { + await widget.repository.attendance(action, widget.session.deviceIdentity); + setState(() => _future = widget.repository.preflight()); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('作业前检查')), + body: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (!snapshot.hasData) { + if (snapshot.hasError) return Center(child: Text(snapshot.error.toString())); + return const Center(child: CircularProgressIndicator()); + } + final result = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(18), + children: [ + Card( + color: Theme.of(context).colorScheme.primary, + child: Padding( + padding: const EdgeInsets.all(22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(roleName(result.roleCode), style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 6), + Text( + result.canWork ? '可以开始今日作业' : '仍有前置条件未完成', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 24, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + ...result.checks.entries.map((entry) { + final detail = entry.value is Map + ? Map.from(entry.value as Map) + : const {}; + final status = detail['status']?.toString() ?? 'blocked'; + final passed = status == 'passed'; + return Card( + child: ListTile( + leading: Icon( + passed + ? Icons.check_circle + : status == 'not_configured' + ? Icons.info_outline + : Icons.cancel, + color: passed + ? Colors.green + : status == 'not_configured' + ? Colors.orange + : Colors.red, + ), + title: Text(_label(entry.key)), + subtitle: Text( + status == 'not_configured' + ? '平台暂未启用,不冒充校验通过' + : passed + ? '已通过服务端校验' + : '未通过', + ), + ), + ); + }), + const SizedBox(height: 18), + if (result.workStatus != 'on_duty') + ElevatedButton.icon( + onPressed: _busy ? null : () => _attendance('clock_in'), + icon: const Icon(Icons.location_on), + label: const Text('定位并上班打卡'), + ) + else ...[ + ElevatedButton( + onPressed: result.canWork ? () => context.go('/work') : null, + child: const Text('进入工作台'), + ), + TextButton( + onPressed: _busy ? null : () => _attendance('clock_out'), + child: const Text('下班打卡'), + ), + ], + ], + ); + }, + ), + ); + + String _label(String value) => switch (value) { + 'account' => '账号状态', + 'role' => '岗位', + 'organization' => '所属组织', + 'credential' => '人员资质', + 'attendance' => '上班状态', + 'daily_training' => '每日培训', + 'service_area' => '服务区域', + 'authorized_device' => '授权设备', + _ => value, + }; +} diff --git a/apps/service_app/lib/ui/features/profile/profile_page.dart b/apps/service_app/lib/ui/features/profile/profile_page.dart new file mode 100644 index 0000000..f335eca --- /dev/null +++ b/apps/service_app/lib/ui/features/profile/profile_page.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../app/dependencies.dart'; +import '../../../data/offline/encrypted_draft_store.dart'; +import '../../../data/repositories/service_repository.dart'; +import '../../../domain/models/service_models.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({ + required this.session, + required this.repository, + required this.drafts, + super.key, + }); + + final StaffSession session; + final ServiceRepository repository; + final EncryptedDraftStore drafts; + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + late Future<(StaffProfile, Map, int)> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<(StaffProfile, Map, int)> _load() async => ( + await widget.repository.profile(), + await widget.repository.wallet(), + await widget.drafts.count(widget.session.identity), + ); + + Future _logout(int draftCount) async { + if (draftCount > 0) { + final discard = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('仍有未同步现场草稿'), + content: Text('当前账号有 $draftCount 份加密草稿。建议返回任务页完成上传;若确认放弃,将安全删除草稿和附件。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回上传')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('放弃并删除')), + ], + ), + ); + if (discard != true) return; + await widget.drafts.discardAccount(widget.session.identity); + } + await widget.session.logout(); + if (mounted) context.go('/login'); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('我的工作台')), + body: FutureBuilder<(StaffProfile, Map, int)>( + future: _future, + builder: (context, snapshot) { + if (!snapshot.hasData) { + if (snapshot.hasError) return Center(child: Text(snapshot.error.toString())); + return const Center(child: CircularProgressIndicator()); + } + final (profile, wallet, drafts) = snapshot.data!; + final balance = (wallet['balance'] as num?)?.toInt() ?? 0; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + CircleAvatar( + radius: 30, + child: Text(profile.name.isEmpty ? '工' : profile.name.substring(0, 1)), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.name, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), + ), + Text('${roleName(profile.roleCode)} · ${profile.phone}'), + ], + ), + ), + Chip(label: Text(profile.workStatus == 'on_duty' ? '在岗' : '离岗')), + ], + ), + ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.account_balance_wallet_outlined), + title: const Text('钱包余额'), + trailing: Text( + '¥${(balance / 100).toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.w900), + ), + ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.lock_outline), + title: const Text('加密现场草稿'), + subtitle: const Text('仅当前账号重新认证后可恢复'), + trailing: Text('$drafts 份'), + ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.verified_user_outlined), + title: const Text('重新执行作业前检查'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go('/preflight'), + ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.logout), + title: const Text('退出登录'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _logout(drafts), + ), + ), + ], + ); + }, + ), + ); +} diff --git a/apps/service_app/lib/ui/features/work/work_detail_page.dart b/apps/service_app/lib/ui/features/work/work_detail_page.dart new file mode 100644 index 0000000..1f0f4db --- /dev/null +++ b/apps/service_app/lib/ui/features/work/work_detail_page.dart @@ -0,0 +1,290 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app/dependencies.dart'; +import '../../../data/offline/encrypted_draft_store.dart'; +import '../../../data/repositories/service_repository.dart'; +import '../../../domain/models/service_models.dart'; + +class WorkDetailPage extends StatefulWidget { + const WorkDetailPage({ + required this.session, + required this.repository, + required this.drafts, + required this.identity, + super.key, + }); + + final StaffSession session; + final ServiceRepository repository; + final EncryptedDraftStore drafts; + final String identity; + + @override + State createState() => _WorkDetailPageState(); +} + +class _WorkDetailPageState extends State { + late Future _future; + bool _busy = false; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future _load() => widget.session.roleCode == 'delivery' + ? widget.repository.deliveryDetail(widget.identity) + : widget.repository.ticketDetail(widget.identity); + + Future _run(Future Function(WorkItem) action, WorkItem item) async { + setState(() => _busy = true); + try { + await action(item); + setState(() => _future = _load()); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _reason() async { + final controller = TextEditingController(); + final value = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('填写原因'), + content: TextField( + controller: controller, + maxLines: 3, + decoration: const InputDecoration(labelText: '原因'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('确认'), + ), + ], + ), + ); + controller.dispose(); + return value; + } + + Future _receipt(WorkItem item) async { + final existing = await widget.drafts.readDraft(widget.session.identity, item.identity); + if (!mounted) return; + String? sealedName; + if (existing?['kind'] == 'delivery_receipt' && existing?['sealed_name'] is String) { + final reuse = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('发现未提交签收草稿'), + content: const Text('是否继续提交上次加密保存的签收凭证?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('重新拍摄')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('继续提交')), + ], + ), + ); + if (reuse == true) sealedName = existing!['sealed_name'] as String; + } + if (sealedName == null) { + final image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 82); + if (image == null) return; + sealedName = await widget.drafts.sealAttachment( + accountIdentity: widget.session.identity, + taskIdentity: item.identity, + stage: 'receipt', + sourcePath: image.path, + ); + } + setState(() => _busy = true); + String? temporaryPath; + try { + await widget.drafts.saveDraft( + accountIdentity: widget.session.identity, + taskIdentity: item.identity, + value: { + 'task_identity': item.identity, + 'kind': 'delivery_receipt', + 'sealed_name': sealedName, + 'request_no': const Uuid().v7(), + 'captured_at': DateTime.now().toUtc().toIso8601String(), + }, + ); + temporaryPath = await widget.drafts.materializeAttachment( + accountIdentity: widget.session.identity, + sealedName: sealedName, + ); + await widget.repository.submitDeliveryReceipt( + identity: item.identity, + recipientName: item.raw['contact_name'] as String? ?? '收货人', + recipientPhone: item.raw['contact_phone'] as String? ?? '', + proofFile: temporaryPath, + ); + await widget.drafts.deleteDraft(widget.session.identity, item.identity); + if (mounted) setState(() => _future = _load()); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('签收提交失败,加密草稿已保留:$error'))); + } + } finally { + if (temporaryPath != null) { + final file = File(temporaryPath); + if (file.existsSync()) await file.delete(); + } + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('任务详情')), + body: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (!snapshot.hasData) { + if (snapshot.hasError) return Center(child: Text(snapshot.error.toString())); + return const Center(child: CircularProgressIndicator()); + } + final item = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(18), + children: [ + Card( + color: Theme.of(context).colorScheme.primary, + child: Padding( + padding: const EdgeInsets.all(22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.number, style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 8), + Text( + item.title, + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 12), + Chip(label: Text(statusName(item.status))), + ], + ), + ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.location_on_outlined), + title: const Text('服务地址'), + subtitle: Text(item.address.isEmpty ? '未提供地址' : item.address), + ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.person_outline), + title: Text(item.raw['contact_name'] as String? ?? '服务用户'), + subtitle: Text(item.raw['contact_phone'] as String? ?? ''), + ), + ), + const SizedBox(height: 18), + if (item.allowedActions.contains('start')) + ElevatedButton( + onPressed: _busy + ? null + : () => _run( + (value) => widget.repository.start(value, widget.session.roleCode), + item, + ), + child: const Text('开始处理'), + ), + if (item.allowedActions.contains('append_tracks')) + OutlinedButton( + onPressed: _busy + ? null + : () => _run( + (value) => widget.repository.appendCurrentTrack(value.identity), + item, + ), + child: const Text('上报当前位置'), + ), + if (item.allowedActions.contains('arrive')) + ElevatedButton( + onPressed: _busy + ? null + : () => _run((value) => widget.repository.arrive(value.identity), item), + child: const Text('到达并校验围栏'), + ), + if (item.allowedActions.contains('submit_receipt')) + ElevatedButton( + onPressed: _busy ? null : () => _receipt(item), + child: const Text('拍摄签收凭证并提交'), + ), + if (item.allowedActions.contains('submit_result')) + ElevatedButton( + onPressed: _busy + ? null + : () async { + final changed = await context.push( + '/tasks/${item.identity}/evidence', + ); + if (changed == true) setState(() => _future = _load()); + }, + child: const Text('现场取证与提交'), + ), + if (item.allowedActions.contains('exception')) + TextButton( + onPressed: _busy + ? null + : () async { + final reason = await _reason(); + if (reason != null && reason.isNotEmpty) { + await _run( + (value) => + widget.repository.exception(value, widget.session.roleCode, reason), + item, + ); + } + }, + child: const Text('标记异常'), + ), + if (item.allowedActions.contains('recover')) + ElevatedButton( + onPressed: _busy + ? null + : () async { + final reason = await _reason(); + if (reason != null && reason.isNotEmpty) { + await _run( + (value) => + widget.repository.recover(value, widget.session.roleCode, reason), + item, + ); + } + }, + child: const Text('恢复任务'), + ), + if (_busy) + const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ], + ); + }, + ), + ); +} diff --git a/apps/service_app/lib/ui/features/work/work_list_page.dart b/apps/service_app/lib/ui/features/work/work_list_page.dart new file mode 100644 index 0000000..70eb5eb --- /dev/null +++ b/apps/service_app/lib/ui/features/work/work_list_page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../app/dependencies.dart'; +import '../../../data/repositories/service_repository.dart'; +import '../../../domain/models/service_models.dart'; +import 'work_list_view_model.dart'; + +class WorkListPage extends StatefulWidget { + const WorkListPage({ + required this.session, + required this.repository, + required this.completed, + super.key, + }); + + final StaffSession session; + final ServiceRepository repository; + final bool completed; + + @override + State createState() => _WorkListPageState(); +} + +class _WorkListPageState extends State { + late final WorkListViewModel _viewModel; + + @override + void initState() { + super.initState(); + _viewModel = WorkListViewModel( + widget.repository, + widget.session.roleCode, + completed: widget.completed, + )..load(); + } + + @override + void dispose() { + _viewModel.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text(widget.completed ? '作业记录' : '${roleName(widget.session.roleCode)}工作台'), + ), + body: ListenableBuilder( + listenable: _viewModel, + builder: (context, _) { + if (_viewModel.loading && _viewModel.items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_viewModel.error != null && _viewModel.items.isEmpty) { + return Center(child: Text(_viewModel.error.toString())); + } + return RefreshIndicator( + onRefresh: _viewModel.load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), + children: [ + Card( + color: widget.session.roleCode == 'operations' + ? const Color(0xFFE9F8F0) + : const Color(0xFFEEEAFE), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon( + widget.session.roleCode == 'delivery' + ? Icons.local_shipping + : widget.session.roleCode == 'installer' + ? Icons.handyman + : Icons.health_and_safety, + size: 38, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + widget.completed ? '服务端确认完成的历史记录' : '仅展示服务端分派给本账号的任务', + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + ], + ), + ), + ), + if (_viewModel.items.isEmpty) + const Padding( + padding: EdgeInsets.all(42), + child: Center(child: Text('暂无任务')), + ) + else + ..._viewModel.items.map( + (item) => Card( + child: ListTile( + contentPadding: const EdgeInsets.all(16), + title: Text(item.title, style: const TextStyle(fontWeight: FontWeight.w800)), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('${item.number}\n${item.address}'), + ), + trailing: Chip(label: Text(statusName(item.status))), + onTap: () => context.push('/tasks/${item.identity}'), + ), + ), + ), + ], + ), + ); + }, + ), + ); +} diff --git a/apps/service_app/lib/ui/features/work/work_list_view_model.dart b/apps/service_app/lib/ui/features/work/work_list_view_model.dart new file mode 100644 index 0000000..a7017ed --- /dev/null +++ b/apps/service_app/lib/ui/features/work/work_list_view_model.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; + +import '../../../data/repositories/service_repository.dart'; +import '../../../domain/models/service_models.dart'; + +class WorkListViewModel extends ChangeNotifier { + WorkListViewModel(this._repository, this._roleCode, {required this.completed}); + + final ServiceRepository _repository; + final String _roleCode; + final bool completed; + List _items = const []; + Object? _error; + bool _loading = false; + + List get items => _items; + Object? get error => _error; + bool get loading => _loading; + + Future load() async { + _loading = true; + _error = null; + notifyListeners(); + try { + final all = await _repository.tasks(_roleCode); + _items = all + .where((item) => completed ? item.status == 23 : item.status != 23 && item.status != 22) + .toList(); + } catch (error) { + _error = error; + } finally { + _loading = false; + notifyListeners(); + } + } +} diff --git a/apps/service_app/pubspec.lock b/apps/service_app/pubspec.lock new file mode 100644 index 0000000..cd8da29 --- /dev/null +++ b/apps/service_app/pubspec.lock @@ -0,0 +1,786 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + 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" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" + url: "https://pub.dev" + source: hosted + version: "7.3.1" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + 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" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + 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_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + 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" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: e146a6d63776582651e97a79cbe459f8e1211b100101fadcd84db83361fa599f + url: "https://pub.dev" + source: hosted + version: "14.0.3" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 + url: "https://pub.dev" + source: hosted + version: "4.2.8" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" + url: "https://pub.dev" + source: hosted + version: "17.3.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.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: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.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" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + 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" + 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_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.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: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + 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: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/apps/service_app/pubspec.yaml b/apps/service_app/pubspec.yaml new file mode 100644 index 0000000..939cd6c --- /dev/null +++ b/apps/service_app/pubspec.yaml @@ -0,0 +1,98 @@ +name: service_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + go_router: ^17.3.0 + http: ^1.6.0 + flutter_secure_storage: ^10.3.1 + uuid: ^4.6.0 + path_provider: ^2.1.6 + cryptography: ^2.9.0 + image_picker: ^1.2.3 + geolocator: ^14.0.3 + connectivity_plus: ^7.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/apps/service_app/test/domain/service_models_test.dart b/apps/service_app/test/domain/service_models_test.dart new file mode 100644 index 0000000..df32841 --- /dev/null +++ b/apps/service_app/test/domain/service_models_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_app/domain/models/service_models.dart'; + +void main() { + group('WorkItem', () { + test('maps delivery allowed actions from server', () { + final item = WorkItem.delivery({ + 'identity': 'order-1', + 'order_no': 'GAS001', + 'order_status': 33, + 'allowed_actions': ['append_tracks', 'arrive', 'exception'], + }); + + expect(item.identity, 'order-1'); + expect(item.allowedActions, contains('arrive')); + expect(statusName(item.status), '配送中'); + }); + + test('derives ticket actions from server status', () { + final item = WorkItem.ticket({ + 'identity': 'ticket-1', + 'ticket_no': 'TK001', + 'ticket_status': 11, + 'category': 'inspection', + }); + + expect(item.title, '安全检查'); + expect(item.allowedActions, contains('submit_result')); + }); + }); +} diff --git a/apps/service_app/test/ui/login_page_test.dart b/apps/service_app/test/ui/login_page_test.dart new file mode 100644 index 0000000..4dff0c8 --- /dev/null +++ b/apps/service_app/test/ui/login_page_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_app/app/dependencies.dart'; +import 'package:service_app/data/services/secure_session_store.dart'; +import 'package:service_app/ui/features/auth/login_page.dart'; + +void main() { + testWidgets('staff login explains the single shared entry', (tester) async { + final session = StaffSession(SecureSessionStore()); + await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + + expect(find.text('瓶安芯服务工作台'), findsOneWidget); + expect(find.text('配送、安装维修与安检共用入口'), findsOneWidget); + expect(find.byType(TextField), findsNWidgets(2)); + }); +} diff --git a/apps/user_app/.gitignore b/apps/user_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/user_app/.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/apps/user_app/.metadata b/apps/user_app/.metadata new file mode 100644 index 0000000..3a28996 --- /dev/null +++ b/apps/user_app/.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: "058e0af2c2b57e369d905a03ac9748b0ebf543c6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: android + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: ios + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + + # 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/apps/user_app/README.md b/apps/user_app/README.md new file mode 100644 index 0000000..ea7dd5b --- /dev/null +++ b/apps/user_app/README.md @@ -0,0 +1,23 @@ +# 瓶安芯用户端 + +Android/iOS Flutter 客户端。首期只接入 `/heqi/client/v1/user` 的真实能力:首页安全内容与服务归属、商城、订单、合同、工单、钱包、地址和个人资料。 + +## 本地运行 + +```bash +flutter pub get +flutter run --dart-define=API_BASE_URL=http://10.0.2.2:12426 +``` + +Android 模拟器访问宿主机使用 `10.0.2.2`;iOS Simulator 使用 `http://127.0.0.1:12426`。Release 环境必须注入 HTTPS 地址。 + +## 质量检查 + +```bash +flutter analyze +flutter test +flutter build apk --debug +flutter build ios --simulator --no-codesign +``` + +充值 Mock 确认、设备控制、收藏、押金、消息和发票均不在 Release UI 暴露。 diff --git a/apps/user_app/analysis_options.yaml b/apps/user_app/analysis_options.yaml new file mode 100644 index 0000000..c645960 --- /dev/null +++ b/apps/user_app/analysis_options.yaml @@ -0,0 +1,16 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + avoid_print: true + use_super_parameters: true + +formatter: + page_width: 100 + trailing_commas: preserve diff --git a/apps/user_app/android/.gitignore b/apps/user_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/user_app/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/apps/user_app/android/app/build.gradle.kts b/apps/user_app/android/app/build.gradle.kts new file mode 100644 index 0000000..7471d63 --- /dev/null +++ b/apps/user_app/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.heqi.user_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.heqi.user_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + 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") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/apps/user_app/android/app/src/main/AndroidManifest.xml b/apps/user_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ff95ab6 --- /dev/null +++ b/apps/user_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user_app/android/app/src/main/kotlin/com/heqi/user_app/MainActivity.kt b/apps/user_app/android/app/src/main/kotlin/com/heqi/user_app/MainActivity.kt new file mode 100644 index 0000000..27d35bc --- /dev/null +++ b/apps/user_app/android/app/src/main/kotlin/com/heqi/user_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.heqi.user_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/user_app/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/user_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/user_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/user_app/android/app/src/main/res/drawable/launch_background.xml b/apps/user_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/user_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/user_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/user_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/apps/user_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/user_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/user_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/apps/user_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/user_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/user_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/apps/user_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/user_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/user_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/apps/user_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/user_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/user_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/apps/user_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/user_app/android/app/src/main/res/values-night/styles.xml b/apps/user_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/user_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/user_app/android/app/src/main/res/values/styles.xml b/apps/user_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/user_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/user_app/android/app/src/profile/AndroidManifest.xml b/apps/user_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/user_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/user_app/android/build.gradle.kts b/apps/user_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/user_app/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/apps/user_app/android/gradle.properties b/apps/user_app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/apps/user_app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/apps/user_app/android/gradle/wrapper/gradle-wrapper.properties b/apps/user_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/apps/user_app/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-9.1.0-all.zip diff --git a/apps/user_app/android/settings.gradle.kts b/apps/user_app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/apps/user_app/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 "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/apps/user_app/ios/.gitignore b/apps/user_app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/apps/user_app/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/apps/user_app/ios/Flutter/AppFrameworkInfo.plist b/apps/user_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/apps/user_app/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/apps/user_app/ios/Flutter/Debug.xcconfig b/apps/user_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/user_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/user_app/ios/Flutter/Release.xcconfig b/apps/user_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/user_app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/user_app/ios/Runner.xcodeproj/project.pbxproj b/apps/user_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d3840d8 --- /dev/null +++ b/apps/user_app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*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 */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 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 = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; 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 = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 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; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + 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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + 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.heqi.userApp; + 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.heqi.userApp.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.heqi.userApp.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.heqi.userApp.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.heqi.userApp; + 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.heqi.userApp; + 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 */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/user_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/user_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/user_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/apps/user_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/user_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/apps/user_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/user_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/user_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/user_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/user_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/user_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/user_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/user_app/ios/Runner/AppDelegate.swift b/apps/user_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/apps/user_app/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/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/apps/user_app/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/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/user_app/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/apps/user_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/apps/user_app/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/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/user_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/apps/user_app/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/apps/user_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/user_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/apps/user_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user_app/ios/Runner/Base.lproj/Main.storyboard b/apps/user_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/apps/user_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user_app/ios/Runner/Info.plist b/apps/user_app/ios/Runner/Info.plist new file mode 100644 index 0000000..c4e188a --- /dev/null +++ b/apps/user_app/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 瓶安芯 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + user_app + 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/apps/user_app/ios/Runner/Runner-Bridging-Header.h b/apps/user_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/apps/user_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/user_app/ios/Runner/SceneDelegate.swift b/apps/user_app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/apps/user_app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/apps/user_app/ios/RunnerTests/RunnerTests.swift b/apps/user_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/apps/user_app/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/apps/user_app/lib/app/app.dart b/apps/user_app/lib/app/app.dart new file mode 100644 index 0000000..2ef1d8e --- /dev/null +++ b/apps/user_app/lib/app/app.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../ui/core/app_theme.dart'; +import 'dependencies.dart'; +import 'router.dart'; + +class UserClientApp extends StatefulWidget { + const UserClientApp({required this.dependencies, super.key}); + + final AppDependencies dependencies; + + @override + State createState() => _UserClientAppState(); +} + +class _UserClientAppState extends State { + late final _router = createRouter(widget.dependencies); + + @override + Widget build(BuildContext context) => MaterialApp.router( + title: '瓶安芯', + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + routerConfig: _router, + ); +} diff --git a/apps/user_app/lib/app/dependencies.dart b/apps/user_app/lib/app/dependencies.dart new file mode 100644 index 0000000..8559654 --- /dev/null +++ b/apps/user_app/lib/app/dependencies.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; + +import '../data/repositories/client_repository.dart'; +import '../data/services/api_client.dart'; +import '../data/services/secure_session_store.dart'; + +class AppDependencies { + AppDependencies._({ + required this.session, + required this.repository, + }); + + final UserSession session; + final ClientRepository repository; + + static Future create() async { + final store = SecureSessionStore(); + final session = UserSession(store); + await session.restore(); + final api = ApiClient(() => session.token); + return AppDependencies._(session: session, repository: ClientRepository(api)); + } +} + +class UserSession extends ChangeNotifier { + UserSession(this._store); + + static const _root = '/heqi/client/v1/user'; + final SecureSessionStore _store; + String _token = ''; + + String get token => _token; + bool get isAuthenticated => _token.isNotEmpty; + + Future restore() async { + _token = await _store.readToken() ?? ''; + } + + Future login({ + required String phone, + required String password, + String? verificationCode, + String? requestIdentity, + }) async { + final api = ApiClient(() => ''); + final verification = verificationCode != null && verificationCode.isNotEmpty; + final details = jsonMap( + await api.post( + '$_root/auth/login', + authenticated: false, + body: { + 'phone': phone, + 'mode': verification ? 'verification_code' : 'password', + 'password': verification ? '' : password, + 'code': verificationCode ?? '', + 'request_identity': requestIdentity ?? '', + }, + ), + ); + _token = details['access_token'] as String? ?? ''; + if (_token.isEmpty) throw const ApiException(500, '登录令牌缺失'); + await _store.writeToken(_token); + notifyListeners(); + } + + Future sendCode(String phone, String purpose) async { + final api = ApiClient(() => ''); + final details = jsonMap( + await api.post( + '$_root/auth/verification-code', + authenticated: false, + body: {'phone': phone, 'purpose': purpose}, + ), + ); + return details['request_identity'] as String? ?? ''; + } + + Future logout() async { + _token = ''; + await _store.clear(); + notifyListeners(); + } +} diff --git a/apps/user_app/lib/app/router.dart b/apps/user_app/lib/app/router.dart new file mode 100644 index 0000000..ba8998a --- /dev/null +++ b/apps/user_app/lib/app/router.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../ui/features/auth/login_page.dart'; +import '../ui/features/auth/register_page.dart'; +import '../ui/features/home/home_page.dart'; +import '../ui/features/orders/orders_page.dart'; +import '../ui/features/profile/profile_page.dart'; +import '../ui/features/shared/record_list_page.dart'; +import '../ui/features/shared/record_list_view_model.dart'; +import '../ui/features/shop/shop_page.dart'; +import 'dependencies.dart'; + +GoRouter createRouter(AppDependencies dependencies) => GoRouter( + initialLocation: '/home', + refreshListenable: dependencies.session, + redirect: (context, state) { + final authRoute = state.matchedLocation == '/login' || state.matchedLocation == '/register'; + if (!dependencies.session.isAuthenticated && !authRoute) return '/login'; + if (dependencies.session.isAuthenticated && state.matchedLocation == '/login') return '/home'; + return null; + }, + routes: [ + GoRoute( + path: '/login', + builder: (context, state) => LoginPage(session: dependencies.session), + ), + GoRoute( + path: '/register', + builder: (context, state) => RegisterPage(session: dependencies.session), + ), + GoRoute( + path: '/records/contracts', + builder: (context, state) => RecordListPage( + title: '供气合同', + eyebrow: '可信履约', + viewModel: RecordListViewModel(dependencies.repository.contracts), + ), + ), + GoRoute( + path: '/records/wallet', + builder: (context, state) => RecordListPage( + title: '钱包流水', + eyebrow: '资金记录', + viewModel: RecordListViewModel(dependencies.repository.walletRecords), + ), + ), + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => _UserShell(navigationShell: navigationShell), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => HomePage(repository: dependencies.repository), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/shop', + builder: (context, state) => ShopPage(repository: dependencies.repository), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/orders', + builder: (context, state) => OrdersPage(repository: dependencies.repository), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/me', + builder: (context, state) => + ProfilePage(session: dependencies.session, repository: dependencies.repository), + ), + ], + ), + ], + ), + ], +); + +class _UserShell extends StatelessWidget { + const _UserShell({required this.navigationShell}); + + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) => Scaffold( + body: navigationShell, + bottomNavigationBar: NavigationBar( + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (index) => + navigationShell.goBranch(index, initialLocation: index == navigationShell.currentIndex), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: '首页', + ), + NavigationDestination( + icon: Icon(Icons.shopping_bag_outlined), + selectedIcon: Icon(Icons.shopping_bag), + label: '商城', + ), + NavigationDestination( + icon: Icon(Icons.receipt_long_outlined), + selectedIcon: Icon(Icons.receipt_long), + label: '订单', + ), + NavigationDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: '我的', + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/data/repositories/client_repository.dart b/apps/user_app/lib/data/repositories/client_repository.dart new file mode 100644 index 0000000..92a34f0 --- /dev/null +++ b/apps/user_app/lib/data/repositories/client_repository.dart @@ -0,0 +1,149 @@ +import '../../domain/models/client_models.dart'; +import '../services/api_client.dart'; + +class ClientRepository { + ClientRepository(this._api); + + static const root = '/heqi/client/v1/user'; + final ApiClient _api; + + Future> contents() async { + final values = jsonList(await _api.get('$root/public/contents', authenticated: false)); + return values + .map( + (value) => ClientRecord( + identity: value['identity'] as String? ?? '', + title: value['title'] as String? ?? '安全公告', + subtitle: value['summary'] as String? ?? value['content_type'] as String? ?? '', + raw: value, + ), + ) + .toList(); + } + + Future> products() async { + final values = jsonList(await _api.get('$root/public/products', authenticated: false)); + return values + .map( + (value) => ClientRecord( + identity: value['identity'] as String? ?? '', + title: value['name'] as String? ?? '商品', + subtitle: + '${moneyText((value['price_amount'] as num?)?.toInt() ?? 0)} · 库存 ${(value['stock_quantity'] as num?)?.toInt() ?? 0}', + raw: value, + ), + ) + .toList(); + } + + Future profile() async => + UserProfile.fromJson(jsonMap(await _api.get('$root/auth/profile'))); + + Future wallet() async => + WalletSummary.fromJson(jsonMap(await _api.get('$root/wallet'))); + + Future> addresses() => + _records('$root/addresses', titleKeys: const ['address'], subtitleKeys: const ['is_default']); + + Future> shopOrders() => _records( + '$root/shop/orders', + titleKeys: const ['order_no'], + subtitleKeys: const ['payable_amount', 'logistics_company'], + statusKey: 'order_status', + ); + + Future> gasOrders() => _records( + '$root/gas/orders', + titleKeys: const ['order_no'], + subtitleKeys: const ['address'], + statusKey: 'order_status', + ); + + Future> contracts() => _records( + '$root/gas/contracts', + titleKeys: const ['contract_no'], + subtitleKeys: const ['title'], + statusKey: 'contract_status', + ); + + Future> tickets() => _records( + '$root/tickets', + titleKeys: const ['ticket_no'], + subtitleKeys: const ['description', 'category'], + statusKey: 'ticket_status', + ); + + Future> walletRecords() => _records( + '$root/wallet/records', + titleKeys: const ['trade_type', 'record_no'], + subtitleKeys: const ['amount', 'direction'], + ); + + Future?> serviceRelation() async { + final value = await _api.get('$root/service-relation'); + if (value == null || value == '') return null; + return jsonMap(value); + } + + Future addAddress(String address, {bool isDefault = false}) async { + await _api.post('$root/addresses', body: {'address': address, 'is_default': isDefault}); + } + + Future createTicket({ + required String requestNo, + required String category, + required String description, + }) async { + await _api.post( + '$root/tickets', + body: {'request_no': requestNo, 'category': category, 'description': description}, + ); + } + + Future createShopOrder({ + required String requestNo, + required String productIdentity, + required String addressIdentity, + required String contactName, + required String contactPhone, + }) async { + await _api.post( + '$root/shop/orders', + body: { + 'request_no': requestNo, + 'address_identity': addressIdentity, + 'contact_name': contactName, + 'contact_phone': contactPhone, + 'items': [ + {'product_identity': productIdentity, 'quantity': 1}, + ], + }, + ); + } + + Future> _records( + String path, { + required List titleKeys, + required List subtitleKeys, + String? statusKey, + }) async { + final values = jsonList(await _api.get(path)); + return values.map((value) { + String pick(List keys) { + for (final key in keys) { + final item = value[key]; + if (item != null && item.toString().isNotEmpty) return item.toString(); + } + return ''; + } + + return ClientRecord( + identity: value['identity'] as String? ?? '', + title: pick(titleKeys), + subtitle: pick(subtitleKeys), + status: statusKey == null ? null : (value[statusKey] as num?)?.toInt(), + raw: value, + ); + }).toList(); + } +} diff --git a/apps/user_app/lib/data/services/api_client.dart b/apps/user_app/lib/data/services/api_client.dart new file mode 100644 index 0000000..e159eaf --- /dev/null +++ b/apps/user_app/lib/data/services/api_client.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +class ApiException implements Exception { + const ApiException(this.code, this.message); + + final int code; + final String message; + + @override + String toString() => message; +} + +class ApiClient { + ApiClient( + this._tokenProvider, { + http.Client? client, + String? baseUrl, + }) : _client = client ?? http.Client(), + baseUrl = + baseUrl ?? + const String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://10.0.2.2:12426', + ); + + final String baseUrl; + final String Function() _tokenProvider; + final http.Client _client; + + Future get(String path, {bool authenticated = true}) => + _send('GET', path, authenticated: authenticated); + + Future post( + String path, { + Map? body, + bool authenticated = true, + }) => _send('POST', path, body: body, authenticated: authenticated); + + Future put(String path, {Map? body}) => _send('PUT', path, body: body); + + Future delete(String path, {Map? body}) => + _send('DELETE', path, body: body); + + Future _send( + String method, + String path, { + Map? body, + bool authenticated = true, + }) async { + final request = http.Request(method, Uri.parse('$baseUrl$path')); + request.headers[HttpHeaders.acceptHeader] = 'application/json'; + if (authenticated) { + final token = _tokenProvider(); + if (token.isNotEmpty) request.headers[HttpHeaders.authorizationHeader] = token; + } + if (body != null) { + request.headers[HttpHeaders.contentTypeHeader] = 'application/json; charset=UTF-8'; + request.body = jsonEncode(body); + } + final streamed = await _client.send(request); + final response = await http.Response.fromStream(streamed); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException(response.statusCode, '网络请求失败(${response.statusCode})'); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const ApiException(500, '服务端响应格式错误'); + } + final code = (decoded['code'] as num?)?.toInt() ?? 500; + if (code != 0) { + throw ApiException(code, decoded['message'] as String? ?? '操作失败'); + } + return decoded['details']; + } +} + +Map jsonMap(Object? value) { + if (value is Map) return value; + if (value is Map) return value.map((key, item) => MapEntry(key.toString(), item)); + throw const ApiException(500, '服务端数据格式错误'); +} + +List> jsonList(Object? value) { + if (value is! List) return const []; + return value.map>(jsonMap).toList(growable: false); +} diff --git a/apps/user_app/lib/data/services/secure_session_store.dart b/apps/user_app/lib/data/services/secure_session_store.dart new file mode 100644 index 0000000..9db8306 --- /dev/null +++ b/apps/user_app/lib/data/services/secure_session_store.dart @@ -0,0 +1,15 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class SecureSessionStore { + SecureSessionStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _tokenKey = 'user_app_access_token'; + final FlutterSecureStorage _storage; + + Future readToken() => _storage.read(key: _tokenKey); + + Future writeToken(String token) => _storage.write(key: _tokenKey, value: token); + + Future clear() => _storage.delete(key: _tokenKey); +} diff --git a/apps/user_app/lib/domain/models/client_models.dart b/apps/user_app/lib/domain/models/client_models.dart new file mode 100644 index 0000000..a8cde20 --- /dev/null +++ b/apps/user_app/lib/domain/models/client_models.dart @@ -0,0 +1,50 @@ +class ClientRecord { + const ClientRecord({ + required this.identity, + required this.title, + required this.subtitle, + required this.raw, + this.status, + }); + + final String identity; + final String title; + final String subtitle; + final int? status; + final Map raw; +} + +class UserProfile { + const UserProfile({ + required this.identity, + required this.name, + required this.phone, + required this.avatar, + }); + + final String identity; + final String name; + final String phone; + final String avatar; + + factory UserProfile.fromJson(Map json) => UserProfile( + identity: json['identity'] as String? ?? '', + name: json['name'] as String? ?? '', + phone: json['phone'] as String? ?? '', + avatar: json['avatar'] as String? ?? '', + ); +} + +class WalletSummary { + const WalletSummary({required this.balance, required this.withdrawalBalance}); + + final int balance; + final int withdrawalBalance; + + factory WalletSummary.fromJson(Map json) => WalletSummary( + balance: (json['balance'] as num?)?.toInt() ?? 0, + withdrawalBalance: (json['withdrawal_balance'] as num?)?.toInt() ?? 0, + ); +} + +String moneyText(int cents) => '¥${(cents / 100).toStringAsFixed(2)}'; diff --git a/apps/user_app/lib/main.dart b/apps/user_app/lib/main.dart new file mode 100644 index 0000000..1caa7e9 --- /dev/null +++ b/apps/user_app/lib/main.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'app/app.dart'; +import 'app/dependencies.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final dependencies = await AppDependencies.create(); + runApp(UserClientApp(dependencies: dependencies)); +} diff --git a/apps/user_app/lib/ui/core/app_theme.dart b/apps/user_app/lib/ui/core/app_theme.dart new file mode 100644 index 0000000..6feba5d --- /dev/null +++ b/apps/user_app/lib/ui/core/app_theme.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const safetyBlue = Color(0xFF246BFD); + static const ink = Color(0xFF14213D); + static const canvas = Color(0xFFF4F7FB); + + static ThemeData light() { + final scheme = ColorScheme.fromSeed( + seedColor: safetyBlue, + primary: safetyBlue, + surface: Colors.white, + error: const Color(0xFFD92D20), + ); + return ThemeData( + colorScheme: scheme, + scaffoldBackgroundColor: canvas, + useMaterial3: true, + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + foregroundColor: ink, + centerTitle: false, + ), + cardTheme: CardThemeData( + color: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + ); + } +} diff --git a/apps/user_app/lib/ui/core/widgets.dart b/apps/user_app/lib/ui/core/widgets.dart new file mode 100644 index 0000000..7f4a31e --- /dev/null +++ b/apps/user_app/lib/ui/core/widgets.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +import '../../domain/models/client_models.dart'; + +class PageIntro extends StatelessWidget { + const PageIntro({required this.eyebrow, required this.title, this.description, super.key}); + + final String eyebrow; + final String title; + final String? description; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + eyebrow, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + const SizedBox(height: 6), + Text( + title, + style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w800), + ), + if (description != null) ...[ + const SizedBox(height: 6), + Text(description!, style: Theme.of(context).textTheme.bodyMedium), + ], + ], + ), + ); +} + +class RecordCard extends StatelessWidget { + const RecordCard({required this.record, this.onTap, super.key}); + + final ClientRecord record; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) => Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + title: Text( + record.title.isEmpty ? '未命名记录' : record.title, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: record.subtitle.isEmpty + ? null + : Padding(padding: const EdgeInsets.only(top: 6), child: Text(record.subtitle)), + trailing: record.status == null + ? const Icon(Icons.chevron_right) + : Chip(label: Text('状态 ${record.status}'), visualDensity: VisualDensity.compact), + onTap: onTap, + ), + ); +} + +class EmptyState extends StatelessWidget { + const EmptyState({required this.title, required this.description, this.onRetry, super.key}); + + final String title; + final String description; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, size: 52, color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 16), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text(description, textAlign: TextAlign.center), + if (onRetry != null) ...[ + const SizedBox(height: 18), + OutlinedButton(onPressed: onRetry, child: const Text('重新加载')), + ], + ], + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/auth/login_page.dart b/apps/user_app/lib/ui/features/auth/login_page.dart new file mode 100644 index 0000000..3f31531 --- /dev/null +++ b/apps/user_app/lib/ui/features/auth/login_page.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../app/dependencies.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({required this.session, super.key}); + + final UserSession session; + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _phone = TextEditingController(); + final _password = TextEditingController(); + bool _submitting = false; + String? _error; + + @override + void dispose() { + _phone.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _login() async { + setState(() { + _submitting = true; + _error = null; + }); + try { + await widget.session.login(phone: _phone.text.trim(), password: _password.text); + } catch (error) { + if (mounted) setState(() => _error = error.toString()); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.health_and_safety_rounded, + size: 68, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 20), + Text( + '瓶安芯', + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 8), + const Text('安全服务与生活采购', textAlign: TextAlign.center), + const SizedBox(height: 36), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + autofillHints: const [AutofillHints.telephoneNumber], + decoration: const InputDecoration( + labelText: '手机号', + prefixIcon: Icon(Icons.phone_outlined), + ), + ), + const SizedBox(height: 14), + TextField( + controller: _password, + obscureText: true, + autofillHints: const [AutofillHints.password], + decoration: const InputDecoration( + labelText: '密码', + prefixIcon: Icon(Icons.lock_outline), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 20), + ElevatedButton( + onPressed: _submitting ? null : _login, + child: _submitting + ? const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('安全登录'), + ), + TextButton( + onPressed: () => context.push('/register'), + child: const Text('首次使用?注册账号'), + ), + const SizedBox(height: 12), + const Text( + '登录即表示同意用户协议和隐私政策', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/auth/register_page.dart b/apps/user_app/lib/ui/features/auth/register_page.dart new file mode 100644 index 0000000..d66c740 --- /dev/null +++ b/apps/user_app/lib/ui/features/auth/register_page.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import '../../../app/dependencies.dart'; +import '../../../data/services/api_client.dart'; + +class RegisterPage extends StatefulWidget { + const RegisterPage({required this.session, super.key}); + + final UserSession session; + + @override + State createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { + final _phone = TextEditingController(); + final _name = TextEditingController(); + final _address = TextEditingController(); + final _password = TextEditingController(); + final _code = TextEditingController(); + String _requestIdentity = ''; + bool _busy = false; + String? _message; + + Future _sendCode() async { + try { + final identity = await widget.session.sendCode(_phone.text.trim(), 'register'); + setState(() { + _requestIdentity = identity; + _message = '验证码已发送'; + }); + } catch (error) { + setState(() => _message = error.toString()); + } + } + + Future _register() async { + setState(() => _busy = true); + try { + final api = ApiClient(() => ''); + await api.post( + '/heqi/client/v1/user/auth/register', + authenticated: false, + body: { + 'phone': _phone.text.trim(), + 'name': _name.text.trim(), + 'address': _address.text.trim(), + 'password': _password.text, + 'code': _code.text.trim(), + 'request_identity': _requestIdentity, + }, + ); + if (mounted) Navigator.of(context).pop(); + } catch (error) { + if (mounted) setState(() => _message = error.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _phone.dispose(); + _name.dispose(); + _address.dispose(); + _password.dispose(); + _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('注册用户')), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: const InputDecoration(labelText: '手机号'), + ), + const SizedBox(height: 12), + TextField( + controller: _name, + decoration: const InputDecoration(labelText: '姓名'), + ), + const SizedBox(height: 12), + TextField( + controller: _address, + decoration: const InputDecoration(labelText: '服务地址'), + ), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration(labelText: '登录密码'), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _code, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '验证码'), + ), + ), + const SizedBox(width: 10), + OutlinedButton(onPressed: _sendCode, child: const Text('获取验证码')), + ], + ), + if (_message != null) + Padding(padding: const EdgeInsets.only(top: 12), child: Text(_message!)), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _busy || _requestIdentity.isEmpty ? null : _register, + child: const Text('创建账号'), + ), + ], + ), + ); +} diff --git a/apps/user_app/lib/ui/features/home/home_page.dart b/apps/user_app/lib/ui/features/home/home_page.dart new file mode 100644 index 0000000..9aed9b8 --- /dev/null +++ b/apps/user_app/lib/ui/features/home/home_page.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../core/widgets.dart'; + +class HomePage extends StatefulWidget { + const HomePage({required this.repository, super.key}); + + final ClientRepository repository; + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + late Future<(List, Map?)> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<(List, Map?)> _load() async => + (await widget.repository.contents(), await widget.repository.serviceRelation()); + + Future _refresh() async { + setState(() => _future = _load()); + await _future; + } + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: FutureBuilder<(List, Map?)>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return EmptyState( + title: '首页加载失败', + description: snapshot.error.toString(), + onRetry: _refresh, + ); + } + final (contents, relation) = snapshot.data ?? (const [], null); + return RefreshIndicator( + onRefresh: _refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 24), + children: [ + const PageIntro( + eyebrow: '安全生活', + title: '今天也要安心用气', + description: '设备控制能力尚未开放,本页只展示真实服务与安全内容。', + ), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon( + Icons.store_mall_directory_outlined, + size: 38, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('当前服务归属', style: TextStyle(fontWeight: FontWeight.w800)), + const SizedBox(height: 4), + Text( + relation == null + ? '尚未建立服务关系' + : '${relation['gas_name'] ?? ''} ${relation['delivery_name'] ?? ''}', + ), + ], + ), + ), + ], + ), + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(20, 26, 20, 8), + child: Text('安全公告', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18)), + ), + if (contents.isEmpty) + const Padding( + padding: EdgeInsets.all(20), + child: Text('暂无已发布内容'), + ) + else + ...contents.take(6).map((item) => RecordCard(record: item)), + ], + ), + ); + }, + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/orders/orders_page.dart b/apps/user_app/lib/ui/features/orders/orders_page.dart new file mode 100644 index 0000000..5911ce8 --- /dev/null +++ b/apps/user_app/lib/ui/features/orders/orders_page.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../shared/record_list_page.dart'; +import '../shared/record_list_view_model.dart'; + +class OrdersPage extends StatelessWidget { + const OrdersPage({required this.repository, super.key}); + + final ClientRepository repository; + + @override + Widget build(BuildContext context) => DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('我的订单'), + bottom: const TabBar( + tabs: [ + Tab(text: '商城'), + Tab(text: '供气'), + Tab(text: '服务工单'), + ], + ), + ), + body: TabBarView( + children: [ + RecordListPage( + title: '商城订单', + eyebrow: '交易', + viewModel: RecordListViewModel(repository.shopOrders), + ), + RecordListPage( + title: '供气订单', + eyebrow: '履约', + viewModel: RecordListViewModel(repository.gasOrders), + ), + RecordListPage( + title: '服务工单', + eyebrow: '服务', + viewModel: RecordListViewModel(repository.tickets), + ), + ], + ), + ), + ); +} diff --git a/apps/user_app/lib/ui/features/profile/profile_page.dart b/apps/user_app/lib/ui/features/profile/profile_page.dart new file mode 100644 index 0000000..1d8549e --- /dev/null +++ b/apps/user_app/lib/ui/features/profile/profile_page.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app/dependencies.dart'; +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({required this.session, required this.repository, super.key}); + + final UserSession session; + final ClientRepository repository; + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + late Future<(UserProfile, WalletSummary)> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<(UserProfile, WalletSummary)> _load() async => + (await widget.repository.profile(), await widget.repository.wallet()); + + Future _addAddress() async { + final controller = TextEditingController(); + final address = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('新增地址'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: '详细地址'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('保存'), + ), + ], + ), + ); + controller.dispose(); + if (address == null || address.isEmpty) return; + await widget.repository.addAddress(address, isDefault: true); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('地址已保存'))); + } + + Future _createTicket() async { + final controller = TextEditingController(); + final description = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('申请维修'), + content: TextField( + controller: controller, + maxLines: 4, + decoration: const InputDecoration(labelText: '问题描述'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('提交'), + ), + ], + ), + ); + controller.dispose(); + if (description == null || description.isEmpty) return; + await widget.repository.createTicket( + requestNo: const Uuid().v7(), + category: 'repair', + description: description, + ); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('工单已提交'))); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('我的')), + body: FutureBuilder<(UserProfile, WalletSummary)>( + future: _future, + builder: (context, snapshot) { + if (!snapshot.hasData) { + if (snapshot.hasError) return Center(child: Text(snapshot.error.toString())); + return const Center(child: CircularProgressIndicator()); + } + final (profile, wallet) = snapshot.data!; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + CircleAvatar( + radius: 30, + child: Text(profile.name.isEmpty ? '用' : profile.name.substring(0, 1)), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.name, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + Text(profile.phone), + ], + ), + ), + ], + ), + ), + ), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('钱包余额'), + SizedBox(height: 4), + Text('充值结果以服务端确认为准', style: TextStyle(fontSize: 12)), + ], + ), + Text( + moneyText(wallet.balance), + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w900), + ), + ], + ), + ), + ), + _item(Icons.location_on_outlined, '地址管理', _addAddress), + _item(Icons.description_outlined, '供气合同', () => context.push('/records/contracts')), + _item( + Icons.account_balance_wallet_outlined, + '钱包流水', + () => context.push('/records/wallet'), + ), + _item(Icons.build_outlined, '申请维修', _createTicket), + _item(Icons.logout, '退出登录', widget.session.logout), + ], + ); + }, + ), + ); + + Widget _item(IconData icon, String title, VoidCallback onTap) => Card( + child: ListTile( + leading: Icon(icon), + title: Text(title), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); +} diff --git a/apps/user_app/lib/ui/features/shared/record_list_page.dart b/apps/user_app/lib/ui/features/shared/record_list_page.dart new file mode 100644 index 0000000..7ce5cfd --- /dev/null +++ b/apps/user_app/lib/ui/features/shared/record_list_page.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../../core/widgets.dart'; +import 'record_list_view_model.dart'; + +class RecordListPage extends StatefulWidget { + const RecordListPage({ + required this.title, + required this.eyebrow, + required this.viewModel, + this.description, + this.floatingActionButton, + super.key, + }); + + final String title; + final String eyebrow; + final String? description; + final RecordListViewModel viewModel; + final Widget? floatingActionButton; + + @override + State createState() => _RecordListPageState(); +} + +class _RecordListPageState extends State { + @override + void initState() { + super.initState(); + widget.viewModel.load(); + } + + @override + void dispose() { + widget.viewModel.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(widget.title)), + floatingActionButton: widget.floatingActionButton, + body: ListenableBuilder( + listenable: widget.viewModel, + builder: (context, _) { + final state = widget.viewModel; + if (state.loading && state.records.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (state.error != null && state.records.isEmpty) { + return EmptyState( + title: '加载失败', + description: state.error.toString(), + onRetry: state.load, + ); + } + return RefreshIndicator( + onRefresh: state.load, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: PageIntro( + eyebrow: widget.eyebrow, + title: widget.title, + description: widget.description, + ), + ), + if (state.records.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: EmptyState(title: '暂无记录', description: '服务端还没有可展示的数据'), + ) + else + SliverList.builder( + itemCount: state.records.length, + itemBuilder: (context, index) => RecordCard(record: state.records[index]), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 24)), + ], + ), + ); + }, + ), + ); +} diff --git a/apps/user_app/lib/ui/features/shared/record_list_view_model.dart b/apps/user_app/lib/ui/features/shared/record_list_view_model.dart new file mode 100644 index 0000000..2f781ff --- /dev/null +++ b/apps/user_app/lib/ui/features/shared/record_list_view_model.dart @@ -0,0 +1,32 @@ +import 'package:flutter/foundation.dart'; + +import '../../../domain/models/client_models.dart'; + +typedef RecordLoader = Future> Function(); + +class RecordListViewModel extends ChangeNotifier { + RecordListViewModel(this._loader); + + final RecordLoader _loader; + List _records = const []; + Object? _error; + bool _loading = false; + + List get records => List.unmodifiable(_records); + Object? get error => _error; + bool get loading => _loading; + + Future load() async { + _loading = true; + _error = null; + notifyListeners(); + try { + _records = await _loader(); + } catch (error) { + _error = error; + } finally { + _loading = false; + notifyListeners(); + } + } +} diff --git a/apps/user_app/lib/ui/features/shop/shop_page.dart b/apps/user_app/lib/ui/features/shop/shop_page.dart new file mode 100644 index 0000000..dca737e --- /dev/null +++ b/apps/user_app/lib/ui/features/shop/shop_page.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../data/repositories/client_repository.dart'; +import '../../../domain/models/client_models.dart'; +import '../../core/widgets.dart'; + +class ShopPage extends StatefulWidget { + const ShopPage({required this.repository, super.key}); + + final ClientRepository repository; + + @override + State createState() => _ShopPageState(); +} + +class _ShopPageState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = widget.repository.products(); + } + + Future _buy(ClientRecord product) async { + final profile = await widget.repository.profile(); + final addresses = await widget.repository.addresses(); + if (!mounted) return; + if (addresses.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先在“我的”中添加收货地址'))); + return; + } + final confirmed = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 30), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '确认下单', + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 12), + Text(product.title), + Text(addresses.first.title), + const SizedBox(height: 18), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('提交订单'), + ), + ], + ), + ), + ); + if (confirmed != true) return; + try { + await widget.repository.createShopOrder( + requestNo: const Uuid().v7(), + productIdentity: product.identity, + addressIdentity: addresses.first.identity, + contactName: profile.name, + contactPhone: profile.phone, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('订单已创建,请前往订单页支付'))); + } + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('安全商城')), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return EmptyState(title: '商品加载失败', description: snapshot.error.toString()); + } + final products = snapshot.data ?? const []; + return ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + const PageIntro(eyebrow: '品质保障', title: '燃气安全商城', description: '价格和库存以服务端结算为准'), + if (products.isEmpty) + const EmptyState(title: '暂无商品', description: '目前没有上架且有库存的商品') + else + ...products.map( + (product) => Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: const Color(0xFFEAF1FF), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.local_fire_department_outlined), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.title, + style: const TextStyle(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 6), + Text(product.subtitle), + ], + ), + ), + IconButton.filled( + onPressed: () => _buy(product), + icon: const Icon(Icons.add_shopping_cart), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ); +} diff --git a/apps/user_app/pubspec.lock b/apps/user_app/pubspec.lock new file mode 100644 index 0000000..479461f --- /dev/null +++ b/apps/user_app/pubspec.lock @@ -0,0 +1,522 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + 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" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.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" + 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" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + 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_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + 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" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" + url: "https://pub.dev" + source: hosted + version: "17.3.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.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: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.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" + 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" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + 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" + 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_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.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: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.38.4" diff --git a/apps/user_app/pubspec.yaml b/apps/user_app/pubspec.yaml new file mode 100644 index 0000000..ab1d753 --- /dev/null +++ b/apps/user_app/pubspec.yaml @@ -0,0 +1,93 @@ +name: user_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + go_router: ^17.3.0 + http: ^1.6.0 + flutter_secure_storage: ^10.3.1 + uuid: ^4.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/apps/user_app/test/domain/client_models_test.dart b/apps/user_app/test/domain/client_models_test.dart new file mode 100644 index 0000000..c2fbb26 --- /dev/null +++ b/apps/user_app/test/domain/client_models_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/client_models.dart'; + +void main() { + group('moneyText', () { + test('formats minor currency units', () { + expect(moneyText(12345), '¥123.45'); + expect(moneyText(0), '¥0.00'); + }); + }); +} diff --git a/apps/user_app/test/ui/login_page_test.dart b/apps/user_app/test/ui/login_page_test.dart new file mode 100644 index 0000000..5a4305b --- /dev/null +++ b/apps/user_app/test/ui/login_page_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/app/dependencies.dart'; +import 'package:user_app/data/services/secure_session_store.dart'; +import 'package:user_app/ui/features/auth/login_page.dart'; + +void main() { + testWidgets('login page exposes phone and password fields', (tester) async { + final session = UserSession(SecureSessionStore()); + await tester.pumpWidget(MaterialApp(home: LoginPage(session: session))); + + expect(find.text('瓶安芯'), findsOneWidget); + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.text('安全登录'), findsOneWidget); + }); +} diff --git a/backend/api/internal/logic/client/staff/auth.go b/backend/api/internal/logic/client/staff/auth.go index 7d58efa..cfbea0a 100644 --- a/backend/api/internal/logic/client/staff/auth.go +++ b/backend/api/internal/logic/client/staff/auth.go @@ -3,6 +3,7 @@ package staff import ( "strings" + "time" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" @@ -63,6 +64,57 @@ func Profile(ctx *gin.Context) { }) } +// Preflight 返回当前单角色账号可由服务端确认的作业前置条件。 +func Preflight(ctx *gin.Context) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return + } + + var credential models.StaffCredential + credentialFound := impl.DBService. + Where("staff_account_id = ? AND status = ?", account.ID, base.StatusEnable). + Order("expired_at desc"). + First(&credential).Error == nil + credentialValid := credentialFound && (credential.ExpiredAt == nil || credential.ExpiredAt.After(time.Now())) + + organizationIdentity, organizationName, organizationType := "", "", "" + if account.DeliveryBasicID != 0 { + var organization models.DeliveryBasic + if impl.DBService.First(&organization, account.DeliveryBasicID).Error == nil { + organizationIdentity, organizationName, organizationType = organization.Identity, organization.Name, "delivery" + } + } else if account.GasBasicID != 0 { + var organization models.GasBasic + if impl.DBService.First(&organization, account.GasBasicID).Error == nil { + organizationIdentity, organizationName, organizationType = organization.Identity, organization.Name, "gas" + } + } + + checks := gin.H{ + "account": gin.H{"status": "passed"}, + "role": gin.H{"status": "passed", "role_code": account.RoleCode}, + "organization": gin.H{"status": checkStatus(organizationIdentity != ""), "identity": organizationIdentity, "name": organizationName, "type": organizationType}, + "credential": gin.H{"status": checkStatus(credentialValid), "expired_at": credential.ExpiredAt}, + "attendance": gin.H{"status": checkStatus(account.WorkStatus == "on_duty"), "work_status": account.WorkStatus}, + "daily_training": gin.H{"status": "not_configured"}, + "service_area": gin.H{"status": "not_configured"}, + "authorized_device": gin.H{"status": "not_configured"}, + } + infra.Response.Success(ctx, gin.H{ + "role_code": account.RoleCode, "work_status": account.WorkStatus, + "can_work": organizationIdentity != "" && credentialValid && account.WorkStatus == "on_duty", + "checks": checks, + }) +} + +func checkStatus(passed bool) string { + if passed { + return "passed" + } + return "blocked" +} + // ChangePassword 修改当前工作人员登录密码。 func ChangePassword(ctx *gin.Context) { account, ok := clientcommon.StaffAccount(ctx) diff --git a/backend/api/internal/logic/client/staff/delivery.go b/backend/api/internal/logic/client/staff/delivery.go new file mode 100644 index 0000000..1ae47ca --- /dev/null +++ b/backend/api/internal/logic/client/staff/delivery.go @@ -0,0 +1,439 @@ +package staff + +import ( + "math" + "strconv" + "strings" + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common" + base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ListDeliveryOrders 返回仅分派给当前配送人员的订单。 +func ListDeliveryOrders(ctx *gin.Context) { + account, ok := requireDeliveryAccount(ctx) + if !ok { + return + } + var orders []models.GasorderBasic + if err := impl.DBService.Where("staff_account_id = ? AND status <> ?", account.ID, base.StatusArchived). + Order("created_at desc").Find(&orders).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, deliveryOrderResponses(orders)) +} + +// GetDeliveryOrder 返回当前配送人员订单详情及气瓶项目。 +func GetDeliveryOrder(ctx *gin.Context) { + account, ok := requireDeliveryAccount(ctx) + if !ok { + return + } + order, ok := requireDeliveryOrder(ctx, account, false) + if !ok { + return + } + var items []models.GasorderItem + if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Order("created_at asc").Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"order": deliveryOrderResponse(order), "items": base.ResourceResponse(items)}) +} + +// StartDeliveryOrder 将已就绪订单置为配送中并创建本次轨迹。 +func StartDeliveryOrder(ctx *gin.Context) { + transitionDeliveryOrder(ctx, base.StatusReady, base.StatusDelivering, true) +} + +// AppendDeliveryTracks 批量补传配送中轨迹点;request_no 保证重复补传不重复落库。 +func AppendDeliveryTracks(ctx *gin.Context) { + account, ok := requireDeliveryAccount(ctx) + if !ok { + return + } + var request struct { + Points []deliveryTrackPointRequest `json:"points" binding:"required,min=1,max=100"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + order, ok := requireDeliveryOrder(ctx, account, true) + if !ok || order.OrderStatus != base.StatusDelivering { + if ok { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + } + return + } + var track models.GasorderTrack + if impl.DBService.Where("gasorder_basic_id = ? AND staff_account_id = ? AND completed_at IS NULL", order.ID, account.ID). + First(&track).Error != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + receivedAt := time.Now() + points := make([]models.GasorderTrackPoint, 0, len(request.Points)) + for _, item := range request.Points { + if !validTrackPoint(item) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + points = append(points, models.GasorderTrackPoint{ + Entity: base.NewEntity(base.StatusEnable), GasorderTrackID: track.ID, RequestNo: item.RequestNo, + Longitude: item.Longitude, Latitude: item.Latitude, OccurredAt: item.OccurredAt, + ReceivedAt: receivedAt, Source: item.Source, Accuracy: item.Accuracy, + Speed: item.Speed, Direction: item.Direction, + }) + } + if err := impl.DBService.Clauses(clause.OnConflict{DoNothing: true}).Create(&points).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"accepted": len(points)}) +} + +// ArriveDeliveryOrder 校验地理围栏并把配送单推进到待签收。 +func ArriveDeliveryOrder(ctx *gin.Context) { + account, ok := requireDeliveryAccount(ctx) + if !ok { + return + } + var request deliveryTrackPointRequest + if ctx.ShouldBindJSON(&request) != nil || !validTrackPoint(request) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + order, ok := requireDeliveryOrder(ctx, account, true) + if !ok { + return + } + distance, valid := coordinateDistanceMeters(order.Longitude, order.Latitude, request.Longitude, request.Latitude) + if order.OrderStatus != base.StatusDelivering || !valid || distance > config.Spec.Global.DeliveryArrivalRadiusMeters { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var track models.GasorderTrack + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("gasorder_basic_id = ? AND staff_account_id = ? AND completed_at IS NULL", order.ID, account.ID). + First(&track).Error; err != nil { + return err + } + now := time.Now() + point := models.GasorderTrackPoint{ + Entity: base.NewEntity(base.StatusEnable), GasorderTrackID: track.ID, RequestNo: request.RequestNo, + Longitude: request.Longitude, Latitude: request.Latitude, OccurredAt: request.OccurredAt, + ReceivedAt: now, Source: request.Source, Accuracy: request.Accuracy, Speed: request.Speed, Direction: request.Direction, + } + if err := tx.Create(&point).Error; err != nil { + return err + } + if err := tx.Model(&track).Update("completed_at", &now).Error; err != nil { + return err + } + result := tx.Model(&models.GasorderBasic{}). + Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, base.StatusDelivering). + Update("order_status", base.StatusAwaitingConfirmation) + if result.Error != nil || result.RowsAffected != 1 { + return gorm.ErrInvalidData + } + return tx.Create(deliveryStatusRecord(order, account, base.StatusDelivering, base.StatusAwaitingConfirmation, "配送到达")).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"order_status": base.StatusAwaitingConfirmation, "distance_meters": math.Round(distance)}) +} + +// ExceptionDeliveryOrder 将配送中或待签收订单置为异常。 +func ExceptionDeliveryOrder(ctx *gin.Context) { + account, ok := requireDeliveryAccount(ctx) + if !ok { + return + } + var request struct { + Reason string `json:"reason" binding:"required,max=1000"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + order, ok := requireDeliveryOrder(ctx, account, true) + if !ok || (order.OrderStatus != base.StatusDelivering && order.OrderStatus != base.StatusAwaitingConfirmation) { + if ok { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + } + return + } + updateDeliveryStatus(ctx, order, account, base.StatusException, request.Reason, gin.H{"previous_order_status": order.OrderStatus}) +} + +// RecoverDeliveryOrder 将本人异常订单恢复到异常前状态。 +func RecoverDeliveryOrder(ctx *gin.Context) { + account, ok := requireDeliveryAccount(ctx) + if !ok { + return + } + var request struct { + Reason string `json:"reason" binding:"required,max=1000"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + order, ok := requireDeliveryOrder(ctx, account, true) + if !ok || order.OrderStatus != base.StatusException || + (order.PreviousOrderStatus != base.StatusDelivering && order.PreviousOrderStatus != base.StatusAwaitingConfirmation) { + if ok { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + } + return + } + target := order.PreviousOrderStatus + updateDeliveryStatus(ctx, order, account, target, request.Reason, gin.H{"previous_order_status": base.StatusDraft}) +} + +// SubmitDeliveryReceipt 保存签收凭证并完成订单,重复 request_no 返回既有结果。 +func SubmitDeliveryReceipt(ctx *gin.Context) { + account, ok := requireDeliveryAccount(ctx) + if !ok { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required"` + ConfirmType string `json:"confirm_type" binding:"required,oneof=signature receipt_code"` + RecipientName string `json:"recipient_name" binding:"required,max=64"` + RecipientPhone string `json:"recipient_phone" binding:"max=32"` + ProofURI string `json:"proof_uri" binding:"required,max=512"` + Remark string `json:"remark" binding:"max=1000"` + } + if ctx.ShouldBindJSON(&request) != nil || !strings.HasPrefix(request.ProofURI, "/uploads/") { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var existing models.GasorderConfirm + if impl.DBService.Where("request_no = ?", request.RequestNo).First(&existing).Error == nil { + infra.Response.Success(ctx, gin.H{"confirmed": true, "identity": existing.Identity, "order_status": base.StatusCompleted}) + return + } + order, ok := requireDeliveryOrder(ctx, account, true) + if !ok || order.OrderStatus != base.StatusAwaitingConfirmation { + if ok { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + } + return + } + confirm := models.GasorderConfirm{ + Entity: base.NewEntity(base.StatusEnable), GasorderBasicID: order.ID, RequestNo: request.RequestNo, + ConfirmType: request.ConfirmType, RecipientName: request.RecipientName, RecipientPhone: request.RecipientPhone, + ProofURI: request.ProofURI, ConfirmedAt: time.Now(), Remark: request.Remark, + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&confirm).Error; err != nil { + return err + } + result := tx.Model(&models.GasorderBasic{}). + Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, base.StatusAwaitingConfirmation). + Update("order_status", base.StatusCompleted) + if result.Error != nil || result.RowsAffected != 1 { + return gorm.ErrInvalidData + } + if err := tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", order.ID).Update("active", false).Error; err != nil { + return err + } + return tx.Create(deliveryStatusRecord(order, account, base.StatusAwaitingConfirmation, base.StatusCompleted, "用户签收")).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"confirmed": true, "identity": confirm.Identity, "order_status": base.StatusCompleted}) +} + +type deliveryTrackPointRequest struct { + RequestNo string `json:"request_no" binding:"required"` + Longitude string `json:"longitude" binding:"required"` + Latitude string `json:"latitude" binding:"required"` + OccurredAt time.Time `json:"occurred_at" binding:"required"` + Source string `json:"source" binding:"required,oneof=gps network manual"` + Accuracy string `json:"accuracy"` + Speed string `json:"speed"` + Direction string `json:"direction"` +} + +func requireDeliveryAccount(ctx *gin.Context) (models.StaffAccount, bool) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return account, false + } + if account.RoleCode != "delivery" { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return account, false + } + return account, true +} + +func requireDeliveryOrder(ctx *gin.Context, account models.StaffAccount, lock bool) (models.GasorderBasic, bool) { + var order models.GasorderBasic + query := impl.DBService + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if query.Where("identity = ? AND staff_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, base.StatusArchived). + First(&order).Error != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return order, false + } + return order, true +} + +func transitionDeliveryOrder(ctx *gin.Context, from, to int, createTrack bool) { + account, ok := requireDeliveryAccount(ctx) + if !ok { + return + } + var request struct { + Reason string `json:"reason" binding:"required,max=1000"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + order, ok := requireDeliveryOrder(ctx, account, true) + if !ok || order.OrderStatus != from { + if ok { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + } + return + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&models.GasorderBasic{}). + Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, from). + Update("order_status", to) + if result.Error != nil || result.RowsAffected != 1 { + return gorm.ErrInvalidData + } + if createTrack { + var attempt int + if err := tx.Model(&models.GasorderTrack{}).Where("gasorder_basic_id = ?", order.ID). + Select("COALESCE(MAX(attempt_no), 0)").Scan(&attempt).Error; err != nil { + return err + } + if err := tx.Create(&models.GasorderTrack{ + Entity: base.NewEntity(base.StatusEnable), GasorderBasicID: order.ID, + StaffAccountID: account.ID, AttemptNo: attempt + 1, StartedAt: time.Now(), + }).Error; err != nil { + return err + } + } + return tx.Create(deliveryStatusRecord(order, account, from, to, request.Reason)).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"order_status": to}) +} + +func updateDeliveryStatus(ctx *gin.Context, order models.GasorderBasic, account models.StaffAccount, target int, reason string, extra gin.H) { + updates := gin.H{"order_status": target} + for key, value := range extra { + updates[key] = value + } + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&models.GasorderBasic{}). + Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, order.OrderStatus). + Updates(updates) + if result.Error != nil || result.RowsAffected != 1 { + return gorm.ErrInvalidData + } + return tx.Create(deliveryStatusRecord(order, account, order.OrderStatus, target, reason)).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"order_status": target}) +} + +func deliveryStatusRecord(order models.GasorderBasic, account models.StaffAccount, from, to int, reason string) models.GasorderStatus { + return models.GasorderStatus{ + Entity: base.NewEntity(base.StatusEnable), GasorderBasicID: order.ID, + FromStatus: from, ToStatus: to, OperatorIdentity: account.Identity, + OperatorName: account.Name, OccurredAt: time.Now(), Reason: strings.TrimSpace(reason), + } +} + +func deliveryOrderResponses(orders []models.GasorderBasic) []gin.H { + responses := make([]gin.H, 0, len(orders)) + for _, order := range orders { + responses = append(responses, deliveryOrderResponse(order)) + } + return responses +} + +func deliveryOrderResponse(order models.GasorderBasic) gin.H { + return gin.H{ + "identity": order.Identity, "order_no": order.OrderNo, "order_status": order.OrderStatus, + "address": order.Address, "longitude": order.Longitude, "latitude": order.Latitude, + "contact_name": order.ContactName, "contact_phone": order.ContactPhone, + "payable_amount": order.PayableAmount, "remark": order.Remark, + "created_at": order.CreatedAt, "updated_at": order.UpdatedAt, + "allowed_actions": deliveryAllowedActions(order.OrderStatus), + } +} + +func deliveryAllowedActions(status int) []string { + switch status { + case base.StatusReady: + return []string{"start"} + case base.StatusDelivering: + return []string{"append_tracks", "arrive", "exception"} + case base.StatusAwaitingConfirmation: + return []string{"submit_receipt", "exception"} + case base.StatusException: + return []string{"recover"} + default: + return []string{} + } +} + +func validTrackPoint(point deliveryTrackPointRequest) bool { + _, longitudeOK := parseCoordinate(point.Longitude, -180, 180) + _, latitudeOK := parseCoordinate(point.Latitude, -90, 90) + return point.RequestNo != "" && longitudeOK && latitudeOK && !point.OccurredAt.IsZero() +} + +func coordinateDistanceMeters(longitudeA, latitudeA, longitudeB, latitudeB string) (float64, bool) { + lonA, okA := parseCoordinate(longitudeA, -180, 180) + latA, okB := parseCoordinate(latitudeA, -90, 90) + lonB, okC := parseCoordinate(longitudeB, -180, 180) + latB, okD := parseCoordinate(latitudeB, -90, 90) + if !(okA && okB && okC && okD) { + return 0, false + } + const earthRadiusMeters = 6371000 + latitudeDelta := (latB - latA) * math.Pi / 180 + longitudeDelta := (lonB - lonA) * math.Pi / 180 + a := math.Sin(latitudeDelta/2)*math.Sin(latitudeDelta/2) + + math.Cos(latA*math.Pi/180)*math.Cos(latB*math.Pi/180)* + math.Sin(longitudeDelta/2)*math.Sin(longitudeDelta/2) + return earthRadiusMeters * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)), true +} + +func parseCoordinate(value string, minimum, maximum float64) (float64, bool) { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + return parsed, err == nil && parsed >= minimum && parsed <= maximum && !math.IsNaN(parsed) && !math.IsInf(parsed, 0) +} diff --git a/backend/api/internal/logic/client/staff/delivery_test.go b/backend/api/internal/logic/client/staff/delivery_test.go new file mode 100644 index 0000000..07b1212 --- /dev/null +++ b/backend/api/internal/logic/client/staff/delivery_test.go @@ -0,0 +1,55 @@ +package staff + +import ( + "testing" + "time" + + base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" +) + +func TestDeliveryAllowedActions(t *testing.T) { + tests := map[int][]string{ + base.StatusReady: {"start"}, + base.StatusDelivering: {"append_tracks", "arrive", "exception"}, + base.StatusAwaitingConfirmation: {"submit_receipt", "exception"}, + base.StatusException: {"recover"}, + } + for status, expected := range tests { + actual := deliveryAllowedActions(status) + if len(actual) != len(expected) { + t.Fatalf("status %d actions = %v, want %v", status, actual, expected) + } + for index := range expected { + if actual[index] != expected[index] { + t.Fatalf("status %d actions = %v, want %v", status, actual, expected) + } + } + } + if actions := deliveryAllowedActions(base.StatusCompleted); len(actions) != 0 { + t.Fatalf("completed order exposed actions: %v", actions) + } +} + +func TestCoordinateDistanceMeters(t *testing.T) { + distance, ok := coordinateDistanceMeters("121.5500", "31.2250", "121.5501", "31.2251") + if !ok || distance <= 0 || distance >= 20 { + t.Fatalf("unexpected nearby distance: %f, valid=%v", distance, ok) + } + if _, ok := coordinateDistanceMeters("invalid", "31.2250", "121.5501", "31.2251"); ok { + t.Fatal("invalid coordinate accepted") + } +} + +func TestValidTrackPoint(t *testing.T) { + point := deliveryTrackPointRequest{ + RequestNo: "track-1", Longitude: "121.55", Latitude: "31.22", + OccurredAt: time.Now(), Source: "gps", + } + if !validTrackPoint(point) { + t.Fatal("valid track point rejected") + } + point.Latitude = "91" + if validTrackPoint(point) { + t.Fatal("out-of-range latitude accepted") + } +} diff --git a/backend/api/internal/logic/client/staff/work.go b/backend/api/internal/logic/client/staff/work.go index 826a88e..e6a7ed6 100644 --- a/backend/api/internal/logic/client/staff/work.go +++ b/backend/api/internal/logic/client/staff/work.go @@ -92,6 +92,21 @@ func ListTickets(ctx *gin.Context) { infra.Response.Success(ctx, base.ResourceResponse(list)) } +// GetTicket 按公开 identity 返回当前工作人员被分派的单一工单。 +func GetTicket(ctx *gin.Context) { + account, ok := clientcommon.StaffAccount(ctx) + if !ok { + return + } + var ticket models.CsTicket + if impl.DBService.Where("identity = ? AND staff_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, base.StatusArchived). + First(&ticket).Error != nil { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Success(ctx, base.ResourceResponse(ticket)) +} + // StartTicket 将本人已分派工单置为处理中。 func StartTicket(ctx *gin.Context) { updateTicketStatus(ctx, 18, 11, nil) diff --git a/backend/api/internal/models/gasorder_confirm.go b/backend/api/internal/models/gasorder_confirm.go index e734ac4..527b8d9 100644 --- a/backend/api/internal/models/gasorder_confirm.go +++ b/backend/api/internal/models/gasorder_confirm.go @@ -9,13 +9,14 @@ import ( // GasorderConfirm 对应 gasorder_confirm,保存用户签收确认。 type GasorderConfirm struct { Entity // 公共实体字段 - GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;uniqueIndex" json:"gasorder_basic_id"` // 订单自增主键 - ConfirmType string `gorm:"column:confirm_type;type:varchar(32);not null" json:"confirm_type"` // 签收类型 - RecipientName string `gorm:"column:recipient_name;type:varchar(64);not null" json:"recipient_name"` // 签收人姓名快照 - RecipientPhone string `gorm:"column:recipient_phone;type:varchar(32);not null;default:''" json:"recipient_phone"` // 签收人手机号快照 - ProofURI string `gorm:"column:proof_uri;type:varchar(512);not null;default:''" json:"proof_uri"` // 签名或凭证地址 - ConfirmedAt time.Time `gorm:"column:confirmed_at;type:timestamptz;not null;index" json:"confirmed_at"` // 确认时间 - Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 签收备注 + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;uniqueIndex" json:"gasorder_basic_id"` // 订单自增主键 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;default:'';uniqueIndex:,where:request_no <> ''" json:"request_no"` // 客户端提交幂等号 + ConfirmType string `gorm:"column:confirm_type;type:varchar(32);not null" json:"confirm_type"` // 签收类型 + RecipientName string `gorm:"column:recipient_name;type:varchar(64);not null" json:"recipient_name"` // 签收人姓名快照 + RecipientPhone string `gorm:"column:recipient_phone;type:varchar(32);not null;default:''" json:"recipient_phone"` // 签收人手机号快照 + ProofURI string `gorm:"column:proof_uri;type:varchar(512);not null;default:''" json:"proof_uri"` // 签名或凭证地址 + ConfirmedAt time.Time `gorm:"column:confirmed_at;type:timestamptz;not null;index" json:"confirmed_at"` // 确认时间 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 签收备注 } func init() { database.AppendMigrate(&GasorderConfirm{}) } diff --git a/backend/api/internal/routers/client.go b/backend/api/internal/routers/client.go index 870f7ce..2948257 100644 --- a/backend/api/internal/routers/client.go +++ b/backend/api/internal/routers/client.go @@ -62,13 +62,23 @@ func registerStaffClient(serviceKey string, engine *gin.Engine) { protected := engine.Group(basePath) protected.Use(sdkmiddleware.JwtAuth(true), clientcommon.RequireClient("service_app")) protected.GET("/auth/profile", stafflogic.Profile) + protected.GET("/preflight", stafflogic.Preflight) protected.PUT("/auth/password", stafflogic.ChangePassword) protected.POST("/attendance", stafflogic.Attendance) protected.GET("/tickets", stafflogic.ListTickets) + protected.GET("/tickets/:identity", stafflogic.GetTicket) protected.POST("/tickets/:identity/start", stafflogic.StartTicket) protected.POST("/tickets/:identity/exception", stafflogic.ExceptionTicket) protected.POST("/tickets/:identity/recover", stafflogic.RecoverTicket) protected.POST("/tickets/:identity/submit-result", stafflogic.SubmitTicketResult) + protected.GET("/delivery/orders", stafflogic.ListDeliveryOrders) + protected.GET("/delivery/orders/:identity", stafflogic.GetDeliveryOrder) + protected.POST("/delivery/orders/:identity/start", stafflogic.StartDeliveryOrder) + protected.POST("/delivery/orders/:identity/tracks", stafflogic.AppendDeliveryTracks) + protected.POST("/delivery/orders/:identity/arrive", stafflogic.ArriveDeliveryOrder) + protected.POST("/delivery/orders/:identity/exception", stafflogic.ExceptionDeliveryOrder) + protected.POST("/delivery/orders/:identity/recover", stafflogic.RecoverDeliveryOrder) + protected.POST("/delivery/orders/:identity/submit-receipt", stafflogic.SubmitDeliveryReceipt) registerClientWalletRoutes(protected, "service_app") } diff --git a/backend/api/internal/routers/client_test.go b/backend/api/internal/routers/client_test.go index 0f52a89..ac79bbe 100644 --- a/backend/api/internal/routers/client_test.go +++ b/backend/api/internal/routers/client_test.go @@ -12,13 +12,20 @@ func TestRegisterClientRoutes(t *testing.T) { RegisterClient("heqi", engine) expected := map[string]bool{ - "POST /heqi/client/v1/user/auth/register": false, - "POST /heqi/client/v1/user/auth/login": false, - "POST /heqi/client/v1/user/wallet/recharges": false, - "POST /heqi/client/v1/user/shop/orders/:identity/pay": false, - "POST /heqi/client/v1/staff/auth/login": false, - "POST /heqi/client/v1/staff/attendance": false, - "POST /heqi/client/v1/staff/tickets/:identity/submit-result": false, + "POST /heqi/client/v1/user/auth/register": false, + "POST /heqi/client/v1/user/auth/login": false, + "POST /heqi/client/v1/user/wallet/recharges": false, + "POST /heqi/client/v1/user/shop/orders/:identity/pay": false, + "POST /heqi/client/v1/staff/auth/login": false, + "GET /heqi/client/v1/staff/preflight": false, + "POST /heqi/client/v1/staff/attendance": false, + "GET /heqi/client/v1/staff/tickets/:identity": false, + "POST /heqi/client/v1/staff/tickets/:identity/submit-result": false, + "GET /heqi/client/v1/staff/delivery/orders": false, + "POST /heqi/client/v1/staff/delivery/orders/:identity/start": false, + "POST /heqi/client/v1/staff/delivery/orders/:identity/tracks": false, + "POST /heqi/client/v1/staff/delivery/orders/:identity/arrive": false, + "POST /heqi/client/v1/staff/delivery/orders/:identity/submit-receipt": false, } for _, route := range engine.Routes() { key := route.Method + " " + route.Path diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go index 59f27ee..9c0a050 100644 --- a/backend/api/internal/seed/mock.go +++ b/backend/api/internal/seed/mock.go @@ -65,7 +65,7 @@ func MockData(database *gorm.DB) error { staff := models.StaffAccount{ Entity: entity(5, common.StatusEnable), Username: "mock_driver", PasswordHash: string(passwordHash), - Name: "王师傅", Phone: "13900000001", RoleCode: "driver", + Name: "王师傅", Phone: "13900000001", RoleCode: "delivery", GasBasicID: gas.ID, DeliveryBasicID: delivery.ID, WorkStatus: "on_duty", } if err := put(tx, &staff); err != nil { diff --git a/docs/03-用户端App需求.md b/docs/03-用户端App需求.md index f413f5e..061de32 100644 --- a/docs/03-用户端App需求.md +++ b/docs/03-用户端App需求.md @@ -2,7 +2,7 @@ ## 1. 产品入口与导航 -Flutter App 使用底部导航:智能瓶阀控制、商城、收藏、订单、我的。消息中心作为“我的记录”和通知入口提供,不单独占用底部导航。未登录用户可浏览受限内容;涉及设备、订单、钱包、押金和地址时必须完成登录。 +首期 Flutter App 使用“首页、商城、订单、我的”四栏底部导航:首页承载安全内容、公告和服务归属,避免把尚无 Client API 的设备控制与收藏伪装成可用主入口。智能瓶阀控制和收藏在对应服务端契约落地后再进入导航。未登录用户可浏览公开内容与商品;涉及订单、钱包、合同、工单和地址时必须完成登录。 产品设计稿的默认登录页使用“手机号 + 验证码”方式,支持记住登录状态和忘记密码入口;用户名密码登录可作为兼容能力保留。验证码登录应具备频控、图形/行为校验和设备风控;登录前必须展示用户协议和隐私政策,并记录用户同意的协议版本。 @@ -98,3 +98,80 @@ Flutter App 使用底部导航:智能瓶阀控制、商城、收藏、订单 - 充值先创建待支付订单;仅开发配置允许 Mock 支付确认,确认后才写余额及不可变流水。微信和支付宝未配置渠道时必须明确返回不可用,不得模拟成功。 - 商城订单交易状态与物流状态分离;物流单号、公司、发货和收货时间由服务端保存,用户只能查看本人订单并确认收货。 - 首期不伪造设备控制、安全事件、押金、消息、发票、收藏、紧急联系人、账户注销和完整售后能力;文档中这些能力保留为后续迭代,不得以静态成功响应冒充已实现。 + +## 7. Flutter 开发说明 + +### 7.1 平台、工程与原型边界 + +- 用户端只交付 Android、iOS,不建设 Flutter Web、桌面端或小程序兼容层。平台差异通过适配器隔离,不在业务页面散落 `Platform.isAndroid`、`Platform.isIOS` 判断。 +- 生产工程按规划放在 `apps/user_app`;当前 `ui` 目录是基于产品设计图制作的交互原型,仅用于视觉、信息架构和流程确认,不得把其中的演示数据或模拟成功状态当作业务实现。 +- Flutter 与 Dart 版本由工程根目录的版本管理文件和 CI 固定;升级 SDK、Gradle、Kotlin、Xcode、CocoaPods 或插件时必须单独验证 Android/iOS 构建、权限和深链。 +- 应用令牌的 client claim 固定为 `user_app`,API 根路径固定为 `/heqi/client/v1/user`;不得复用 `service_app` 或任何管理后台会话。 + +### 7.2 分层结构与依赖方向 + +采用“按功能组织 UI、按类型组织 Data/Domain”的 MVVM + Repository 结构: + +```text +apps/user_app/lib/ + app/ + app.dart # MaterialApp.router、主题、语言 + router.dart # go_router、鉴权与协议确认守卫 + dependencies.dart # Service/Repository/ViewModel 装配 + data/ + models/ # API DTO,不直接进入 Widget + services/ # HTTP、扫码、蓝牙、推送、受控存储适配 + repositories/ # 缓存、重试、DTO 到领域模型转换 + domain/ + models/ # 不可变领域模型 + use_cases/ # 控阀、下单、余额支付等复杂规则编排 + ui/ + core/ # 主题、字体、间距、通用状态与组件 + features/ + auth/ + home/ + shop/ + order/ + wallet/ + profile/ +``` + +- View 只负责渲染、动画、无障碍语义和导航,不直接发 HTTP、写缓存或决定业务状态。 +- ViewModel 暴露不可变 UI state 和明确命令;Repository 是远端与本地数据的单一事实入口;跨 Repository 或高风险流程才抽取 Use Case。 +- Service 必须无业务状态,负责封装 HTTP、扫码、蓝牙、推送、相机和安全存储等外部边界;平台插件通过接口注入,便于 Android/iOS 替换与测试。 +- DTO、领域模型、UI state 分离。HTTP、日志、深链和 Flutter 页面统一使用 `identity`,不得暴露或接受数据库自增 `id`。 + +### 7.3 路由与导航 + +- 使用 `MaterialApp.router` 与 `go_router`。首期底部四栏使用 `StatefulShellRoute.indexedStack` 保持各分支的滚动位置和页面栈: + - `/home`:安全内容、公告与当前服务归属 + - `/shop`、`/shop/products/:identity`、`/cart`、`/checkout` + - `/orders`、`/orders/:identity`、`/orders/:identity/delivery` + - `/me`、`/me/wallet`、`/me/records`、`/me/settings` +- `/valves` 与 `/favorites` 属于后续路由;对应 Client API 未落地前不得注册可操作页面或用 Mock 数据占据主导航。 +- 登录、协议版本确认和首次安全宣导使用根级守卫;涉及设备、订单、钱包、地址的页面必须在 redirect 中校验会话,不能依靠按钮隐藏。 +- 邀请二维码、订单通知、支付结果和安全通知使用白名单深链。Android App Links 与 iOS Universal Links 均须校验域名归属;深链参数只接受 `identity` 和短期签名上下文。 +- 高风险开阀被拦截时导航到可解释的限制页面或安全事件详情,不允许通过返回栈、群控入口或手工深链绕过。 + +### 7.4 状态、请求与错误处理 + +- 所有异步页面统一使用 `initial/loading/content/empty/error/refreshing` 状态;写操作另有 `submitting/succeeded/failed/conflict`,禁止用一个全局 `isLoading` 遮蔽不同请求。 +- 设备命令 UI 至少表示待发送、已发送、设备已确认、执行失败、超时待确认、已撤销。创建命令成功后按命令 `identity` 查询回执;超时只能显示“待确认”,不能回退为“已成功”。 +- 写请求生成并持久化幂等键;重试复用原键。余额支付、充值、提现、订单提交、报修和设备命令均不得因页面重建或网络重连重复创建事实。 +- ViewModel 根据稳定错误码映射可操作文案与恢复入口;不得解析后端错误文案驱动流程。401/403、状态冲突、限流、外部依赖不可用和未知错误分别处理。 +- 金额以最小货币单位整数进入领域层,只在格式化组件中转换为展示文本;时间统一解析为 UTC 并按用户时区展示,同时保留数据更新时间。 + +### 7.5 本地数据、安全与平台能力 + +- 访问令牌、刷新令牌和支付相关临时凭据只进入 Android Keystore / iOS Keychain 支持的安全存储;日志、崩溃报告、埋点和剪贴板不得记录令牌、支付密码、完整手机号、地址或定位。 +- 普通缓存只保存可恢复数据并设置版本与过期时间。安全事件、资金、订单和设备命令的服务端事实不能由本地缓存覆盖;退出登录时按数据分类清理。 +- Android/iOS 的相机、相册、蓝牙、定位、通知权限均采用使用时申请和拒绝后降级。定位失败提供手动地址入口;蓝牙失败提供扫码或手动设备码入口。 +- 推送点击必须先恢复会话并重新向服务端读取对象状态;通知载荷不得包含完整地址、手机号、支付信息或可直接执行设备控制的凭证。 +- 屏幕截图、应用切后台和最近任务缩略图对支付密码、银行卡、证件等页面按风险实施遮挡;是否禁止安全事件页面截图由合规评审决定。 + +### 7.6 视觉、无障碍与测试 + +- 设计基线以 `doc/用户端APP-产品设计` 和 `ui/?app=user` 为准:安全蓝为主色,瓶阀状态、告警与命令回执优先于营销内容。危险、警告、成功不能只靠颜色表达。 +- 使用统一 ThemeExtension 管理颜色、圆角、间距、阴影和状态色;正文最小字号、动态字体缩放、44×44 logical pixels 触控目标、屏幕阅读器语义和对比度必须在 Android/iOS 真机验证。 +- ViewModel、Use Case、Repository 覆盖单元测试;瓶阀状态、支付、登录守卫和错误恢复覆盖 Widget 测试;扫码绑定、开关阀回执、下单支付、订单轨迹覆盖集成测试。 +- 每次合并至少执行 `flutter analyze`、`flutter test`、Android debug 构建和 iOS Simulator 构建;涉及相机、蓝牙、推送、支付、Universal Links/App Links 的改动还须执行对应真机回归。 diff --git a/docs/04-服务端App需求.md b/docs/04-服务端App需求.md index b8e4aa7..e34635f 100644 --- a/docs/04-服务端App需求.md +++ b/docs/04-服务端App需求.md @@ -2,7 +2,7 @@ ## 1. 定位与角色模型 -服务端 App 是安装维修员、安检员和配送员共用的 Flutter 移动应用。“服务端”在本文指服务人员端,并非后端服务。用户登录后由平台分配角色、资质、所属可燃气体站/配送点和服务区域;具有多个角色时可切换工作台,但每次操作都带角色和组织上下文。 +服务端 App 是安装维修员、安检员和配送员共用的 Flutter 移动应用。“服务端”在本文指服务人员端,并非后端服务。首期采用“一账号一角色”:用户登录后由平台返回唯一岗位、资质及所属气站/配送点,客户端不得自行切换或拼装角色上下文。 设计稿要求不同角色使用同一登录与账户体系,但加载不同的底部导航和工作台:配送员使用“订单、任务、用户、我的”,安装维修员使用“工单、巡检、记录、我的”,安检员使用“任务、记录、隐患、我的”。导航、数据和接口均按当前角色、组织、服务区域与资质过滤。 @@ -118,8 +118,107 @@ ## 8. 首期 Client API 落地边界(2026-07) - 工作人员 API 固定为 `/heqi/client/v1/staff`,令牌客户端为 `service_app`;不提供注册,只允许后台已创建、启用且岗位受支持的账户登录。 +- 三类岗位均属于同一 Staff Client API:安装维修和安检使用 `/tickets` 工单接口;配送使用 `/delivery/orders`、开始配送、轨迹批量补传、到达围栏校验、异常/恢复和提交签收接口。所有查询和动作只允许访问当前账号被分派的对象。 +- 登录后调用 `/preflight`。首期真实校验账号、唯一岗位、所属组织、资质有效期和上班状态;每日培训、服务区域与授权设备尚未配置时返回 `not_configured`,客户端必须明确展示,不能显示为“已通过”。 - 首期岗位为配送、安装维修、安检。安装/维修工单只分派给安装维修人员,安检/复检只分派给安检人员,客服类工单不进入工作人员 App。 - 工单统一复用 `cs_ticket`,状态为待分派、已分派、处理中、异常、待用户确认、已完成或已取消。工作人员只能操作分派给本人的工单;现场结果必须包含定位、原始采集时间、上传资源地址和幂等号。 - 安装和维修至少提交前、中、后图片及用户签名;安检和复检至少提交一张图片、结果及用户签名。单次最多六张图片、三段视频。不合规或高风险结论只能进入异常,不能提交待用户确认。 - 配送人员只操作分派给本人的供气配送订单,可开始配送、批量补传轨迹、到达校验、异常/恢复和提交签收;不能修改订单金额。到达以订单地址坐标和配置地理围栏为准。 - 工作人员钱包与用户钱包复用统一模型,支持余额、充值订单、不可变流水、提现及银行卡;服务收入只能由已完成业务事实产生,客户端不能直接增加余额。 + +## 9. Flutter 开发说明 + +### 9.1 平台、角色与工程边界 + +- 服务人员端只交付 Android、iOS。生产工程按规划放在 `apps/service_app`;当前 `ui` 目录是配送、安装维修、安检三类原型的交互实现,不连接真实定位、相机、蓝牙、支付或业务 API。 +- 三类岗位共用一个 Flutter 应用和登录体系,每个账号仅有一个服务岗位,令牌 client claim 固定为 `service_app` 并携带唯一 `role_code`。岗位变更必须由后台完成并重新登录换取令牌;客户端不提供角色切换。 +- 工作人员 API 根路径固定为 `/heqi/client/v1/staff`。客户端不得访问用户端、平台后台、气站后台或配送点后台的令牌与接口。 +- 首期后端不提供工作人员自助注册时,Flutter 注册页面不得伪造成功;若未来开放申请,只能创建待审核账户,不能直接授予岗位能力。 + +### 9.2 分层结构与角色化功能 + +采用 MVVM + Repository,UI、业务编排和数据边界严格分离: + +```text +apps/service_app/lib/ + app/ + app.dart + router.dart # go_router、岗位与作业前置守卫 + role_context.dart # 当前唯一岗位、组织和服务区域 + dependencies.dart + data/ + models/ + services/ # API、定位、相机、蓝牙、上传、安全存储 + repositories/ # 任务、取证、轨迹、钱包、离线队列 + offline/ # 加密草稿、Outbox、冲突与补传 + domain/ + models/ + use_cases/ # 到场、扫描、安检、安装、完成任务 + ui/ + core/ # 角色主题、通用状态、取证组件 + features/ + auth/ + workbench/ + delivery/ + installation/ + inspection/ + evidence/ + hazard/ + records/ + wallet/ + profile/ +``` + +- 共用任务卡、步骤器、相机、签名、弱网提示等展示组件;配送、安装维修、安检分别拥有独立 ViewModel、Use Case、检查表解释器和状态机,不得用一个万能表单加前端条件判断代替领域规则。 +- View 不直接更新任务状态、金额、风险等级或本地数据库。ViewModel 只发起命令并呈现服务端返回状态;Repository 负责 API、本地加密草稿、上传队列和领域模型转换。 +- 检查表、强制取证项、材料清单和步骤顺序由服务端版本化下发。客户端缓存模板版本,提交时带模板 `identity` 和版本;过期模板必须进入冲突处理,不能静默套用新模板。 +- 所有任务、证据、轨迹、人员、组织和钱包引用只使用 `identity`;数据库内部自增 ID 不进入 Flutter 模型、日志、深链或离线队列。 + +### 9.3 路由、底部导航与守卫 + +- 使用 `MaterialApp.router` 与 `go_router`。登录后先进入 `/preflight`,统一校验账户启用、岗位、组织、资质、每日培训、上班状态、服务区域和授权设备,再进入角色工作台。 +- 首期角色底部导航使用 `StatefulShellRoute.indexedStack`,只暴露有真实 Client API 的入口: + - 配送员:`/work`(本人配送订单)、`/records`(已完成记录)、`/me` + - 安装维修员与安检员:`/work`(本人工单)、`/records`(已完成记录)、`/me` +- 任务详情统一使用 `/tasks/:identity`,具体步骤使用 `/tasks/:identity/steps/:stepCode`;路由解析后仍须从服务端读取任务类型、当前状态和允许动作,不能信任路径中的角色或步骤。 +- 未完成培训、未上班、资质失效、超出服务区或任务未分派给本人时,守卫导航到明确的阻断页;返回栈、通知深链和手工 URL 均不能绕过。 +- 普通退出时若存在未提交草稿,必须先返回上传或二次确认放弃并安全删除;令牌失效或异常退出时按账号加密封存,只有同一账号重新认证后可恢复,换账号不可见。 + +### 9.4 任务状态与现场作业 + +- 每类任务由独立状态模型驱动,UI 只展示服务端返回的 `allowed_actions`。按钮禁用、步骤条位置和本地完成标记不构成业务校验。 +- 配送流程至少实现订单确认、导航到场、围栏校验、气瓶扫描、随瓶安检、收款确认、签收/回收;安装流程按使用条件、备料、安装、测试、前期安检、用户确认、收款执行;安检流程按详情、执行、检查、风险等级、签名、隐患/复检执行。 +- 不合格、高风险、测试失败、围栏异常、金额差异、证据缺失和设备回执不确定时只能进入异常、待整改或待确认,不能由客户端跳到已完成。 +- 关阀、设备激活、任务完结、收款和提现均显示服务端确认状态。请求成功、文件进入上传队列或本地步骤完成不得显示为最终成功。 +- 检查项使用稳定代码;照片类型、材料、风险等级和错误原因由契约映射,禁止根据中文标题或错误文案驱动状态流转。 + +### 9.5 离线队列、定位与证据 + +- 离线能力只覆盖已分配任务的只读信息和现场草稿。关阀、激活、资金、最终完结等动作必须在线确认;离线时明确告知“已暂存/待补传”,不能显示“已完成”。 +- 本地使用平台安全存储保护数据库密钥,任务草稿、轨迹点、照片/视频元数据、签名、扫描结果和收款确认采用加密数据库或加密文件。退出角色或账户时按服务端留存策略处理,不能只删索引留下明文文件。 +- 每个离线写入包含本地操作 `identity`、业务对象 `identity`、幂等键、原始采集时间、来源、完整性标记和内容哈希。补传保留原始时间,按依赖顺序投递,409/版本冲突进入人工可见的冲突队列。 +- 文件先落加密暂存区并记录哈希,获得短期上传授权后上传;业务提交只引用上传成功的资源 URI。失败重试不得重复创建证据,后台删除或拒绝的附件不得被本地队列重新“复活”。 +- 配送定位仅在履约期间、满足权限与任务状态时采集。Android 前台服务和 iOS 后台定位必须显示系统要求的可见提示;权限撤销、精度不足、后台受限和长时间无点位均写入任务状态。 +- 用户签名画布保存矢量笔画或可验证位图及哈希,并与结论、任务、操作者和采集时间绑定;禁止复用其他任务签名。 + +### 9.6 Android/iOS 平台适配 + +| 能力 | Android | iOS | +| --- | --- | --- | +| 相机/相册 | 运行时按用途申请 Camera/Photo Picker,使用系统选择器优先 | 使用相机和 Photos 限定访问,解释用途并处理 Limited 状态 | +| 蓝牙扫描 | 按系统版本申请 Nearby Devices/Bluetooth 权限 | 配置 Bluetooth 用途说明,仅在任务步骤内扫描 | +| 定位 | 前台精确定位;配送后台轨迹使用合规前台服务与常驻通知 | 先申请 When In Use,确需配送后台定位时再升级并配置 Background Modes | +| 通知 | 创建安全、任务、上传三类通知渠道,高风险安全通知不可静默关闭 | 分类注册通知操作,点击后重新读取任务和权限 | +| 文件暂存 | App 私有目录、加密数据库,禁止写公共目录 | Application Support/Library 私有目录,排除不必要云备份 | +| 深链 | App Links 校验域名与签名证书 | Universal Links 配置 Associated Domains | + +- 权限说明必须与实际采集行为一致。拒绝权限时给出可恢复路径;不得因相机或精确定位权限失败伪造照片、地址或到场结果。 +- 地图、导航、蓝牙、相机和签名通过抽象 Service 注入;Android/iOS 实现返回统一领域结果和稳定错误码。 + +### 9.7 视觉、性能与测试 + +- 设计基线以 `doc/服务端APP-配送端-产品设计`、`doc/服务端APP-安装端-产品设计`、`doc/服务端APP-安全检查端-产品设计` 和 `ui/?app=service&role=...` 为准。统一使用紫色作业框架,安检关键成功动作可使用安全绿色;风险等级必须同时展示文字、图标和颜色。 +- 任务详情优先展示任务号、类型、预约/SLA、地址、脱敏联系人、风险与允许动作。固定底部操作按钮不得遮挡检查项、签名或系统安全区。 +- 长清单使用惰性列表和分段保存;照片视频缩略图解码、压缩和上传移出 UI isolate。后台轨迹、上传和补传必须受电量、网络与系统调度约束,不用常驻无限循环。 +- ViewModel、Use Case、Repository、离线队列和冲突处理覆盖单元测试;角色/组织守卫、步骤前置、风险结论和弱网状态覆盖 Widget 测试;三角色主闭环覆盖 Android/iOS 集成测试。 +- 每次合并至少执行 `flutter analyze`、`flutter test`、Android debug 构建和 iOS Simulator 构建;定位、相机、蓝牙、推送、后台任务、App Links/Universal Links 和安全存储改动必须真机回归。 diff --git a/docs/10-技术实现规划.md b/docs/10-技术实现规划.md index 594f480..0d5aa1c 100644 --- a/docs/10-技术实现规划.md +++ b/docs/10-技术实现规划.md @@ -13,8 +13,8 @@ | 层级 | 推荐技术 | 用途 | | --- | --- | --- | -| 用户端 App | Flutter | 用户设备、安全、商城、订单、钱包、消息和个人中心 | -| 服务端 App | Flutter | 安装维修、安检、配送三类工作台;通过角色与能力包控制模块 | +| 用户端 App | Flutter 3 / Dart 3 | 首期首页内容、服务归属、商城、订单、合同、工单、钱包、地址和个人中心 | +| 服务端 App | Flutter 3 / Dart 3 | 配送、安装维修、安检三类单角色账号工作台、现场取证与受控离线草稿 | | 平台总后台 | Vue 3 + TypeScript | 全局治理、运营、财务、安全、审计等高密度管理页面 | | 可燃气体站管理系统 | Vue 3 + TypeScript | 站点商品、订单、服务、库存与经营管理 | | 配送点管理系统 | Vue 3 + TypeScript | 调度、配送仓、路线、人员和配送运营工作台 | @@ -72,6 +72,13 @@ flowchart LR - 已分配任务、轨迹点、照片/视频元数据、签名、扫描结果和收款确认可在弱网下加密暂存。补传必须携带原始采集时间、服务端接收时间、任务 `identity`、操作者 `identity`、来源、完整性标记与幂等键,禁止以补传时间覆盖采集时间。 - 后端负责乱序校正、重复去除、异常速度/精度标记、证据哈希与状态机校验;前端离线缓存、按钮禁用或页面显示不能替代服务端权限、金额、地理围栏和完成条件校验。 +### 4.2 Flutter 工程落地基线 + +- 生产工程已落在 `apps/user_app` 与 `apps/service_app`,只生成 Android、iOS 平台目录;`ui` 继续作为视觉和交互原型,不作为运行时依赖。 +- 两个 App 使用 `MaterialApp.router`、`go_router`、MVVM + Repository 与注入的平台 Service。HTTP 根地址通过 `--dart-define=API_BASE_URL=...` 注入;用户端和工作人员端分别固定访问 `/heqi/client/v1/user` 与 `/heqi/client/v1/staff`,JWT 请求头沿用当前服务端原始令牌契约。 +- 访问令牌保存在 Android Keystore / iOS Keychain 支持的安全存储。服务端 App 的现场草稿和附件按账号使用 AES-GCM 加密;恢复网络后才上传并执行最终业务提交。 +- 充值 Mock 确认只允许 Debug/开发联调,Release UI 不注册该入口;未落地的设备控制、收藏、押金、消息和发票能力不得以静态成功状态替代。 + ## 5. 研发目录规划(建议) ```text @@ -105,7 +112,7 @@ platforms/ performance/ # 遥测、订单、轨迹与消息积压压测 ``` -该结构是后续开发建议,不代表本次创建了任何代码目录或代码文件。 +其中 `apps/user_app`、`apps/service_app`、`backend/{api,worker,iot}` 与当前管理端目录已经落地;其余标记为规划的目录仍不得因局部任务提前创建空壳。 ## 6. 后端领域划分 @@ -143,8 +150,8 @@ platforms/ | 开放接口 | `api_` | `api_product`、`api_client`、`api_subscription` | | 平台任务 | `sys_` | `sys_outbox_event`、`sys_dead_letter_event` | -- 所有主表必须包含 `identity` 字段,类型为 UUID V7,并作为该表的主键。UUID V7 由应用服务生成,保证时间有序性;禁止使用数据库自增主键、随机 UUID V4 或将业务编号作为主键。 -- 引用主表时,外键字段命名为 `<实体名>_identity`,例如 `order_identity`、`service_person_identity`。业务展示编号(订单号、设备编码、站点编码等)应使用独立字段并设置唯一约束,不能替代 `identity`。 +- 每张表必须包含数据库内部使用的 `id bigint` 自增主键;主表还必须包含由应用生成、带唯一索引的 UUID V7 `identity varchar(36)`。HTTP、消息、审计日志、Flutter/Vue 模型和跨服务引用只使用 `identity`,不得暴露或接受内部 `id`。 +- 数据库内部关联优先使用 `<实体词根>_id` 指向自增主键;跨服务契约、异步事件和审计关联使用 `<实体词根>_identity`。业务展示编号另设唯一字段,不能替代 `id` 或 `identity`。 - 每个主表还应按需要包含 `created_at`、`updated_at`、`created_by_identity`、`updated_by_identity`、`status`、`version` 等审计/并发字段;资金流水、安全事件、审计日志等不可变记录不得被物理删除。 - 数据库表、字段、索引、约束和枚举必须编写中文注释;注释说明业务含义、取值/单位、脱敏或留存要求。模型注释与接口契约必须同步维护,禁止只在设计文档中说明。 diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..7c0264b --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ +.design-qa/ diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..1223b5c --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1,2 @@ +fund=false +audit=false diff --git a/ui/.openai/hosting.json b/ui/.openai/hosting.json new file mode 100644 index 0000000..47c28cb --- /dev/null +++ b/ui/.openai/hosting.json @@ -0,0 +1,4 @@ +{ + "d1": null, + "r2": null +} diff --git a/ui/AGENTS.md b/ui/AGENTS.md new file mode 100644 index 0000000..1c335ed --- /dev/null +++ b/ui/AGENTS.md @@ -0,0 +1,71 @@ +# Mobile Prototype Agent Guide + +## Prototype Instructions + +In ChatGPT Work Mode, run `sites-preview start "$PWD"`, open `http://terminal.local:4173/` in the cloud browser, and verify the rendered app and its primary interactions. Keep that preview open and tell the user to inspect it in the cloud browser; do not present the local URL as a user-facing chat link. In Codex Desktop, run the local server yourself, open the preview in the in-app browser, and provide the clickable local URL. Do not deploy to Sites unless the user explicitly asks to share, publish, or deploy. Do not give the user server-start instructions when you can run it. + +Before planning or implementing any mobile-app change, read this `AGENTS.md` in full. It is the source of truth for the template's runtime and component guidance. + +Before making substantial visual changes, use the Product Design plugin's `get-context` skill when the visual source is unclear or no longer matches the current goal. When the user gives durable prototype-specific design feedback, preferences, or decisions, record them in `AGENTS.md`. + +When implementing from a selected generated mock, treat that image as the source of truth for layout, component anatomy, density, spacing, color, typography, visible content, and hierarchy. + +## Editing Boundary + +- Build app-specific UI in `src/Prototype.tsx` and `src/prototype.css`. +- Treat `src/App.tsx`, `src/main.tsx`, `src/styles.css`, `src/mobile/`, `public/assets/iphone/`, `public/assets/android/`, `public/assets/status/`, `vite.config.ts`, `worker/index.js`, and `scripts/prepare-sites-build.mjs` as protected runtime files. Do not edit, replace, remove, or recreate them unless the user explicitly asks to change the mobile runtime itself. For an explicit runtime change, update the affected lock hashes only after verifying the new runtime behavior. +- Run `npm run check:runtime` before preview or handoff. If it fails, restore the protected runtime instead of weakening or bypassing the check. +- `npm run build` preserves the mobile runtime and prepares the static Cloudflare Worker output required by Sites. Before a Sites handoff, confirm `dist/client/index.html`, `dist/server/index.js`, `dist/.openai/hosting.json`, and source `.openai/hosting.json` exist, then run `npm run test:sites`. Do not replace this project with a Vinext starter. + +## Runtime Contract + +- Preserve the mobile device runtime unless the user's task explicitly asks otherwise. Do not replace it with a standalone page. Visual fidelity applies to app-owned content inside the device screen, not to template-owned device chrome. +- Keep `App` composed around `PhoneFrame` -> `KeyboardProvider`, with `StatusBar`, app content, `HomeIndicator`, and `KeyboardDock` mounted inside the phone frame. `StatusBar` and the iOS home indicator are overlaid device chrome. When the Android keyboard is closed, the app viewport reserves the protected navigation-bar region instead of painting behind it. When the Android keyboard is open, preserve the current full-screen keyboard layout: its asset includes the IME navigation strip and the separate black navigation bar is hidden. iOS screens continue to paint behind the home-indicator area and own their safe-area content padding. +- Preserve the `iPhone` / `Pixel 10` device picker and both calibrated device presets. The Pixel screen is `427 x 952`; its `32 x 32` camera circle and `public/assets/android/navigation-bar.svg` bottom navigation bar are protected device chrome, not app content. +- Preserve the device picker's intentionally lightweight Codex styling in the top-right corner: its trigger wrapper is borderless and transparent, its trigger sizes to content, and its right-aligned menu uses the compact 3px inset plus the specified hairline and elevation shadow layers. Keep the prototype root and default app screen white. +- Preserve `StatusBar` as live device chrome, including its platform-specific typography, source status-icon assets, and spacing. Pixel 10 uses Roboto, Android indicators, and 32px top, left, and right padding. iPhone uses its iOS indicators, system typography, and calibrated spacing. Do not hardcode screenshot times like `9:41` into the status bar, replace its real-time clock, or move status bar content into app markup unless the user explicitly asks for a fixed/mock device time. +- `PhoneFrame` owns the calibrated device frame, screen portal, device picker, camera cutout, and custom cursor. Keep device assets in `public/assets/iphone/` and `public/assets/android/`; if an asset fails to load, repair the asset path or restore the asset instead of removing the frame, keyboard, or image render. +- Use `MobileScroll` directly for simple single-screen prototypes. Use `FlowStack` for conventional multi-screen flows whose routes can own their fixed header and footer; when using it, define each route as a `FlowScreen`: `{ id, header?, headerHeight?, footer?, footerHeight?, render }`, and use `flow.push(screen)`, `flow.pop()`, and `flow.replace(screen)` from `FlowStack` render callbacks or `useFlow()` instead of introducing another router. +- Use `Carousel` for a carousel, horizontal rail, swipeable cards, image or media strip, horizontally scrollable cards, chip rail, or other horizontal collection. +- For a layered app shell—such as a persistent composer, independently presented sheet, pushed/peek sidebar, or app-wide transition—compose directly in `Prototype.tsx` rather than forcing it through `FlowStack`. Keep app-owned fixed chrome as sibling layers outside `MobileScroll`. +- When using `FlowScreen`, put route-owned fixed headers or footers in `FlowScreen.header` or `FlowScreen.footer`. Set `headerHeight` to the visible app-toolbar height; `FlowStack` adds the device's top safe-area/status-bar inset automatically. Do not include `StatusBar` or its height in the header. Set `footerHeight` to the full app-footer height. `FlowScreen.footer` is an overlay, not reserved layout space; screens using it must add their own bottom content padding such as `padding-bottom: calc(var(--flow-footer-height) + var(--mobile-safe-area-height) + 24px)` so final content can scroll above the footer while still painting behind it. +- Render only scrollable content inside `MobileScroll`; it is for content that should move with scroll and rubber-band overscroll. Keep app-owned headers, nav bars, tabs, composers, and overlays outside it. This keeps scroll physics, safe areas, keyboard insets, scrollbars, and drag click suppression active without letting content paint under fixed chrome. +- Buttons, links, cards, and images inside `MobileScroll` should still allow drag scrolling when the pointer moves beyond tap slop. Use `data-scroll-drag="ignore"` only for rare controls that must own the drag gesture themselves. +- Do not add `var(--keyboard-height)` to ordinary screen/content padding inside `MobileScroll`; the scroll viewport already shrinks above the simulated keyboard. For custom fixed composers, search bars, or toast chrome, use `useKeyboardInsets().bottomInset`. It is relative to the app viewport: Android returns `0` while the closed-keyboard viewport already reserves navigation, then returns the keyboard height while open; iOS continues to clear the home indicator while closed and ride directly above the keyboard while open. Do not pin custom bottom chrome to `bottom: 0` or only `keyboardHeight`. +- Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for every text-entry control. A raw `input` or `textarea` disconnects focus, keyboard animation, safe-area insets, and attached surfaces. +- Use `BottomSheet` for phone-scoped sheets. Its props are `open`, `onOpenChange`, `title`, optional `description`, optional `snap`, and `children`; it renders through the phone screen portal and dismisses the keyboard before opening. + +## Horizontal Carousels + +- Use `Carousel` for horizontally draggable cards, images, media, chips, or other horizontal collections. Do not recreate these with `overflow-x`, custom pointer handlers, or a generic div. +- `Carousel` can be nested directly inside `MobileScroll`. It owns horizontal gestures and automatically yields vertical gestures to the parent. +- Never put `data-scroll-drag="ignore"` on or around a `Carousel`; doing so prevents vertical parent scrolling when a gesture begins inside it. +- Do not add CSS scroll snapping to `Carousel`; its runtime owns momentum and release motion. +- Use `data-scroll-drag="ignore"` only when a control must prevent parent scrolling in every drag direction. + +See `src/mobile/COMPONENTS.md` for the full component and gesture contract. + +## Keyboard Rule + +The simulated keyboard is a separate top-layer component. Before presenting anything that behaves like iOS navigation or modal UI, dismiss it first. + +Call `keyboard.hide()` before: + +- pushing, popping, or replacing FlowStack routes +- opening bottom sheets, action sheets, dialogs, menus, or navigation sheets +- starting transitions where the destination should not inherit text-input focus + +`FlowStack` already hides the keyboard for `push`, `pop`, and `replace`. `BottomSheet` already hides it before opening. If you add new modal/sheet/navigation primitives, follow the same rule. + +When a composer, search surface, or other keyboard-attached component closes, call `keyboard.hide()` in the same event before changing that component's open state. Position attached surfaces from `useKeyboardInsets()` rather than a separate timer or visibility flag so both dismiss together. + +When any text-entry control loses focus, dismiss the simulated keyboard. If the control is custom or does not use the runtime's keyboard-aware fields, handle its blur event and call `keyboard.hide()` explicitly. Keep the keyboard open only when focus is moving directly to another text-entry control that should share the same keyboard session. + +## Interaction Rules + +- Do not trigger buttons or inputs after a pointer has become a drag. Preserve the drag suppression behavior in `MobileScroll`. +- Do not allow native browser image/file dragging inside the phone frame. Preserve the phone-level `dragstart` suppression and non-draggable image styles so scroll drags that begin on images still scroll the prototype. +- Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for text entry so the simulated keyboard and safe-area insets stay connected. +- Fixed phone chrome should not animate with pushed screens. Screen content can animate; the status bar, camera cutout, and preview chrome should stay put. +- Keep the keyboard below the home indicator/safe area layer in z-index, and above ordinary app UI while visible. +- Keep the home indicator as the topmost safe-area layer in the z-index above everything else in the prototype. diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..a5013bf --- /dev/null +++ b/ui/README.md @@ -0,0 +1,38 @@ +# 瓶安芯移动端 UI 原型 + +本目录是依据 `doc/*产品设计` 原型图制作的 iOS/Android 交互原型,用于确认信息架构、视觉语言和关键流程,不是生产 Flutter 工程,也不连接真实 API、IoT、支付、定位或对象存储。 + +## 预览入口 + +启动本地预览: + +```bash +npm install +npm run dev -- --host 127.0.0.1 --port 4173 +``` + +入口参数: + +| 入口 | 查询参数 | 主要内容 | +| --- | --- | --- | +| 用户端 | `/?app=user` | 瓶阀控制、商城、订单、个人中心 | +| 配送员 | `/?app=service&role=delivery` | 配送任务、到场、气瓶扫描、随瓶安检、签收 | +| 安装维修员 | `/?app=service&role=installer` | 使用条件、备料、安装、测试、安检、用户确认 | +| 安检员 | `/?app=service&role=inspector` | 现场检查、取证、风险等级、隐患闭环 | + +右上角设备选择器可切换 iPhone 与 Pixel 10,用于检查 iOS/Android 安全区、底部系统区域和键盘行为。 + +## 原型边界 + +- 所有数据均为演示数据,按钮只模拟界面状态,不表示后端业务已实现。 +- 设备命令先进入“等待回执”,不能用请求成功冒充设备执行成功。 +- 工作人员角色切换只改变当前工作上下文;生产实现必须由服务端重新校验岗位、组织、资质、培训、上班状态和服务区域。 +- 现场照片、签名、定位和弱网补传在原型中只展示交互,生产实现须遵循 `docs/04-服务端App需求.md` 的 Flutter 开发说明。 +- 生产 Flutter 工程按仓库规划分别放入 `apps/user_app` 和 `apps/service_app`;在明确进入实现阶段前,不从本原型复制出空壳工程。 + +## 视觉基线 + +- 用户端:安全蓝为主色,瓶阀状态是首页第一层级,危险与待确认状态必须同时使用图标和文字。 +- 服务端:紫色为统一作业框架,安检关键动作使用安全绿色;配送、安装维修、安检共享组件但不共享任务状态机。 +- 中文界面最小正文建议不低于 12sp,触控目标不小于 44×44 logical pixels;金额、时间、单位和更新时间必须明确。 +- 图标使用统一图标库,禁止使用 emoji、字符画或自行绘制的业务图标替代正式资产。 diff --git a/ui/design-qa.md b/ui/design-qa.md new file mode 100644 index 0000000..3f993f4 --- /dev/null +++ b/ui/design-qa.md @@ -0,0 +1,42 @@ +# Design QA + +final result: passed + +## Comparison target + +- User source: `doc/用户端APP-产品设计/4.jpg` +- Delivery source: `doc/服务端APP-配送端-产品设计/f777dcf4d618a0f9664d126b32f73682.jpg` +- Installation source: `doc/服务端APP-安装端-产品设计/202c904481a03ff644a8ac0c0d1c9ae4.jpg` +- Inspection source: `doc/服务端APP-安全检查端-产品设计/ff7edcd4f78721beb5f0953ab08c23c4.jpg` +- Implementation: local `/ui` mobile prototype at the matching `app` and `role` query states. +- Reference pixels: 1344×2772. Each reference was proportionally normalized onto a 393×852 comparison canvas. +- Implementation viewport: 393×852 CSS pixels at device scale factor 1, captured from the app-owned phone screen. +- Device-frame, status-bar, home-indicator and Android system navigation differences were excluded because they are template-owned runtime chrome. + +## Pass 1 + +- P2 — User valve hero used a dark navy surface while the source uses a light neutral control panel with a dominant red action. Fixed by changing the panel to a light neutral surface, enlarging the valve control and moving the automatic-close status into a light inset strip. +- P1 — Pixel 10 could retain an open simulated keyboard after a device change during review. Fixed by dismissing the runtime keyboard whenever the selected device changes. +- P3 — The original user screen centers the page title in a mini-program header; the implementation uses a native-app leading title and live iOS/Android status bar. Kept as a deliberate native-shell adaptation. + +## Pass 2 + +- User valve screen: hierarchy, status density, red control emphasis, light panel, bottom navigation and safety feedback match the source direction. +- Delivery screen: purple task shell, schedule context, user/address facts, prominent primary action and fixed four-tab navigation match the source. +- Installation screen: purple workbench, time/shift state, urgent work item, task card and role-specific action match the source. +- Inspection screen: green safety identity, task list, time/shift state and inspection action match the source. +- iPhone and Pixel 10: no content overflow, fixed navigation obstruction, open keyboard, browser console error or missing primary action remained. +- No actionable P0, P1 or P2 differences remained. + +## Interaction checks + +- Valve control: confirmation sheet → waiting for device receipt → confirmed open state. +- User navigation: valve, shop, favorites, orders and profile tabs are reachable. +- Service work: task list → task detail → next required step. +- Role switch: delivery, installation/repair and inspection reload the role-specific workbench. +- Evidence sheet: photo type selection and simulated capture close correctly. + +## Remaining P3 notes + +- Source screenshots contain mini-program chrome and older iconography; the prototype intentionally uses the protected native device runtime and a single Phosphor icon family. +- Mock content is consolidated from multiple source screens, so exact task numbers and timestamps vary while layout, hierarchy and workflow remain aligned. diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..3c7dd31 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + 瓶安芯移动端 UI 原型 + + +
+ + + diff --git a/ui/mobile-runtime.lock.json b/ui/mobile-runtime.lock.json new file mode 100644 index 0000000..383d553 --- /dev/null +++ b/ui/mobile-runtime.lock.json @@ -0,0 +1,30 @@ +{ + "scripts/check-mobile-runtime.mjs": "ee85738cc2ef889c0b457d8ccac61e017b8c5d16f393e766ec9fb7d11c9aff7f", + "scripts/prepare-sites-build.mjs": "b6a6adaa4fab3234676116dd1c9cb6611275ab9d92dd26f5bf402393e3744bf6", + "scripts/update-mobile-runtime-lock.mjs": "b4e1aa41133f19ba8bb32685b3ec05798b1ceabaf72c328419d545aa60fe1f0b", + "vite.config.ts": "da36eba05b845c3b0d69d823a515f6f7d9612992126ed2fe32c2809815ec73ba", + "src/App.tsx": "7054b4e14304ab33153d3a2e860aa43d2a4f69e8a69630b4a37a2cbcd4ad2f16", + "src/main.tsx": "01b7049715148ab645637905ccbbc91fc3f87659198a309541c2d1f03bb537d0", + "src/styles.css": "844c67b27cf5e38ba395153145d058c929c6479199d245cd046f3646badd719d", + "src/mobile/BottomSheet.tsx": "f9c376dc78b8b5feb1c8752aa23414993ab66013d11e81b2ebb8342c570032ac", + "src/mobile/Carousel.tsx": "242f4a7488e18c35e9b43651c9d5877288655a6f9f1734e03e56108f17526bf7", + "src/mobile/Device.tsx": "867de9c65220f2ac7202f895d2b10a64c747f211a4b22cd8f81a1a1d66e52fc2", + "src/mobile/FlowStack.tsx": "1e85744c5f1c1138dd5e8b7b27fbb6c05139d5aac93d94c45084a018eddd4ebc", + "src/mobile/Keyboard.tsx": "862587264375499846385ce817ea1b813db8f118140cdb6da163f989a6946cac", + "src/mobile/MobileCursor.tsx": "be2353205e05d823bc23ecae88179d712781ee9ede8fa317057c194c7c23ee7d", + "src/mobile/MobileRuntime.tsx": "1ffe80a9582460005d66f10fc73deaff08b81431785a506887cc0d8320be747f", + "src/mobile/MobileScroll.tsx": "ea3e3691f587ea13753162b9e64c1981263e5b9beddebea21a6a1551dce76430", + "src/mobile/PhoneFrame.tsx": "73015590b468acbb4b58e3f6fe5eef99c5f68f67722caffd8f00d271ba00a91d", + "src/mobile/assets.ts": "d40cb8e5390fd2b54618266e7694e40349006dfe480f65d60559316ca8c9c234", + "src/mobile/components.tsx": "20b02f6c6de4cfd08aa6a49bf9bc261c0ea000864b37e1001afdcd892afe4bc8", + "src/mobile/geometry.ts": "f1382e5d6cc9adfd7ac142304f2f9a5a2330db1d74b3f0848adbaf311676bf16", + "src/mobile/index.ts": "ee69d9341571db5dee84dd2d7452a62eef09085a84a85f198a3edc48047e0412", + "public/assets/iphone/Bezel.png": "ee2514d74d2f75f3541405fa2f20811f159879a66586778cfd8c9c84c44959df", + "public/assets/iphone/Keyboard.png": "292e2d83d69adaeb663d6a840ce8db8d0a5257710b472c20713e03f56b08ea88", + "public/assets/android/Pixel10.png": "a87c1250ba20756f5d020b5351a7ae71d663dcfc64aca3a1cd56452e8a05233e", + "public/assets/android/Keyboard.png": "6085e50be17ca2705b4351283187be2c5b01b58262c70b90d74b3376efb4adfe", + "public/assets/android/navigation-bar.svg": "077a782a3b6c2915fc518dfb4bba825c8c5861b2572f9522a7024902b9cb2850", + "public/assets/status/status-icons.svg": "1a8bd4207cf34532ae514bdcdca693acc9a2b8ebc9d3182dc163c3e9ddcc4823", + "public/assets/status/ios-status-icons.svg": "40e218b274d0973d519b50f42884e6e05ef0f2b6a0e46e102fda54218784466d", + "worker/index.js": "2dd0615a445143933d88d4271f54f5d63ee951421fcd08c5a7617bb09c564389" +} diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..3df913b --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2295 @@ +{ + "name": "ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.1.0", + "dependencies": { + "@fontsource/roboto": "5.2.10", + "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-dialog": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.20", + "@radix-ui/react-icons": "1.3.2", + "@use-gesture/react": "10.3.1", + "motion": "12.42.2", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.3", + "typescript": "7.0.2", + "vite": "8.1.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fontsource/roboto": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz", + "integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", + "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz", + "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", + "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", + "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", + "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", + "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-effect-event": "0.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.20.tgz", + "integrity": "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.20", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", + "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.20.tgz", + "integrity": "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.15", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.3", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.15", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.3.tgz", + "integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", + "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", + "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.15.tgz", + "integrity": "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.5", + "@radix-ui/react-collection": "1.1.12", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/framer-motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.42.2", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", + "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.42.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..e9ae68a --- /dev/null +++ b/ui/package.json @@ -0,0 +1,35 @@ +{ + "name": "ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "check:runtime": "node scripts/check-mobile-runtime.mjs", + "update:runtime-lock": "node scripts/update-mobile-runtime-lock.mjs", + "test:runtime": "playwright test", + "test:sites": "node --test tests/sites-worker.test.mjs", + "predev": "npm run check:runtime", + "dev": "vite", + "prebuild": "npm run check:runtime", + "build": "tsc && vite build && node scripts/prepare-sites-build.mjs" + }, + "dependencies": { + "@fontsource/roboto": "5.2.10", + "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-dialog": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.20", + "@radix-ui/react-icons": "1.3.2", + "@use-gesture/react": "10.3.1", + "motion": "12.42.2", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.3", + "typescript": "7.0.2", + "vite": "8.1.3" + } +} diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 0000000..04d4c8b --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +const testPort = Number(process.env.MOBILE_RUNTIME_TEST_PORT ?? 4174); + +export default defineConfig({ + testDir: "./tests", + testMatch: "**/*.spec.ts", + timeout: 20_000, + use: { + baseURL: `http://127.0.0.1:${testPort}`, + viewport: { width: 1100, height: 1100 }, + }, + webServer: { + command: `npm run dev -- --port ${testPort}`, + url: `http://127.0.0.1:${testPort}/tests/runtime-fixture.html`, + reuseExistingServer: process.env.MOBILE_RUNTIME_TEST_PORT == null, + }, +}); diff --git a/ui/public/assets/android/Keyboard.png b/ui/public/assets/android/Keyboard.png new file mode 100644 index 0000000..bf959fa Binary files /dev/null and b/ui/public/assets/android/Keyboard.png differ diff --git a/ui/public/assets/android/Pixel10.png b/ui/public/assets/android/Pixel10.png new file mode 100644 index 0000000..2ba4a10 Binary files /dev/null and b/ui/public/assets/android/Pixel10.png differ diff --git a/ui/public/assets/android/navigation-bar.svg b/ui/public/assets/android/navigation-bar.svg new file mode 100644 index 0000000..9e79543 --- /dev/null +++ b/ui/public/assets/android/navigation-bar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/public/assets/iphone/Bezel.png b/ui/public/assets/iphone/Bezel.png new file mode 100644 index 0000000..759e8aa Binary files /dev/null and b/ui/public/assets/iphone/Bezel.png differ diff --git a/ui/public/assets/iphone/Keyboard.png b/ui/public/assets/iphone/Keyboard.png new file mode 100644 index 0000000..80207c3 Binary files /dev/null and b/ui/public/assets/iphone/Keyboard.png differ diff --git a/ui/public/assets/status/ios-status-icons.svg b/ui/public/assets/status/ios-status-icons.svg new file mode 100644 index 0000000..b75235a --- /dev/null +++ b/ui/public/assets/status/ios-status-icons.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/public/assets/status/status-icons.svg b/ui/public/assets/status/status-icons.svg new file mode 100644 index 0000000..8a97e81 --- /dev/null +++ b/ui/public/assets/status/status-icons.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/scripts/check-mobile-runtime.mjs b/ui/scripts/check-mobile-runtime.mjs new file mode 100644 index 0000000..72bf53d --- /dev/null +++ b/ui/scripts/check-mobile-runtime.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const lockPath = path.join(root, "mobile-runtime.lock.json"); +const lockedFiles = JSON.parse(readFileSync(lockPath, "utf8")); +const failures = []; + +for (const [relativePath, expectedHash] of Object.entries(lockedFiles)) { + const filePath = path.join(root, relativePath); + + if (!existsSync(filePath)) { + failures.push(`${relativePath} is missing`); + continue; + } + + const actualHash = createHash("sha256").update(readFileSync(filePath)).digest("hex"); + if (actualHash !== expectedHash) { + failures.push(`${relativePath} was modified`); + } +} + +if (failures.length > 0) { + console.error("Mobile runtime integrity check failed:\n"); + for (const failure of failures) console.error(`- ${failure}`); + console.error("\nRestore the protected runtime. Put app UI in src/Prototype.tsx and src/prototype.css."); + process.exit(1); +} + +console.log(`Mobile runtime integrity check passed (${Object.keys(lockedFiles).length} protected files).`); diff --git a/ui/scripts/prepare-sites-build.mjs b/ui/scripts/prepare-sites-build.mjs new file mode 100644 index 0000000..86a9e8c --- /dev/null +++ b/ui/scripts/prepare-sites-build.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const dist = path.join(root, "dist"); +const index = path.join(dist, "client", "index.html"); +const worker = path.join(root, "worker", "index.js"); +const hosting = path.join(root, ".openai", "hosting.json"); + +for (const file of [index, worker, hosting]) { + if (!existsSync(file)) throw new Error("Missing Sites build input: " + file); +} + +mkdirSync(path.join(dist, "server"), { recursive: true }); +mkdirSync(path.join(dist, ".openai"), { recursive: true }); +copyFileSync(worker, path.join(dist, "server", "index.js")); +copyFileSync(hosting, path.join(dist, ".openai", "hosting.json")); + +console.log("Prepared Sites build: dist/server/index.js and dist/.openai/hosting.json"); diff --git a/ui/scripts/update-mobile-runtime-lock.mjs b/ui/scripts/update-mobile-runtime-lock.mjs new file mode 100644 index 0000000..7d91275 --- /dev/null +++ b/ui/scripts/update-mobile-runtime-lock.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const lockPath = path.join(root, "mobile-runtime.lock.json"); +const protectedFiles = [ + "scripts/check-mobile-runtime.mjs", + "scripts/prepare-sites-build.mjs", + "scripts/update-mobile-runtime-lock.mjs", + "vite.config.ts", + "src/App.tsx", + "src/main.tsx", + "src/styles.css", + "src/mobile/BottomSheet.tsx", + "src/mobile/Carousel.tsx", + "src/mobile/Device.tsx", + "src/mobile/FlowStack.tsx", + "src/mobile/Keyboard.tsx", + "src/mobile/MobileCursor.tsx", + "src/mobile/MobileRuntime.tsx", + "src/mobile/MobileScroll.tsx", + "src/mobile/PhoneFrame.tsx", + "src/mobile/assets.ts", + "src/mobile/components.tsx", + "src/mobile/geometry.ts", + "src/mobile/index.ts", + "public/assets/iphone/Bezel.png", + "public/assets/iphone/Keyboard.png", + "public/assets/android/Pixel10.png", + "public/assets/android/Keyboard.png", + "public/assets/android/navigation-bar.svg", + "public/assets/status/status-icons.svg", + "public/assets/status/ios-status-icons.svg", + "worker/index.js", +]; + +const hashes = {}; +for (const relativePath of protectedFiles) { + const filePath = path.join(root, relativePath); + if (!existsSync(filePath)) throw new Error(`Protected runtime file is missing: ${relativePath}`); + hashes[relativePath] = createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +writeFileSync(lockPath, `${JSON.stringify(hashes, null, 2)}\n`); +console.log(`Updated mobile-runtime.lock.json (${protectedFiles.length} protected files).`); diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..fe4bedd --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,10 @@ +import { MobileRuntime } from "./mobile"; +import Prototype from "./Prototype"; + +export default function App() { + return ( + + + + ); +} diff --git a/ui/src/Prototype.tsx b/ui/src/Prototype.tsx new file mode 100644 index 0000000..b30cf4e --- /dev/null +++ b/ui/src/Prototype.tsx @@ -0,0 +1,728 @@ +import { useEffect, useMemo, useState } from "react"; +import { + ArrowLeft, + Bank, + BatteryHigh, + Bell, + Bluetooth, + Buildings, + CalendarCheck, + Camera, + CaretRight, + ChartBar, + CheckCircle, + ClipboardText, + Clock, + CloudSlash, + Fire, + FirstAid, + Gauge, + GearSix, + HardHat, + Heart, + HouseLine, + Lifebuoy, + ListChecks, + MagnifyingGlass, + MapPin, + NavigationArrow, + Package, + PhoneCall, + Plus, + Power, + QrCode, + Receipt, + ShieldCheck, + ShoppingCart, + Signature, + Storefront, + ThermometerSimple, + Truck, + User, + UserCircle, + Wallet, + WarningCircle, + WarningDiamond, + WifiHigh, + Wrench, + XCircle, +} from "@phosphor-icons/react"; +import { BottomSheet, MobileScroll, useKeyboard, useMobileDevice } from "./mobile"; + +type UserTab = "valve" | "shop" | "saved" | "orders" | "profile"; +type ServiceTab = "tasks" | "records" | "hazards" | "profile"; +type ServiceRole = "delivery" | "installer" | "inspector"; +type ValveState = "closed" | "pending" | "open"; + +const roleMeta: Record< + ServiceRole, + { + label: string; + title: string; + icon: typeof Truck; + accent: string; + taskLabel: string; + taskId: string; + taskType: string; + address: string; + steps: string[]; + checklist: string[]; + } +> = { + delivery: { + label: "配送员", + title: "配送工作台", + icon: Truck, + accent: "#7c3aed", + taskLabel: "待配送", + taskId: "D001", + taskType: "气瓶配送与空瓶回收", + address: "武侯区红牌楼街道袖海巷 5 栋", + steps: ["订单确认", "导航到场", "扫描气瓶", "随瓶安检", "收款签收"], + checklist: ["核对配送规格与数量", "蓝牙或扫码录入气瓶", "完成随瓶安检照片", "确认线上支付结果"], + }, + installer: { + label: "安装维修员", + title: "安装维修工作台", + icon: Wrench, + accent: "#7c3aed", + taskLabel: "待处理", + taskId: "W001", + taskType: "智能瓶阀安装", + address: "武侯区红牌楼街道袖海巷 5 栋", + steps: ["使用条件", "准备材料", "执行安装", "测试安装", "前期安检", "用户确认"], + checklist: ["通风与安装位置合规", "管道与连接件已备齐", "测漏与压力测试通过", "设备回执与用户签字完整"], + }, + inspector: { + label: "安检员", + title: "安全检查工作台", + icon: ShieldCheck, + accent: "#16a34a", + taskLabel: "待检查", + taskId: "S004", + taskType: "常规入户安检", + address: "武侯区红牌楼街道袖海巷 4 栋", + steps: ["任务详情", "现场执行", "安全检查", "结果确认"], + checklist: ["燃气具与报警器状态", "智能瓶阀与管道连接", "通风及易燃物环境", "照片水印与用户签字"], + }, +}; + +function IconBadge({ + children, + tone = "blue", +}: { + children: React.ReactNode; + tone?: "blue" | "green" | "orange" | "purple" | "red"; +}) { + return {children}; +} + +function StatusPill({ + children, + tone = "success", +}: { + children: React.ReactNode; + tone?: "success" | "warning" | "danger" | "info" | "neutral"; +}) { + return {children}; +} + +function MetricCard({ + icon, + label, + value, + tone = "blue", +}: { + icon: React.ReactNode; + label: string; + value: string; + tone?: "blue" | "green" | "orange" | "purple"; +}) { + return ( +
+
+ {icon} + {label} +
+ {value} +
+ ); +} + +const userNav: Array<{ id: UserTab; label: string; icon: typeof Wrench }> = [ + { id: "valve", label: "角阀控制", icon: Wrench }, + { id: "shop", label: "商城", icon: ShoppingCart }, + { id: "saved", label: "收藏", icon: Heart }, + { id: "orders", label: "订单", icon: ClipboardText }, + { id: "profile", label: "我的", icon: User }, +]; + +function UserValveScreen({ + valveState, + onControl, + onRepair, +}: { + valveState: ValveState; + onControl: () => void; + onRepair: () => void; +}) { + const isPending = valveState === "pending"; + const isOpen = valveState === "open"; + + return ( +
+
+
+ 智能瓶阀 +

角阀控制

+
+ +
+ +
+
设备在线
+ 更新于 16:28:02 +
+ +
+ } label="电池电量" value="85%" tone="green" /> + } label="环境压力" value="101.3 kPa" /> + } label="温度" value="24℃" tone="orange" /> + } label="气瓶余量" value="15 kg" tone="purple" /> +
+ +
+
+ 厨房 · 主设备 +

{isPending ? "等待设备回执" : isOpen ? "瓶阀已开启" : "瓶阀已关闭"}

+

{isPending ? "命令已发送,请勿重复操作" : isOpen ? "安全检查正常,可随时关阀" : "当前处于安全关闭状态"}

+
+ +
+ 自动关闭 + 今日 22:00 +
+
+ +
+
+
+ 安全监测 +

设备状态正常

+
+ 无未处理告警 +
+
+
近期未检测到燃气泄漏
+
报警器和瓶阀通信正常
+
设备最近安检已通过
+
+
+ +
+
+ 紧急服务 +

发现异常?立即处理

+

报修将自动附带设备、地址和采集时间

+
+ +
+ + +
+
+
+ ); +} + +function UserShopScreen() { + const [category, setCategory] = useState("全部"); + const categories = ["全部", "燃气灶具", "热水器", "软管"]; + return ( +
+
+
安全商城

燃气商城

+ +
+ +
+ {categories.map((item) => ( + + ))} +
+
+ {[ + { name: "嵌入式燃气灶", price: "¥1,299", note: "一级能效", icon: Fire }, + { name: "智能恒温热水器", price: "¥899", note: "熄火保护", icon: ThermometerSimple }, + { name: "不锈钢燃气软管", price: "¥89", note: "防鼠咬", icon: Wrench }, + { name: "家用燃气报警器", price: "¥199", note: "联动关阀", icon: WarningCircle }, + ].map((product) => { + const ProductIcon = product.icon; + return ( +
+ +
+

{product.name}

+

{product.note} · 安装可选

+
{product.price}
+
+ ); + })} +
+
+ ); +} + +function UserOrdersScreen() { + return ( +
+
+
履约进度

我的订单

+ +
+
+ +
+
+
+ 订单 1785159018310配送中 +
+
+ +

用气瓶(50kg×1,15kg×1)

预约:今天 17:30–20:00

+ ¥1,000 +
+
+
订单已确认16:31
+
配送员已接单16:42
+
配送中预计 18:20 送达
+
+ +
+
+
订单 178505012501已完成
+
+ +

智能瓶阀安装服务

已完成 · 2026/07/25

+ ¥299 +
+
+
+ ); +} + +function UserProfileScreen() { + return ( +
+
+
个人中心

我的

+ +
+
+
+

用户6078

139****5078 · 四川省成都市

+ +
+
+
账户余额¥21,901
+ +
+
+ {[ + [Receipt, "我的记录", "订单、押金、报修"], + [ChartBar, "用气统计", "月度与年度"], + [PhoneCall, "紧急联系人", "1 位联系人"], + [ShieldCheck, "安全与隐私", "协议与授权"], + [Lifebuoy, "服务与帮助", "客服、知识库"], + ].map(([Icon, label, note]) => ( + + ))} +
+
+ ); +} + +function UserApp() { + const [tab, setTab] = useState("valve"); + const [valveState, setValveState] = useState("closed"); + const [sheet, setSheet] = useState<"valve" | "repair" | null>(null); + + const confirmValve = () => { + setSheet(null); + setValveState("pending"); + window.setTimeout(() => setValveState((current) => (current === "pending" ? "open" : current)), 1400); + }; + + const content = tab === "valve" + ? setSheet("valve")} onRepair={() => setSheet("repair")} /> + : tab === "shop" || tab === "saved" + ? + : tab === "orders" + ? + : ; + + return ( +
+ {content} + + + setSheet(open ? "valve" : null)} + title={valveState === "open" ? "确认关闭瓶阀" : "确认开启瓶阀"} + description="平台校验通过后会下发命令,执行结果以设备回执为准。" + snap={0.46} + > +
+ +
安全条件正常无高风险事件,设备在线,最近安检合格
+
+
+ + +
+
+ + setSheet(open ? "repair" : null)} + title="一键报修" + description="请选择最接近的故障类型,现场信息将在提交前再次确认。" + snap={0.62} + > +
+ + + + +
+
当前地址成都市武侯区红牌楼街道
+ +
+
+ ); +} + +const serviceNav: Array<{ id: ServiceTab; label: string; icon: typeof ClipboardText }> = [ + { id: "tasks", label: "任务", icon: ClipboardText }, + { id: "records", label: "记录", icon: Clock }, + { id: "hazards", label: "隐患", icon: WarningDiamond }, + { id: "profile", label: "我的", icon: User }, +]; + +function ServiceTaskList({ + role, + onOpenTask, + onOpenRole, +}: { + role: ServiceRole; + onOpenTask: () => void; + onOpenRole: () => void; +}) { + const meta = roleMeta[role]; + const RoleIcon = meta.icon; + return ( +
+
+
+ 瓶安芯服务 +

{meta.title}

+
+ +
+ +
+
当前时间16:28:07工作中 · 服务区域正常
+ 已上班 + +
+ +
+ +
今日安全培训已完成角色题库 v2026.07 · 5/5 正确
+ +
+ +
+
+
今日任务

{meta.taskLabel} 3

+ +
+
+
+ +
{meta.taskId}

{meta.taskType}

+ {meta.taskLabel} +
+
张三 · 138****1234
+

{meta.address}

+
+
预约时间今天 17:30
+
优先级紧急
+
预计收入¥180
+
+ +
+
+
+ +
{role === "delivery" ? "R002" : role === "installer" ? "W002" : "S002"}

{role === "delivery" ? "空瓶回收" : role === "installer" ? "阀门维修" : "专项安全检查"}

+ 14:00 +
+

武侯区簇桥街道 12 号

+
+
+ +
弱网保护已开启现场材料将加密暂存,恢复后按采集时间补传
+
+ ); +} + +function ServiceTaskDetail({ + role, + step, + onBack, + onNext, + onEvidence, +}: { + role: ServiceRole; + step: number; + onBack: () => void; + onNext: () => void; + onEvidence: () => void; +}) { + const meta = roleMeta[role]; + const done = step >= meta.steps.length; + return ( +
+
+ +
工单详情{meta.taskId}
+ {done ? "待确认" : "执行中"} +
+
+ {meta.steps.map((label, index) => ( +
+ {index < step ? : index + 1} + {label} +
+ ))} +
+ + {done ? ( +
+ + 现场步骤已完成 +

等待用户或服务端确认

+

当前仅代表材料已提交,最终完成状态以服务端校验结果为准。

+ +
+ ) : ( + <> +
+
本步骤

{meta.steps[step]}

必填
+
+
任务类型{meta.taskType}
+
服务对象张三
+
服务地址{meta.address}
+
+
+ +
+
现场清单

完成前置检查

+ {meta.checklist.map((item, index) => ( + + ))} +
+ +
+
现场取证照片将写入地址、采集时间与任务号0/6
+ +
+ +
+
用户签字确认签名与本次检查结论绑定
+ +
+ + {role === "inspector" && step >= 2 ? ( +
+
安检结论

风险等级

+
+
+ ) : null} + + + + )} +
+ ); +} + +function ServiceRecords({ role }: { role: ServiceRole }) { + const meta = roleMeta[role]; + return ( +
+
作业留痕

服务记录

+
} label="本月完成" value="18 单" tone="green" />} label="及时完成率" value="96%" tone="purple" />
+
+ {[1, 2, 3].map((item) => ( + + ))} +
+
+ ); +} + +function ServiceHazards() { + return ( +
+
安全闭环

隐患排查

+
2 项隐患待处理最近更新时间 16:20
+ {[ + { id: "H001", title: "管道老化", status: "处理中", tone: "warning" as const }, + { id: "H002", title: "阀门泄漏", status: "待复检", tone: "danger" as const }, + ].map((item) => ( +
+
{item.id}{item.status}
+

{item.title}

武侯区红牌楼街道

+ +
+ ))} +
+ ); +} + +function ServiceProfile({ role, onOpenRole }: { role: ServiceRole; onOpenRole: () => void }) { + const meta = roleMeta[role]; + return ( +
+
个人中心

我的工作台

+
+
+

李四

工号 1023 · 双华公司

+
+ +
+
可提现余额¥588.00
+
本月收入¥382本年收入¥588待结算¥180
+
+
+ {[[Wallet, "账户管理"], [Bank, "银行卡"], [Buildings, "资质与单位"], [GearSix, "设置"], [Lifebuoy, "帮助中心"]].map(([Icon, label]) => ( + + ))} +
+
+ ); +} + +function ServiceApp({ initialRole }: { initialRole: ServiceRole }) { + const [role, setRole] = useState(initialRole); + const [tab, setTab] = useState("tasks"); + const [detail, setDetail] = useState(false); + const [step, setStep] = useState(0); + const [sheet, setSheet] = useState<"role" | "evidence" | null>(null); + + const chooseRole = (nextRole: ServiceRole) => { + setRole(nextRole); + setDetail(false); + setStep(0); + setTab("tasks"); + setSheet(null); + }; + + let content: React.ReactNode; + if (detail) { + content = setDetail(false)} onNext={() => setStep((value) => value + 1)} onEvidence={() => setSheet("evidence")} />; + } else if (tab === "tasks") { + content = { setStep(0); setDetail(true); }} onOpenRole={() => setSheet("role")} />; + } else if (tab === "records") { + content = ; + } else if (tab === "hazards") { + content = ; + } else { + content = setSheet("role")} />; + } + + return ( +
+ {content} + + + setSheet(open ? "role" : null)} title="切换服务角色" description="角色切换会重新加载组织、服务区域、资质和待办。" snap={0.52}> +
+ {(Object.keys(roleMeta) as ServiceRole[]).map((item) => { + const itemMeta = roleMeta[item]; + const RoleIcon = itemMeta.icon; + return ; + })} +
+
+ + setSheet(open ? "evidence" : null)} title="现场取证" description="采集的原始时间、定位、任务号和完整性标记将一并保存。" snap={0.58}> +
选择照片类型后拍摄支持 1–6 张照片,不能用补传时间覆盖采集时间
+
+
+
+
+ ); +} + +export default function Prototype() { + const params = useMemo(() => new URLSearchParams(window.location.search), []); + const keyboard = useKeyboard(); + const { device } = useMobileDevice(); + const app = params.get("app") === "service" ? "service" : "user"; + const roleParam = params.get("role"); + const role: ServiceRole = roleParam === "installer" || roleParam === "inspector" ? roleParam : "delivery"; + + useEffect(() => { + keyboard.hide(); + }, [device.id]); + + return app === "service" ? : ; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..9ff4f46 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "@fontsource/roboto/latin-500.css"; +import App from "./App"; +import "./styles.css"; +import "./prototype.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/ui/src/mobile/BottomSheet.tsx b/ui/src/mobile/BottomSheet.tsx new file mode 100644 index 0000000..bd9252e --- /dev/null +++ b/ui/src/mobile/BottomSheet.tsx @@ -0,0 +1,136 @@ +import { type PropsWithChildren, useEffect, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { useDrag } from "@use-gesture/react"; +import { AnimatePresence, motion } from "motion/react"; +import { useKeyboard, useKeyboardInsets } from "./Keyboard"; +import { useScreenPortal } from "./PhoneFrame"; +import { useMobileDevice } from "./Device"; + +type BottomSheetProps = PropsWithChildren<{ + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + snap?: number; +}>; + +export function BottomSheet({ + open, + onOpenChange, + title, + description, + snap = 0.72, + children, +}: BottomSheetProps) { + const { device } = useMobileDevice(); + const { screenRef } = useScreenPortal(); + const keyboard = useKeyboard(); + const { keyboardHeight } = useKeyboardInsets(); + const [dragY, setDragY] = useState(0); + + useEffect(() => { + if (open) keyboard.hide(); + }, [open]); + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + keyboard.hide(); + } + + onOpenChange(nextOpen); + }; + + const bindDrag = useDrag( + (state) => { + const [, movementY] = state.movement; + const [, velocityY] = state.velocity; + const [, directionY] = state.direction; + const nextY = Math.max(0, movementY); + + if (!state.last) { + setDragY(nextY); + return; + } + + const shouldClose = nextY > 96 || (velocityY > 0.55 && directionY > 0); + setDragY(0); + + if (shouldClose) { + onOpenChange(false); + } + }, + { + axis: "y", + filterTaps: true, + }, + ); + + const sheetHeight = Math.round(device.geometry.screen.height * snap); + const effectiveHeight = Math.max(260, sheetHeight - Math.min(keyboardHeight, 180)); + const sheetBottom = + device.platform === "android" + ? Math.max(device.geometry.safeArea.bottom, keyboardHeight) + : keyboardHeight; + const portalContainer = screenRef.current ?? undefined; + + return ( + + {/* Keep the portal mounted after `open` flips so AnimatePresence can run + the sheet and overlay exit animations before Radix removes them. */} + + + {open ? ( + <> + + + + + +
+
+
+
+ {title} + {description ? {description} : null} +
+
{children}
+ + + + ) : null} + + + + ); +} diff --git a/ui/src/mobile/COMPONENTS.md b/ui/src/mobile/COMPONENTS.md new file mode 100644 index 0000000..653194a --- /dev/null +++ b/ui/src/mobile/COMPONENTS.md @@ -0,0 +1,31 @@ +# Mobile runtime components + +## Carousel + +`Carousel` is the standard component for horizontal collections: cards, images, media, swipeable items, and chip or filter rails. Place it directly inside `MobileScroll`; consumers should not add gesture wrappers or pointer handlers. + +```tsx + +
+ + {cards} + +
+
+``` + +The runtime resolves nested gestures by axis. Horizontal intent stays with `Carousel`; vertical intent is handed to the parent `MobileScroll`. Slight vertical drift after a horizontal gesture is claimed does not move, rubber-band, or add momentum to the parent. Taps remain clickable, while a completed drag suppresses the item click. + +Do not use `data-scroll-drag="ignore"` for carousels or ordinary rails. It is a hard opt-out that prevents parent scrolling in every direction. Do not layer CSS scroll snapping over the runtime's JavaScript momentum. If snapping is added later, it should be a component option so one system owns release motion. + +## Keyboard-linked surfaces + +Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for all text entry. Position a composer, search surface, or other keyboard-linked UI from `useKeyboardInsets().bottomInset`. The inset is relative to the app viewport: Android's closed-keyboard viewport already ends above its navigation bar, while iOS still needs its overlaid home-indicator inset; both platforms return the keyboard height while the keyboard is open. Never pin those surfaces to only `keyboardHeight`. When that surface closes, call `keyboard.hide()` in the same event before updating its own open state. + +## BottomSheet + +`BottomSheet` dismisses the keyboard before opening and animates both in and out by default. Keep its `open` state controlled through `onOpenChange`; no consumer exit-animation wrapper is needed. diff --git a/ui/src/mobile/Carousel.tsx b/ui/src/mobile/Carousel.tsx new file mode 100644 index 0000000..28f0997 --- /dev/null +++ b/ui/src/mobile/Carousel.tsx @@ -0,0 +1,257 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type PropsWithChildren, +} from "react"; + +export type CarouselProps = PropsWithChildren<{ + className?: string; + contentClassName?: string; + ariaLabel?: string; + showScrollbar?: boolean; + draggingEnabled?: boolean; +}>; + +const physics = { + friction: 2.1, + velocityScale: 890, + velocityTolerance: 18, + bounceTension: 200, + bounceFriction: 40, + overdragScale: 0.5, + maxOverdrag: 96, + sampleWindow: 100, + dragThreshold: 8, +} as const; + +type Sample = { value: number; time: number }; +type DragSession = { + pointerId: number; + startPrimary: number; + startCross: number; + startOffset: number; + captured: boolean; + dragged: boolean; +}; + +export function Carousel({ + className, + contentClassName, + ariaLabel, + showScrollbar = false, + draggingEnabled = true, + children, +}: CarouselProps) { + const scrollRef = useRef(null); + const sessionRef = useRef(null); + const samplesRef = useRef([]); + const frameRef = useRef(null); + const overdragRef = useRef(0); + const suppressClickRef = useRef(false); + const [dragging, setDragging] = useState(false); + const [overdrag, setOverdrag] = useState(0); + const [thumb, setThumb] = useState({ visible: false, offset: 0, size: 0 }); + + const offset = useCallback((node: HTMLDivElement) => node.scrollLeft, []); + const setOffset = useCallback((node: HTMLDivElement, value: number) => { + node.scrollLeft = value; + }, []); + const clientSize = useCallback((node: HTMLDivElement) => node.clientWidth, []); + const scrollSize = useCallback((node: HTMLDivElement) => node.scrollWidth, []); + const maxOffset = useCallback((node: HTMLDivElement) => Math.max(0, scrollSize(node) - clientSize(node)), [clientSize, scrollSize]); + + const stopMotion = useCallback(() => { + if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + }, []); + const setRubberBand = useCallback((value: number) => { + const next = Math.max(-physics.maxOverdrag, Math.min(physics.maxOverdrag, value)); + overdragRef.current = next; + setOverdrag(next); + }, []); + + const updateThumb = useCallback((visible = true) => { + if (!showScrollbar || !scrollRef.current) return; + const node = scrollRef.current; + const viewport = clientSize(node); + const content = scrollSize(node); + const enabled = content > viewport + 2; + const size = enabled ? Math.max(36, (viewport / content) * viewport) : 0; + const track = Math.max(0, viewport - size - 8); + const progress = offset(node) / Math.max(1, content - viewport); + setThumb({ visible: visible && enabled, size, offset: enabled ? 4 + progress * track : 0 }); + }, [clientSize, offset, scrollSize, showScrollbar]); + + const springBack = useCallback((initialVelocity = 0) => { + stopMotion(); + let position = overdragRef.current; + let velocity = Math.max(-1400, Math.min(1400, initialVelocity)); + let previous: number | null = null; + const tick = (time: number) => { + const seconds = Math.min(0.034, ((previous === null ? 16 : time - previous) || 16) / 1000); + previous = time; + velocity += (-physics.bounceTension * position - physics.bounceFriction * velocity) * seconds; + position += velocity * seconds; + if (Math.abs(position) < 0.5 && Math.abs(velocity) < 0.5) { + setRubberBand(0); + frameRef.current = null; + return; + } + setRubberBand(position); + frameRef.current = window.requestAnimationFrame(tick); + }; + frameRef.current = window.requestAnimationFrame(tick); + }, [setRubberBand, stopMotion]); + + const momentum = useCallback((node: HTMLDivElement, initialVelocity: number) => { + let velocity = initialVelocity; + let previous: number | null = null; + const tick = (time: number) => { + const seconds = (previous === null ? 16 : Math.min(34, time - previous)) / 1000; + previous = time; + velocity *= Math.exp(-physics.friction * seconds); + if (Math.abs(velocity) < physics.velocityTolerance) { + frameRef.current = null; + updateThumb(true); + return; + } + const next = offset(node) + velocity * seconds; + const maximum = maxOffset(node); + if (next < 0 || next > maximum) { + setOffset(node, Math.max(0, Math.min(maximum, next))); + // Match MobileScroll's edge convention: positive displacement at the + // leading edge, negative displacement at the trailing edge. + setRubberBand((next < 0 ? -next : maximum - next) * physics.overdragScale); + springBack(velocity * physics.overdragScale); + return; + } + setOffset(node, next); + updateThumb(true); + frameRef.current = window.requestAnimationFrame(tick); + }; + if (Math.abs(velocity) >= physics.velocityTolerance) frameRef.current = window.requestAnimationFrame(tick); + }, [maxOffset, offset, setOffset, setRubberBand, springBack, updateThumb]); + + useEffect(() => { + const node = scrollRef.current; + if (!node) return; + const onScroll = () => updateThumb(true); + const observer = new ResizeObserver(() => updateThumb(false)); + node.addEventListener("scroll", onScroll, { passive: true }); + observer.observe(node); + if (node.firstElementChild) observer.observe(node.firstElementChild); + updateThumb(false); + return () => { + node.removeEventListener("scroll", onScroll); + observer.disconnect(); + stopMotion(); + }; + }, [stopMotion, updateThumb]); + + const primary = (event: ReactPointerEvent) => event.clientX; + const cross = (event: ReactPointerEvent) => event.clientY; + const record = (value: number) => { + const time = performance.now(); + samplesRef.current = [...samplesRef.current, { value, time }].filter((sample) => time - sample.time <= physics.sampleWindow); + }; + + const onPointerDown = (event: ReactPointerEvent) => { + const node = scrollRef.current; + if (!draggingEnabled || !node || maxOffset(node) <= 2 || (event.pointerType === "mouse" && event.button !== 0)) return; + stopMotion(); + samplesRef.current = []; + record(primary(event)); + setRubberBand(0); + sessionRef.current = { pointerId: event.pointerId, startPrimary: primary(event), startCross: cross(event), startOffset: offset(node), captured: false, dragged: false }; + }; + + const onPointerMove = (event: ReactPointerEvent) => { + const node = scrollRef.current; + const session = sessionRef.current; + if (!node || !session || session.pointerId !== event.pointerId) return; + const delta = primary(event) - session.startPrimary; + const crossDelta = cross(event) - session.startCross; + if (!session.dragged) { + // Keep the gesture pending until it clears tap slop. Pointer-down and + // these early moves must bubble so a parent MobileScroll can still win. + if (Math.max(Math.abs(delta), Math.abs(crossDelta)) < physics.dragThreshold) return; + if (Math.abs(crossDelta) > Math.abs(delta)) { + // The cross axis won. Abandon this session without capturing or + // canceling the event so the parent can handle this move and release. + sessionRef.current = null; + return; + } + // The scroller owns the gesture from this move onward. Capture keeps + // delivery stable outside its bounds; stopping propagation prevents the + // parent from accumulating vertical drift or release momentum. + event.currentTarget.setPointerCapture(event.pointerId); + session.captured = true; + } + event.preventDefault(); + event.stopPropagation(); + session.dragged = true; + setDragging(true); + suppressClickRef.current = true; + record(primary(event)); + const desired = session.startOffset - delta; + const maximum = maxOffset(node); + setOffset(node, Math.max(0, Math.min(maximum, desired))); + setRubberBand(desired < 0 ? -desired * physics.overdragScale : desired > maximum ? -(desired - maximum) * physics.overdragScale : 0); + updateThumb(true); + }; + + const finish = (event: ReactPointerEvent) => { + const node = scrollRef.current; + const session = sessionRef.current; + if (!node || !session || session.pointerId !== event.pointerId) return; + if (session.captured) event.currentTarget.releasePointerCapture(event.pointerId); + const samples = samplesRef.current; + const first = samples[0]; + const last = samples[samples.length - 1]; + const velocity = first && last ? -((last.value - first.value) / Math.max(1, last.time - first.time)) * physics.velocityScale : 0; + sessionRef.current = null; + setDragging(false); + if (session.dragged) event.preventDefault(); + if (Math.abs(overdragRef.current) > 0.1) springBack(velocity * physics.overdragScale); + else momentum(node, velocity); + }; + + const onClickCapture = (event: ReactMouseEvent) => { + if (!suppressClickRef.current) return; + suppressClickRef.current = false; + event.preventDefault(); + event.stopPropagation(); + }; + + const style = { "--mobile-carousel-overdrag": `${overdrag}px` } as CSSProperties; + const thumbStyle = { width: thumb.size, transform: `translateX(${thumb.offset}px)` }; + + return ( +
+
{children}
+ {showScrollbar ? ( + + ); +} diff --git a/ui/src/mobile/Device.tsx b/ui/src/mobile/Device.tsx new file mode 100644 index 0000000..aedf084 --- /dev/null +++ b/ui/src/mobile/Device.tsx @@ -0,0 +1,115 @@ +import { createContext, type PropsWithChildren, useContext, useMemo, useState } from "react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons"; +import { mobileAssets } from "./assets"; +import { iphoneGeometry, pixelGeometry, type MobileDeviceGeometry } from "./geometry"; + +export type MobileDeviceId = "iphone" | "pixel-10"; + +type MobileDevicePreset = { + id: MobileDeviceId; + label: string; + platform: "ios" | "android"; + bezel: string; + bezelLayer: "above-screen" | "below-screen"; + geometry: MobileDeviceGeometry; + camera?: { + size: number; + top: number; + }; +}; + +export const mobileDevices: Record = { + iphone: { + id: "iphone", + label: "iPhone", + platform: "ios", + bezel: mobileAssets.iphoneBezel, + bezelLayer: "above-screen", + geometry: iphoneGeometry, + }, + "pixel-10": { + id: "pixel-10", + label: "Pixel 10", + platform: "android", + bezel: mobileAssets.pixel10Bezel, + bezelLayer: "below-screen", + geometry: pixelGeometry, + camera: { + size: 32, + top: 23, + }, + }, +}; + +type MobileDeviceContextValue = { + device: MobileDevicePreset; + deviceId: MobileDeviceId; + setDeviceId: (deviceId: MobileDeviceId) => void; +}; + +const MobileDeviceContext = createContext(null); + +export function MobileDeviceProvider({ children }: PropsWithChildren) { + const [deviceId, setDeviceId] = useState("iphone"); + const value = useMemo( + () => ({ device: mobileDevices[deviceId], deviceId, setDeviceId }), + [deviceId], + ); + + return {children}; +} + +export function useMobileDevice() { + const context = useContext(MobileDeviceContext); + + if (!context) { + throw new Error("useMobileDevice must be used inside MobileDeviceProvider"); + } + + return context; +} + +export function DevicePicker() { + const { device, deviceId, setDeviceId } = useMobileDevice(); + + return ( + +
+ + + +
+ + + setDeviceId(value as MobileDeviceId)} + > + {Object.values(mobileDevices).map((option) => ( + + {option.label} + + + + ))} + + + +
+ ); +} diff --git a/ui/src/mobile/FlowStack.tsx b/ui/src/mobile/FlowStack.tsx new file mode 100644 index 0000000..f98f257 --- /dev/null +++ b/ui/src/mobile/FlowStack.tsx @@ -0,0 +1,234 @@ +import { + createContext, + type CSSProperties, + type PropsWithChildren, + type ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { useDrag } from "@use-gesture/react"; +import { useMobileDevice } from "./Device"; +import { useKeyboard, useKeyboardDismissDrag, useKeyboardInsets } from "./Keyboard"; + +export type FlowScreen = { + id: string; + title?: string; + header?: (flow: FlowControls) => ReactNode; + headerHeight?: number; + footer?: (flow: FlowControls) => ReactNode; + footerHeight?: number; + render: (flow: FlowControls) => ReactNode; +}; + +type FlowEntry = FlowScreen & { + key: string; +}; + +export type FlowControls = { + current: FlowEntry; + previous: FlowEntry | null; + stack: FlowEntry[]; + canGoBack: boolean; + push: (screen: FlowScreen) => void; + pop: () => void; + replace: (screen: FlowScreen) => void; +}; + +const FlowContext = createContext(null); + +export function useFlow() { + const context = useContext(FlowContext); + + if (!context) { + throw new Error("useFlow must be used inside FlowStack"); + } + + return context; +} + +function FlowProvider({ value, children }: PropsWithChildren<{ value: FlowControls }>) { + return {children}; +} + +export function FlowStack({ initial }: { initial: FlowScreen }) { + const { device } = useMobileDevice(); + const keyboard = useKeyboard(); + const { bottomInset, keyboardDragging } = useKeyboardInsets(); + const dismissKeyboardDrag = useKeyboardDismissDrag(); + const sequence = useRef(1); + const gestureStartedAtEdge = useRef(false); + const initialEntry = useRef({ ...initial, key: `${initial.id}-0` }); + const [stack, setStack] = useState(() => [initialEntry.current]); + const [direction, setDirection] = useState(1); + const [swipeX, setSwipeX] = useState(0); + + const toEntry = useCallback((screen: FlowScreen): FlowEntry => { + const next = sequence.current; + sequence.current += 1; + return { ...screen, key: `${screen.id}-${next}` }; + }, []); + + const pop = useCallback(() => { + keyboard.hide(); + setDirection(-1); + setStack((currentStack) => { + if (currentStack.length <= 1) return currentStack; + return currentStack.slice(0, -1); + }); + }, [keyboard]); + + const controls = useMemo(() => { + const current = stack[stack.length - 1]; + const previous = stack.length > 1 ? stack[stack.length - 2] : null; + + return { + current, + previous, + stack, + canGoBack: stack.length > 1, + push: (screen) => { + keyboard.hide(); + setDirection(1); + setSwipeX(0); + setStack((currentStack) => [...currentStack, toEntry(screen)]); + }, + pop, + replace: (screen) => { + keyboard.hide(); + setDirection(1); + setSwipeX(0); + setStack((currentStack) => { + const next = currentStack.slice(0, -1); + return [...next, toEntry(screen)]; + }); + }, + }; + }, [keyboard, pop, stack, toEntry]); + + const bindEdgeSwipe = useDrag( + (state) => { + if (!controls.canGoBack) return; + + if (state.first) { + const target = state.event.currentTarget as HTMLElement; + const bounds = target.getBoundingClientRect(); + gestureStartedAtEdge.current = state.initial[0] - bounds.left < 28; + } + + if (!gestureStartedAtEdge.current) return; + + const [movementX] = state.movement; + const [velocityX] = state.velocity; + const [directionX] = state.direction; + const nextX = Math.max(0, Math.min(movementX, device.geometry.screen.width)); + + if (!state.last) { + setSwipeX(nextX); + return; + } + + const shouldPop = nextX > 92 || (velocityX > 0.45 && directionX > 0); + setSwipeX(0); + gestureStartedAtEdge.current = false; + + if (shouldPop) { + controls.pop(); + } + }, + { + axis: "x", + filterTaps: true, + pointer: { touch: true }, + }, + ); + + const screenWidth = device.geometry.screen.width; + const topIndex = stack.length - 1; + const parkedX = -screenWidth * 0.28; + const header = controls.current.header?.(controls); + const headerHeight = controls.current.headerHeight ?? 0; + const headerSafeArea = header ? device.geometry.safeArea.top : 0; + const totalHeaderHeight = header ? headerSafeArea + headerHeight : 0; + const footer = controls.current.footer?.(controls); + const footerHeight = controls.current.footerHeight ?? 0; + + const screenVariants = { + enter: (animationDirection: number) => ({ + x: animationDirection > 0 ? screenWidth : parkedX, + scale: animationDirection > 0 ? 1 : 0.985, + }), + exit: (animationDirection: number) => ({ + x: animationDirection < 0 ? screenWidth : parkedX, + scale: animationDirection < 0 ? 1 : 0.985, + }), + }; + + return ( + +
+ {header ? ( +
+ {header} +
+ ) : null} +
+ + {stack.map((entry, index) => { + const isTop = index === topIndex; + const isVisible = index >= topIndex - 1; + + return ( + + {entry.render(controls)} + + ); + })} + +
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+
+ ); +} diff --git a/ui/src/mobile/Keyboard.tsx b/ui/src/mobile/Keyboard.tsx new file mode 100644 index 0000000..54d8464 --- /dev/null +++ b/ui/src/mobile/Keyboard.tsx @@ -0,0 +1,241 @@ +import { + createContext, + type InputHTMLAttributes, + type PointerEvent as ReactPointerEvent, + type PropsWithChildren, + type Ref, + type TextareaHTMLAttributes, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { motion } from "motion/react"; +import { mobileAssets } from "./assets"; +import { useMobileDevice } from "./Device"; + +type KeyboardContextValue = { + visible: boolean; + height: number; + fullHeight: number; + progress: number; + dragOffset: number; + isDragging: boolean; + focusedElement: HTMLElement | null; + setDragOffset: (offset: number) => void; + setDragging: (dragging: boolean) => void; + show: (element?: HTMLElement | null) => void; + hide: () => void; +}; + +type KeyboardInputProps = InputHTMLAttributes & { + ref?: Ref; +}; + +const KeyboardContext = createContext(null); + +export function KeyboardProvider({ children }: PropsWithChildren) { + const { device } = useMobileDevice(); + const [visible, setVisible] = useState(false); + const [dragOffset, setRawDragOffset] = useState(0); + const [isDragging, setDragging] = useState(false); + const [focusedElement, setFocusedElement] = useState(null); + const fullHeight = device.geometry.keyboard.height; + const setDragOffset = (offset: number) => { + setRawDragOffset(Math.max(0, Math.min(fullHeight, offset))); + }; + + const value = useMemo( + () => ({ + visible, + height: visible ? Math.max(0, fullHeight - dragOffset) : 0, + fullHeight, + dragOffset, + isDragging, + progress: visible ? 1 : 0, + focusedElement, + setDragOffset, + setDragging, + show: (element) => { + setRawDragOffset(0); + setDragging(false); + setFocusedElement(element ?? null); + setVisible(true); + }, + hide: () => { + focusedElement?.blur(); + setDragging(false); + setFocusedElement(null); + setVisible(false); + }, + }), + [dragOffset, focusedElement, fullHeight, isDragging, visible], + ); + + return {children}; +} + +export function useKeyboard() { + const context = useContext(KeyboardContext); + + if (!context) { + throw new Error("useKeyboard must be used inside KeyboardProvider"); + } + + return context; +} + +export function useKeyboardInsets() { + const keyboard = useKeyboard(); + const { device } = useMobileDevice(); + const reservesAndroidNavigation = device.platform === "android" && !keyboard.visible; + + return { + keyboardHeight: keyboard.height, + keyboardFullHeight: keyboard.fullHeight, + keyboardDragging: keyboard.isDragging, + bottomInset: reservesAndroidNavigation + ? 0 + : device.platform === "android" + ? keyboard.height + : Math.max(device.geometry.safeArea.bottom, keyboard.height), + availableHeight: + device.geometry.screen.height - + keyboard.height - + (reservesAndroidNavigation ? device.geometry.safeArea.bottom : 0), + isKeyboardVisible: keyboard.visible, + }; +} + +export function useKeyboardDismissDrag() { + const keyboard = useKeyboard(); + const dragRef = useRef({ + pointerId: null as number | null, + startY: 0, + lastY: 0, + lastTime: 0, + velocityY: 0, + }); + + const endDismissDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (drag.pointerId !== event.pointerId) return; + + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Capture may already be gone after pointer cancel. + } + + const nextY = Math.max(0, event.clientY - drag.startY); + const shouldDismiss = nextY > 76 || drag.velocityY > 0.45; + drag.pointerId = null; + + if (shouldDismiss) { + keyboard.setDragOffset(nextY); + keyboard.hide(); + return; + } + + keyboard.setDragging(false); + keyboard.setDragOffset(0); + }; + + return { + onPointerDown: (event: ReactPointerEvent) => { + if (!keyboard.visible) return; + if (event.pointerType === "mouse" && event.button !== 0) return; + if ( + event.target instanceof Element && + event.target.closest('button, input, textarea, select, a, [role="button"], [contenteditable="true"]') + ) { + return; + } + + keyboard.setDragging(true); + dragRef.current = { + pointerId: event.pointerId, + startY: event.clientY, + lastY: event.clientY, + lastTime: performance.now(), + velocityY: 0, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, + onPointerMove: (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (drag.pointerId !== event.pointerId) return; + + const now = performance.now(); + const elapsed = Math.max(1, now - drag.lastTime); + drag.velocityY = (event.clientY - drag.lastY) / elapsed; + drag.lastY = event.clientY; + drag.lastTime = now; + keyboard.setDragOffset(Math.max(0, event.clientY - drag.startY)); + }, + onPointerUp: endDismissDrag, + onPointerCancel: endDismissDrag, + }; +} + +export function KeyboardInput(props: KeyboardInputProps) { + const keyboard = useKeyboard(); + const { ref, ...inputProps } = props; + + return ( + { + keyboard.show(event.currentTarget); + inputProps.onFocus?.(event); + }} + /> + ); +} + +export function KeyboardTextarea(props: TextareaHTMLAttributes) { + const keyboard = useKeyboard(); + + return ( +