feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
+1
View File
@@ -23,6 +23,7 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
manifestPlaceholders["appAuthRedirectScheme"] = "aioa"
}
buildTypes {
@@ -7,7 +7,6 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+42
View File
@@ -0,0 +1,42 @@
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+189
View File
@@ -0,0 +1,189 @@
PODS:
- AppAuth (2.0.0):
- AppAuth/Core (= 2.0.0)
- AppAuth/ExternalUserAgent (= 2.0.0)
- AppAuth/Core (2.0.0)
- AppAuth/ExternalUserAgent (2.0.0):
- AppAuth/Core
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (12.15.0):
- FirebaseCore (~> 12.15.0)
- Firebase/Messaging (12.15.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.15.0)
- firebase_core (4.12.1):
- Firebase/CoreOnly (= 12.15.0)
- Flutter
- firebase_messaging (16.4.3):
- Firebase/Messaging (= 12.15.0)
- firebase_core
- Flutter
- FirebaseCore (12.15.0):
- FirebaseCoreInternal (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.15.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.15.0):
- FirebaseCore (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.15.0):
- FirebaseCore (~> 12.15.0)
- FirebaseInstallations (~> 12.15.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0)
- flutter_appauth (0.0.1):
- AppAuth (= 2.0.0)
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
SPEC REPOS:
trunk:
- AppAuth
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES:
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
flutter_appauth:
:path: ".symlinks/plugins/flutter_appauth/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: a8539b633d474fbeb654c7043f9c1649e274045b
firebase_core: 1c1e250a77e2df9e80bb8bf0875c8b427044a697
firebase_messaging: 90cf2b62f5058120ab19f29ceff1503ba0f51ba7
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
FirebaseMessaging: 40017d7bc8457ee295b0f41d480a80fdabc9994e
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_appauth: 53aecd8e5a88b27c0457bf06aa755d3b43625e85
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
PODFILE CHECKSUM: ce2a4dd764e1c7aeed6a7cdc5e61d092b6dc6d32
COCOAPODS: 1.16.2
+135 -5
View File
@@ -10,9 +10,11 @@
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 */; };
3D192DCCA3F7D857428950DE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A0B2050BAA4941AAE206A774 /* Pods_Runner.framework */; };
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 */; };
93B4DE8061F359FC6A549D30 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CDB206CCD4C60B56B4582F10 /* Pods_RunnerTests.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -42,13 +44,16 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
017F0AB48EAAC1F19FC83B2F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
1DECA71953B8676083696D0C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
764D0C90813B439DD3167437 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -59,14 +64,28 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A0B2050BAA4941AAE206A774 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B35D44954EE6D9B854D37705 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
C7F4C3F2FA5C38583A7660AC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
CDB206CCD4C60B56B4582F10 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FFAF78D225577C3406E1EDAE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
1605CC1007E5A2E35A7817B4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
93B4DE8061F359FC6A549D30 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
3D192DCCA3F7D857428950DE /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -81,6 +100,15 @@
path = RunnerTests;
sourceTree = "<group>";
};
8185E7C5804F08FC016736C9 /* Frameworks */ = {
isa = PBXGroup;
children = (
A0B2050BAA4941AAE206A774 /* Pods_Runner.framework */,
CDB206CCD4C60B56B4582F10 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -100,6 +128,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
CF03A8A68A90DC50946874FE /* Pods */,
8185E7C5804F08FC016736C9 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -128,6 +158,20 @@
path = Runner;
sourceTree = "<group>";
};
CF03A8A68A90DC50946874FE /* Pods */ = {
isa = PBXGroup;
children = (
FFAF78D225577C3406E1EDAE /* Pods-Runner.debug.xcconfig */,
017F0AB48EAAC1F19FC83B2F /* Pods-Runner.release.xcconfig */,
764D0C90813B439DD3167437 /* Pods-Runner.profile.xcconfig */,
B35D44954EE6D9B854D37705 /* Pods-RunnerTests.debug.xcconfig */,
1DECA71953B8676083696D0C /* Pods-RunnerTests.release.xcconfig */,
C7F4C3F2FA5C38583A7660AC /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -135,8 +179,10 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
C361AC12B26B49124E1928F4 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
1605CC1007E5A2E35A7817B4 /* Frameworks */,
);
buildRules = (
);
@@ -152,12 +198,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
F07428379A097C2D2DD2608F /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
A45E06FAFFF783A7CD54029D /* [CP] Embed Pods Frameworks */,
78AFF89006F9FC9C5E4B93E9 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -201,7 +250,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@@ -251,6 +300,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
78AFF89006F9FC9C5E4B93E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -266,6 +332,67 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
A45E06FAFFF783A7CD54029D /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C361AC12B26B49124E1928F4 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
F07428379A097C2D2DD2608F /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -360,7 +487,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -392,6 +519,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B35D44954EE6D9B854D37705 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -409,6 +537,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 1DECA71953B8676083696D0C /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -424,6 +553,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C7F4C3F2FA5C38583A7660AC /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -486,7 +616,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -537,7 +667,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -627,7 +757,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
+3
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+11
View File
@@ -16,6 +16,17 @@
<string>6.0</string>
<key>CFBundleName</key>
<string>aioa_mobile</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>aioa</string>
</array>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+16 -2
View File
@@ -1,12 +1,26 @@
import 'package:aioa_mobile/app/router.dart';
import 'package:aioa_mobile/app/theme.dart';
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:aioa_mobile/core/auth/login_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:aioa_mobile/core/notifications/push_registration_controller.dart';
class AioaApp extends StatelessWidget {
class AioaApp extends ConsumerWidget {
const AioaApp({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authSessionProvider);
if (auth.isLoading) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildAioaTheme(),
home: const Scaffold(body: Center(child: CircularProgressIndicator())),
);
}
if (auth.value == null) return const LoginPage();
ref.watch(pushRegistrationProvider);
return MaterialApp.router(
title: 'AIOA',
debugShowCheckedModeBanner: false,
+8
View File
@@ -2,6 +2,8 @@ import 'package:aioa_mobile/app/shell.dart';
import 'package:aioa_mobile/features/assistant/presentation/assistant_page.dart';
import 'package:aioa_mobile/features/form/presentation/leave_form_page.dart';
import 'package:aioa_mobile/features/profile/presentation/profile_page.dart';
import 'package:aioa_mobile/features/requests/presentation/leave_request_detail_page.dart';
import 'package:aioa_mobile/features/requests/presentation/leave_request_list_page.dart';
import 'package:aioa_mobile/features/tasks/presentation/tasks_page.dart';
import 'package:aioa_mobile/features/workspace/presentation/workspace_page.dart';
import 'package:flutter/material.dart';
@@ -27,5 +29,11 @@ final appRouter = GoRouter(
child: const LeaveFormPage(),
),
),
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
GoRoute(
path: '/leave/:id',
builder: (_, state) =>
LeaveRequestDetailPage(id: state.pathParameters['id']!),
),
],
);
+17
View File
@@ -0,0 +1,17 @@
class AuthSession {
const AuthSession({
required this.accessToken,
required this.refreshToken,
required this.idToken,
required this.expiresAt,
});
final String accessToken;
final String? refreshToken;
final String? idToken;
final DateTime expiresAt;
bool get needsRefresh => expiresAt.isBefore(
DateTime.now().toUtc().add(const Duration(minutes: 1)),
);
}
@@ -0,0 +1,167 @@
import 'package:aioa_mobile/core/auth/auth_session.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
String get oidcIssuer => RuntimeConfig.oidcIssuer;
const oidcClientId = 'aioa-mobile';
const oidcRedirectUrl = 'aioa://oauth/callback';
const oidcLogoutRedirectUrl = 'aioa://oauth/logout';
final authSessionProvider =
AsyncNotifierProvider<AuthSessionController, AuthSession?>(
AuthSessionController.new,
);
class AuthSessionController extends AsyncNotifier<AuthSession?> {
static const _storage = FlutterSecureStorage();
static const _appAuth = FlutterAppAuth();
@override
Future<AuthSession?> build() => _restore();
Future<void> login() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
oidcClientId,
oidcRedirectUrl,
issuer: oidcIssuer,
scopes: const ['openid', 'profile', 'email', 'offline_access'],
promptValues: const ['login'],
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
return _storeResponse(result);
});
}
Future<void> logout() async {
final current = state.value;
try {
if (current?.idToken != null) {
await _appAuth.endSession(
EndSessionRequest(
idTokenHint: current!.idToken,
postLogoutRedirectUrl: oidcLogoutRedirectUrl,
issuer: oidcIssuer,
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
}
} finally {
await _clear();
state = const AsyncData(null);
}
}
Future<void> invalidateDeviceSession() async {
await _clear();
state = const AsyncData(null);
}
Future<String?> validAccessToken() async {
final current = state.value;
if (current == null) return null;
if (!current.needsRefresh) return current.accessToken;
final refreshToken = current.refreshToken;
if (refreshToken == null) {
await _clear();
state = const AsyncData(null);
return null;
}
try {
final refreshed = await _refresh(current);
state = AsyncData(refreshed);
return refreshed.accessToken;
} catch (_) {
await _clear();
state = const AsyncData(null);
return null;
}
}
Future<AuthSession?> _restore() async {
final values = await Future.wait([
_storage.read(key: 'access_token'),
_storage.read(key: 'refresh_token'),
_storage.read(key: 'id_token'),
_storage.read(key: 'expires_at'),
]);
final accessToken = values[0];
final expiresAt = DateTime.tryParse(values[3] ?? '');
if (accessToken == null || expiresAt == null) return null;
final session = AuthSession(
accessToken: accessToken,
refreshToken: values[1],
idToken: values[2],
expiresAt: expiresAt,
);
if (session.needsRefresh) {
if (session.refreshToken == null) {
await _clear();
return null;
}
try {
return await _refresh(session);
} catch (_) {
await _clear();
return null;
}
}
return session;
}
Future<AuthSession> _refresh(AuthSession current) async {
final response = await _appAuth.token(
TokenRequest(
oidcClientId,
oidcRedirectUrl,
issuer: oidcIssuer,
refreshToken: current.refreshToken,
scopes: const ['openid', 'profile', 'email', 'offline_access'],
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
return _storeResponse(response, fallback: current);
}
Future<AuthSession> _storeResponse(
TokenResponse response, {
AuthSession? fallback,
}) async {
final accessToken = response.accessToken ?? fallback?.accessToken;
final expiresAt =
response.accessTokenExpirationDateTime ?? fallback?.expiresAt;
if (accessToken == null || expiresAt == null) {
throw StateError('OIDC token response is incomplete');
}
final session = AuthSession(
accessToken: accessToken,
refreshToken: response.refreshToken ?? fallback?.refreshToken,
idToken: response.idToken ?? fallback?.idToken,
expiresAt: expiresAt.toUtc(),
);
await Future.wait([
_storage.write(key: 'access_token', value: session.accessToken),
_storage.write(key: 'refresh_token', value: session.refreshToken),
_storage.write(key: 'id_token', value: session.idToken),
_storage.write(
key: 'expires_at',
value: session.expiresAt.toIso8601String(),
),
]);
return session;
}
Future<void> _clear() async {
await Future.wait([
_storage.delete(key: 'access_token'),
_storage.delete(key: 'refresh_token'),
_storage.delete(key: 'id_token'),
_storage.delete(key: 'expires_at'),
]);
}
}
@@ -0,0 +1,148 @@
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
String get apiBaseUrl => RuntimeConfig.apiBaseUrl;
final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
final inner = http.Client();
return AuthenticatedHttpClient(
inner: inner,
apiOrigin: Uri.parse(apiBaseUrl).origin,
tokenProvider: ref.read(authSessionProvider.notifier).validAccessToken,
deviceRegistration: ref.watch(deviceRegistrationProvider),
);
});
final deviceRegistrationProvider = Provider<DeviceRegistration>((ref) {
return DeviceRegistration(
inner: http.Client(),
baseUrl: apiBaseUrl,
onRevoked: ref.read(authSessionProvider.notifier).invalidateDeviceSession,
);
});
class AuthenticatedHttpClient extends http.BaseClient {
AuthenticatedHttpClient({
required this.inner,
required this.apiOrigin,
required this.tokenProvider,
this.deviceRegistration,
});
final http.Client inner;
final String apiOrigin;
final Future<String?> Function() tokenProvider;
final DeviceRegistration? deviceRegistration;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
if (request.url.origin == apiOrigin &&
!request.headers.containsKey('Authorization')) {
final token = await tokenProvider();
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
final registration = deviceRegistration;
if (registration != null) {
request.headers['X-AIOA-Device-Id'] = await registration.ensure(
token,
);
}
}
}
final response = await inner.send(request);
if (response.headers['x-aioa-auth-error'] == 'DEVICE_REVOKED') {
await deviceRegistration?.revoked();
}
return response;
}
@override
void close() => inner.close();
}
class DeviceRegistration {
DeviceRegistration({
required this.inner,
required this.baseUrl,
required this.onRevoked,
});
static const _storage = FlutterSecureStorage();
final http.Client inner;
final String baseUrl;
final Future<void> Function() onRevoked;
String? _registeredToken;
String? _deviceId;
Future<String> ensure(String token) async {
final id = _deviceId ??= await _loadOrCreateId();
if (_registeredToken == token) return id;
final platform = Platform.isIOS
? 'IOS'
: Platform.isAndroid
? 'ANDROID'
: 'OTHER';
final response = await inner.post(
Uri.parse('$baseUrl/devices/register'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({
'id': id,
'name': Platform.localHostname.isEmpty
? '$platform 设备'
: Platform.localHostname,
'platform': platform,
}),
);
if (response.statusCode == 401 || response.statusCode == 403) {
await revoked();
throw const DeviceRevokedException();
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw DeviceRegistrationException(response.statusCode);
}
_registeredToken = token;
return id;
}
Future<void> revoked() async {
_registeredToken = null;
await onRevoked();
}
Future<String> currentId() async => _deviceId ??= await _loadOrCreateId();
Future<String> _loadOrCreateId() async {
final existing = await _storage.read(key: 'device_id');
if (existing != null) return existing;
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String hex(int start, int end) => bytes
.sublist(start, end)
.map((value) => value.toRadixString(16).padLeft(2, '0'))
.join();
final id =
'${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
await _storage.write(key: 'device_id', value: id);
return id;
}
}
class DeviceRevokedException implements Exception {
const DeviceRevokedException();
}
class DeviceRegistrationException implements Exception {
const DeviceRegistrationException(this.statusCode);
final int statusCode;
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:aioa_mobile/app/theme.dart';
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authSessionProvider);
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildAioaTheme(),
home: Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CircleAvatar(
radius: 36,
child: Icon(Icons.apartment_rounded, size: 38),
),
const SizedBox(height: 20),
Text(
'AIOA',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
const Text('使用企业账号安全登录', textAlign: TextAlign.center),
if (auth.hasError) ...[
const SizedBox(height: 16),
Text(
'登录失败:${auth.error}',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 28),
FilledButton.icon(
onPressed: auth.isLoading
? null
: ref.read(authSessionProvider.notifier).login,
icon: auth.isLoading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('Keycloak 登录'),
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
class RuntimeConfig {
static const _configuredApiBaseUrl = String.fromEnvironment(
'AIOA_API_BASE_URL',
);
static const _configuredOidcIssuer = String.fromEnvironment(
'AIOA_OIDC_ISSUER',
);
static String get apiBaseUrl => _configuredApiBaseUrl.isNotEmpty
? _configuredApiBaseUrl
: '${_localOrigin(8080)}/api/v1';
static String get oidcIssuer => _configuredOidcIssuer.isNotEmpty
? _configuredOidcIssuer
: 'http://localhost:8081/realms/aioa';
static String _localOrigin(int port) {
final host = defaultTargetPlatform == TargetPlatform.android
? '10.0.2.2'
: '127.0.0.1';
return 'http://$host:$port';
}
}
+85 -1
View File
@@ -10,6 +10,17 @@ class DynamicFormDefinition {
final JsonFormSchema dataSchema;
final FormUiSchema uiSchema;
factory DynamicFormDefinition.fromJson(Map<String, Object?> json) {
return DynamicFormDefinition(
dataSchema: JsonFormSchema.fromJson(
Map<String, Object?>.from(json['dataSchema']! as Map),
),
uiSchema: FormUiSchema.fromJson(
Map<String, Object?>.from(json['uiSchema']! as Map),
),
);
}
}
class JsonFormSchema {
@@ -24,6 +35,23 @@ class JsonFormSchema {
final String title;
final Map<String, JsonFieldSchema> properties;
final Set<String> required;
factory JsonFormSchema.fromJson(Map<String, Object?> json) {
final properties = Map<String, Object?>.from(json['properties']! as Map);
return JsonFormSchema(
id: json[r'$id']! as String,
title: json['title']! as String,
properties: properties.map(
(key, value) => MapEntry(
key,
JsonFieldSchema.fromJson(Map<String, Object?>.from(value as Map)),
),
),
required: ((json['required'] as List?) ?? const [])
.cast<String>()
.toSet(),
);
}
}
class JsonFieldSchema {
@@ -40,6 +68,16 @@ class JsonFieldSchema {
final List<String> enumValues;
final int? minLength;
final int? maxLength;
factory JsonFieldSchema.fromJson(Map<String, Object?> json) {
return JsonFieldSchema(
type: JsonValueType.values.byName(json['type']! as String),
format: json['format'] as String?,
enumValues: ((json['enum'] as List?) ?? const []).cast<String>(),
minLength: json['minLength'] as int?,
maxLength: json['maxLength'] as int?,
);
}
}
class FormUiSchema {
@@ -47,6 +85,19 @@ class FormUiSchema {
final String description;
final List<FormSectionSchema> sections;
factory FormUiSchema.fromJson(Map<String, Object?> json) {
return FormUiSchema(
description: json['description']! as String,
sections: (json['sections']! as List)
.map(
(value) => FormSectionSchema.fromJson(
Map<String, Object?>.from(value as Map),
),
)
.toList(),
);
}
}
class FormSectionSchema {
@@ -54,6 +105,19 @@ class FormSectionSchema {
final String title;
final List<FormControlSchema> controls;
factory FormSectionSchema.fromJson(Map<String, Object?> json) {
return FormSectionSchema(
title: json['title']! as String,
controls: (json['controls']! as List)
.map(
(value) => FormControlSchema.fromJson(
Map<String, Object?>.from(value as Map),
),
)
.toList(),
);
}
}
class FormControlSchema {
@@ -72,21 +136,41 @@ class FormControlSchema {
final String? placeholder;
final String? helperText;
final Map<String, String> optionLabels;
factory FormControlSchema.fromJson(Map<String, Object?> json) {
return FormControlSchema(
field: json['field']! as String,
label: json['label']! as String,
control: FormControlType.values.byName(json['control']! as String),
placeholder: json['placeholder'] as String?,
helperText: json['helperText'] as String?,
optionLabels: ((json['optionLabels'] as Map?) ?? const {})
.cast<String, String>(),
);
}
}
class DynamicFormState {
const DynamicFormState({this.values = const {}, this.errors = const {}});
const DynamicFormState({
this.values = const {},
this.errors = const {},
this.restoredAt,
});
final Map<String, Object?> values;
final Map<String, String> errors;
final DateTime? restoredAt;
DynamicFormState copyWith({
Map<String, Object?>? values,
Map<String, String>? errors,
DateTime? restoredAt,
bool clearRestoredAt = false,
}) {
return DynamicFormState(
values: values ?? this.values,
errors: errors ?? this.errors,
restoredAt: clearRestoredAt ? null : restoredAt ?? this.restoredAt,
);
}
}
@@ -0,0 +1,53 @@
import 'dart:convert';
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
final pushRegistrationProvider =
AsyncNotifierProvider<PushRegistrationController, bool>(
PushRegistrationController.new,
);
class PushRegistrationController extends AsyncNotifier<bool> {
@override
Future<bool> build() async {
try {
if (Firebase.apps.isEmpty) await Firebase.initializeApp();
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.denied) {
return false;
}
final token = await messaging.getToken();
if (token == null) return false;
await _upload(token);
messaging.onTokenRefresh.listen(_upload);
return true;
} catch (_) {
// 未提供 Firebase 平台配置时保留站内通知,应用仍可正常启动。
return false;
}
}
Future<void> _upload(String token) async {
final id = await ref.read(deviceRegistrationProvider).currentId();
final response = await ref
.read(authenticatedHttpClientProvider)
.put(
Uri.parse('${RuntimeConfig.apiBaseUrl}/devices/$id/push-token'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'token': token}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw http.ClientException('Push token registration failed');
}
}
}
@@ -0,0 +1,30 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/assistant/data/leave_progress_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveProgressRepositoryProvider = Provider(
(ref) => LeaveProgressRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveProgressProvider =
AsyncNotifierProvider<LeaveProgressController, ProgressAnswer?>(
LeaveProgressController.new,
);
class LeaveProgressController extends AsyncNotifier<ProgressAnswer?> {
String _question = '';
@override
Future<ProgressAnswer?> build() async => null;
Future<void> ask(String question, {String? selectedRequestId}) async {
if (selectedRequestId == null) _question = question.trim();
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref
.read(leaveProgressRepositoryProvider)
.ask(_question, selectedRequestId: selectedRequestId),
);
}
}
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class ProgressCandidate {
const ProgressCandidate({
required this.id,
required this.type,
required this.status,
required this.startsAt,
required this.endsAt,
});
final String id, type, status;
final DateTime startsAt, endsAt;
factory ProgressCandidate.fromJson(Map<String, Object?> j) =>
ProgressCandidate(
id: j['id']! as String,
type: j['type']! as String,
status: j['status']! as String,
startsAt: DateTime.parse(j['startsAt']! as String),
endsAt: DateTime.parse(j['endsAt']! as String),
);
}
class ProgressAnswer {
const ProgressAnswer({
required this.requiresSelection,
required this.candidates,
this.answer,
this.request,
this.activeTasks = const [],
this.completedTasks = const [],
this.processEnded = false,
});
final bool requiresSelection, processEnded;
final List<ProgressCandidate> candidates;
final String? answer;
final ProgressCandidate? request;
final List<String> activeTasks, completedTasks;
factory ProgressAnswer.fromJson(Map<String, Object?> j) {
final p = j['progress'] as Map<String, Object?>?;
return ProgressAnswer(
requiresSelection: j['requiresSelection']! as bool,
candidates: ((j['candidates'] as List?) ?? const [])
.map(
(e) =>
ProgressCandidate.fromJson(Map<String, Object?>.from(e as Map)),
)
.toList(),
answer: j['answer'] as String?,
request: j['request'] == null
? null
: ProgressCandidate.fromJson(
Map<String, Object?>.from(j['request']! as Map),
),
activeTasks: ((p?['activeTaskNames'] as List?) ?? const [])
.cast<String>(),
completedTasks: ((p?['completedTaskNames'] as List?) ?? const [])
.cast<String>(),
processEnded: p?['processEnded'] as bool? ?? false,
);
}
}
class LeaveProgressRepository {
LeaveProgressRepository({required this.client, required this.baseUrl});
final http.Client client;
final String baseUrl;
Future<ProgressAnswer> ask(String text, {String? selectedRequestId}) async {
final response = await client.post(
Uri.parse('$baseUrl/ai/leave-progress-answers'),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({
'text': text,
'timezone': 'Asia/Shanghai',
'selectedRequestId': ?selectedRequestId,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('查询失败(${response.statusCode}');
}
return ProgressAnswer.fromJson(
jsonDecode(response.body) as Map<String, Object?>,
);
}
}
@@ -1,9 +1,147 @@
import 'package:aioa_mobile/features/assistant/application/leave_progress_controller.dart';
import 'package:aioa_mobile/features/assistant/data/leave_progress_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
class AssistantPage extends StatelessWidget {
class AssistantPage extends ConsumerStatefulWidget {
const AssistantPage({super.key});
@override
ConsumerState<AssistantPage> createState() => _AssistantPageState();
}
class _AssistantPageState extends ConsumerState<AssistantPage> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
const Center(child: Text('AI 助手将在下一阶段接入'));
Widget build(BuildContext context) {
final result = ref.watch(leaveProgressProvider);
return Scaffold(
appBar: AppBar(title: const Text('AI 流程助手')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('询问本人请假流程进度,AI 只读查询,不会执行审批或修改申请。'),
const SizedBox(height: 12),
TextField(
controller: _controller,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: '例如:我最近提交的年假审批到哪一步了?',
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed: result.isLoading
? null
: () {
if (_controller.text.trim().isNotEmpty) {
ref
.read(leaveProgressProvider.notifier)
.ask(_controller.text);
}
},
icon: const Icon(Icons.auto_awesome),
label: const Text('查询进度'),
),
const SizedBox(height: 16),
result.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('查询失败:$e'),
),
),
data: (data) => data == null
? const SizedBox.shrink()
: _Result(
data: data,
onSelect: (id) => ref
.read(leaveProgressProvider.notifier)
.ask('', selectedRequestId: id),
),
),
],
),
);
}
}
class _Result extends StatelessWidget {
const _Result({required this.data, required this.onSelect});
final ProgressAnswer data;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
if (data.requiresSelection) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('找到多条可能的申请,请选择:'),
...data.candidates.map(
(c) => Card(
child: ListTile(
onTap: () => onSelect(c.id),
title: Text('${_type(c.type)} · ${_status(c.status)}'),
subtitle: Text(
'${DateFormat('MM-dd HH:mm').format(c.startsAt.toLocal())}${DateFormat('MM-dd HH:mm').format(c.endsAt.toLocal())}',
),
trailing: const Icon(Icons.chevron_right),
),
),
),
],
);
}
final request = data.request!;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_type(request.type)} · ${_status(request.status)}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
Text(data.answer ?? ''),
if (data.activeTasks.isNotEmpty) ...[
const SizedBox(height: 12),
Text('当前节点:${data.activeTasks.join('')}'),
],
if (data.completedTasks.isNotEmpty)
Text('已完成:${data.completedTasks.join('')}'),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push('/leave/${request.id}'),
icon: const Icon(Icons.open_in_new),
label: const Text('查看申请详情'),
),
],
),
),
);
}
static String _type(String v) =>
{'PERSONAL': '事假', 'SICK': '病假', 'ANNUAL': '年假'}[v] ?? v;
static String _status(String v) =>
{
'DRAFT': '草稿',
'PENDING': '审批中',
'APPROVED': '已通过',
'REJECTED': '已驳回',
'WITHDRAWN': '已撤回',
}[v] ??
v;
}
@@ -0,0 +1,12 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final aiLeaveSuggestionRepositoryProvider =
Provider<AiLeaveSuggestionRepository>(
(ref) => AiLeaveSuggestionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
@@ -0,0 +1,15 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final formDefinitionRepositoryProvider = Provider<FormDefinitionRepository>(
(ref) => FormDefinitionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveFormDefinitionProvider = FutureProvider<LoadedFormDefinition>((ref) {
return ref.watch(formDefinitionRepositoryProvider).loadLeaveRequest();
});
@@ -0,0 +1,97 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'dart:typed_data';
import 'package:aioa_mobile/features/form/data/leave_attachment_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveAttachmentRepositoryProvider = Provider<LeaveAttachmentRepository>(
(ref) => LeaveAttachmentRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
class LeaveAttachmentState {
const LeaveAttachmentState({
this.items = const [],
this.uploading = false,
this.progress = 0,
this.error,
});
final List<LeaveAttachmentItem> items;
final bool uploading;
final double progress;
final String? error;
LeaveAttachmentState copyWith({
List<LeaveAttachmentItem>? items,
bool? uploading,
double? progress,
String? error,
bool clearError = false,
}) => LeaveAttachmentState(
items: items ?? this.items,
uploading: uploading ?? this.uploading,
progress: progress ?? this.progress,
error: clearError ? null : error ?? this.error,
);
}
final leaveAttachmentProvider =
NotifierProvider.family<
LeaveAttachmentController,
LeaveAttachmentState,
String
>((leaveRequestId) => LeaveAttachmentController(leaveRequestId));
class LeaveAttachmentController extends Notifier<LeaveAttachmentState> {
LeaveAttachmentController(this._leaveRequestId);
final String _leaveRequestId;
@override
LeaveAttachmentState build() => const LeaveAttachmentState();
Future<void> load() async {
try {
final items = await ref
.read(leaveAttachmentRepositoryProvider)
.list(_leaveRequestId);
state = state.copyWith(items: items, clearError: true);
} on LeaveAttachmentException catch (error) {
state = state.copyWith(error: error.message);
}
}
Future<void> upload({
required String fileName,
required String contentType,
required Uint8List bytes,
}) async {
if (state.uploading) return;
state = state.copyWith(uploading: true, progress: 0, clearError: true);
try {
final item = await ref
.read(leaveAttachmentRepositoryProvider)
.upload(
leaveRequestId: _leaveRequestId,
fileName: fileName,
contentType: contentType,
bytes: bytes,
onProgress: (progress) {
state = state.copyWith(progress: progress);
},
);
state = state.copyWith(
items: [...state.items, item],
uploading: false,
progress: 1,
clearError: true,
);
} on LeaveAttachmentException catch (error) {
state = state.copyWith(uploading: false, error: error.message);
}
}
}
@@ -1,21 +1,47 @@
import 'dart:async';
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:aioa_mobile/core/forms/schema/form_validator.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:aioa_mobile/features/form/data/leave_local_draft_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveLocalDraftRepositoryProvider = Provider<LeaveLocalDraftRepository>(
(ref) => LeaveLocalDraftRepository(),
);
final leaveDraftProvider =
NotifierProvider<LeaveDraftController, DynamicFormState>(
LeaveDraftController.new,
);
class LeaveDraftController extends Notifier<DynamicFormState> {
late final LeaveLocalDraftRepository _repository;
@override
DynamicFormState build() => const DynamicFormState();
DynamicFormState build() {
_repository = ref.watch(leaveLocalDraftRepositoryProvider);
unawaited(_restore());
return const DynamicFormState();
}
Future<void> _restore() async {
final restored = await _repository.load();
if (restored == null || state.values.isNotEmpty) return;
state = DynamicFormState(
values: restored.values,
restoredAt: restored.savedAt,
);
}
void setValue(String field, Object? value) {
final values = {...state.values, field: value};
final errors = {...state.errors}..remove(field);
state = state.copyWith(values: values, errors: errors);
state = state.copyWith(
values: values,
errors: errors,
clearRestoredAt: true,
);
unawaited(_repository.save(values));
}
void applyAiSuggestion() {
@@ -36,13 +62,22 @@ class LeaveDraftController extends Notifier<DynamicFormState> {
'reason': '办理个人事务,已提前完成工作交接。',
},
);
unawaited(_repository.save(state.values));
}
bool validate() {
final errors = validateDynamicForm(
leaveFormDefinition.dataSchema,
state.values,
);
void applySuggestion(Map<String, Object?> suggestion) {
final values = {...state.values, ...suggestion};
state = DynamicFormState(values: values);
unawaited(_repository.save(values));
}
Future<void> clear() async {
await _repository.clear();
state = const DynamicFormState();
}
bool validate(JsonFormSchema schema) {
final errors = validateDynamicForm(schema, state.values);
final startsAt = DateTime.tryParse(
state.values['startsAt'] as String? ?? '',
);
@@ -0,0 +1,44 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/leave_draft_submission_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveDraftSubmissionRepositoryProvider =
Provider<LeaveDraftSubmissionRepository>(
(ref) => LeaveDraftSubmissionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveSubmissionProvider =
NotifierProvider<LeaveSubmissionController, LeaveSubmissionState>(
LeaveSubmissionController.new,
);
class LeaveSubmissionState {
const LeaveSubmissionState({this.submitting = false, this.error});
final bool submitting;
final String? error;
}
class LeaveSubmissionController extends Notifier<LeaveSubmissionState> {
@override
LeaveSubmissionState build() => const LeaveSubmissionState();
Future<CreatedLeaveDraft?> submit(Map<String, Object?> values) async {
if (state.submitting) return null;
state = const LeaveSubmissionState(submitting: true);
try {
final created = await ref
.read(leaveDraftSubmissionRepositoryProvider)
.create(values);
state = const LeaveSubmissionState();
return created;
} on LeaveDraftSubmissionException catch (error) {
state = LeaveSubmissionState(error: error.message);
return null;
}
}
}
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class AiLeaveSuggestion {
const AiLeaveSuggestion({
required this.values,
required this.assumptions,
required this.needsClarification,
required this.model,
});
final Map<String, Object?> values;
final List<String> assumptions;
final List<String> needsClarification;
final String model;
}
class AiLeaveSuggestionException implements Exception {
const AiLeaveSuggestionException(this.message);
final String message;
@override
String toString() => message;
}
class AiLeaveSuggestionRepository {
AiLeaveSuggestionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<AiLeaveSuggestion> suggest(String text) async {
final response = await _client.post(
Uri.parse('$baseUrl/ai/leave-draft-suggestions'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({'text': text.trim(), 'timezone': 'Asia/Shanghai'}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
String? message;
try {
message =
(jsonDecode(response.body) as Map<String, Object?>)['detail']
as String?;
} catch (_) {}
throw AiLeaveSuggestionException(
message ?? 'AI 建议生成失败(${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
if (json['requiresUserConfirmation'] != true) {
throw const AiLeaveSuggestionException('AI 响应缺少用户确认保护');
}
final suggestion = Map<String, Object?>.from(json['suggestion']! as Map);
return AiLeaveSuggestion(
values: {
for (final key in const ['type', 'startsAt', 'endsAt', 'reason'])
if (suggestion[key] != null) key: suggestion[key],
},
assumptions: ((suggestion['assumptions'] as List?) ?? const [])
.cast<String>(),
needsClarification:
((suggestion['needsClarification'] as List?) ?? const [])
.cast<String>(),
model: json['model']! as String,
);
}
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
enum FormDefinitionSource { remote, cache, bundled }
class LoadedFormDefinition {
const LoadedFormDefinition({required this.definition, required this.source});
final DynamicFormDefinition definition;
final FormDefinitionSource source;
}
class FormDefinitionRepository {
FormDefinitionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
static const _cacheKey = 'form-definition.leave-request.v1';
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<LoadedFormDefinition> loadLeaveRequest() async {
final preferences = await SharedPreferences.getInstance();
try {
final response = await _client
.get(
Uri.parse('$baseUrl/form-definitions/leave-request'),
headers: {
'Accept': 'application/json',
if (accessToken.isNotEmpty)
'Authorization': 'Bearer $accessToken',
},
)
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
throw http.ClientException(
'Form definition request failed: ${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
final definition = DynamicFormDefinition.fromJson(json);
try {
await preferences.setString(_cacheKey, response.body);
} catch (_) {
// A valid remote definition remains usable even if local persistence
// is temporarily unavailable.
}
return LoadedFormDefinition(
definition: definition,
source: FormDefinitionSource.remote,
);
} catch (_) {
final cached = preferences.getString(_cacheKey);
if (cached != null) {
try {
return LoadedFormDefinition(
definition: DynamicFormDefinition.fromJson(
jsonDecode(cached) as Map<String, Object?>,
),
source: FormDefinitionSource.cache,
);
} catch (_) {
await preferences.remove(_cacheKey);
}
}
return const LoadedFormDefinition(
definition: leaveFormDefinition,
source: FormDefinitionSource.bundled,
);
}
}
}
@@ -0,0 +1,147 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
class LeaveAttachmentItem {
const LeaveAttachmentItem({
required this.id,
required this.fileName,
required this.contentType,
required this.sizeBytes,
required this.status,
});
final String id;
final String fileName;
final String contentType;
final int sizeBytes;
final String status;
factory LeaveAttachmentItem.fromJson(Map<String, Object?> json) {
return LeaveAttachmentItem(
id: json['id']! as String,
fileName: json['fileName']! as String,
contentType: json['contentType']! as String,
sizeBytes: json['sizeBytes']! as int,
status: json['status']! as String,
);
}
}
class LeaveAttachmentException implements Exception {
const LeaveAttachmentException(this.message);
final String message;
@override
String toString() => message;
}
class LeaveAttachmentRepository {
LeaveAttachmentRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<LeaveAttachmentItem> upload({
required String leaveRequestId,
required String fileName,
required String contentType,
required Uint8List bytes,
required void Function(double progress) onProgress,
}) async {
if (bytes.isEmpty || bytes.length > 10 * 1024 * 1024) {
throw const LeaveAttachmentException('附件大小必须在 1 字节到 10 MB 之间');
}
final taskResponse = await _client.post(
Uri.parse(
'$baseUrl/leave-requests/$leaveRequestId/attachments/upload-tasks',
),
headers: _jsonHeaders,
body: jsonEncode({
'fileName': fileName,
'contentType': contentType,
'sizeBytes': bytes.length,
}),
);
_requireSuccess(taskResponse, {201});
final task = jsonDecode(taskResponse.body) as Map<String, Object?>;
final attachment = Map<String, Object?>.from(task['attachment']! as Map);
final attachmentId = attachment['id']! as String;
final uploadRequest = http.StreamedRequest(
'PUT',
Uri.parse(task['uploadUrl']! as String),
);
uploadRequest.headers['Content-Type'] = contentType;
uploadRequest.contentLength = bytes.length;
final uploadResponseFuture = _client.send(uploadRequest);
const chunkSize = 64 * 1024;
var sent = 0;
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = (offset + chunkSize).clamp(0, bytes.length);
uploadRequest.sink.add(bytes.sublist(offset, end));
sent = end;
onProgress(sent / bytes.length);
}
await uploadRequest.sink.close();
final uploadResponse = await uploadResponseFuture;
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
throw LeaveAttachmentException('对象存储上传失败(${uploadResponse.statusCode}');
}
final completeResponse = await _client.post(
Uri.parse(
'$baseUrl/leave-requests/$leaveRequestId/attachments/$attachmentId/complete',
),
headers: _authHeaders,
);
_requireSuccess(completeResponse, {200});
onProgress(1);
return LeaveAttachmentItem.fromJson(
jsonDecode(completeResponse.body) as Map<String, Object?>,
);
}
Future<List<LeaveAttachmentItem>> list(String leaveRequestId) async {
final response = await _client.get(
Uri.parse('$baseUrl/leave-requests/$leaveRequestId/attachments'),
headers: _authHeaders,
);
_requireSuccess(response, {200});
return (jsonDecode(response.body) as List)
.map(
(item) => LeaveAttachmentItem.fromJson(
Map<String, Object?>.from(item as Map),
),
)
.toList();
}
Map<String, String> get _authHeaders => {
'Accept': 'application/json',
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
};
Map<String, String> get _jsonHeaders => {
..._authHeaders,
'Content-Type': 'application/json',
};
void _requireSuccess(http.Response response, Set<int> expected) {
if (expected.contains(response.statusCode)) return;
String? message;
try {
final problem = jsonDecode(response.body) as Map<String, Object?>;
message = problem['detail'] as String?;
} catch (_) {}
throw LeaveAttachmentException(message ?? '附件请求失败(${response.statusCode}');
}
}
@@ -0,0 +1,144 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class CreatedLeaveDraft {
const CreatedLeaveDraft({
required this.id,
required this.status,
required this.version,
});
final String id;
final String status;
final int version;
}
class LeaveDraftSubmissionException implements Exception {
const LeaveDraftSubmissionException(this.message, {this.retryable = true});
final String message;
final bool retryable;
@override
String toString() => message;
}
class LeaveDraftSubmissionRepository {
LeaveDraftSubmissionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
static const pendingStorageKey = 'leave-request.pending-create.v1';
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<CreatedLeaveDraft> create(Map<String, Object?> values) async {
final requestBody = _requestBody(values);
final payload = jsonEncode(requestBody);
final preferences = await SharedPreferences.getInstance();
final pending = _readPending(preferences);
final idempotencyKey = pending?.payload == payload
? pending!.idempotencyKey
: _newIdempotencyKey();
await preferences.setString(
pendingStorageKey,
jsonEncode({'idempotencyKey': idempotencyKey, 'payload': payload}),
);
late http.Response response;
try {
response = await _client
.post(
Uri.parse('$baseUrl/leave-requests/drafts'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
'Idempotency-Key': idempotencyKey,
},
body: payload,
)
.timeout(const Duration(seconds: 10));
} catch (_) {
throw const LeaveDraftSubmissionException('网络不可用,已保存请求,可安全重试');
}
if (response.statusCode != 200 && response.statusCode != 201) {
final message =
_problemMessage(response.body) ?? '创建草稿失败(${response.statusCode}';
final retryable =
response.statusCode >= 500 || response.statusCode == 401;
if (!retryable) await preferences.remove(pendingStorageKey);
throw LeaveDraftSubmissionException(message, retryable: retryable);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
await preferences.remove(pendingStorageKey);
return CreatedLeaveDraft(
id: json['id']! as String,
status: json['status']! as String,
version: json['version']! as int,
);
}
Map<String, Object?> _requestBody(Map<String, Object?> values) => {
'type': values['type'],
'startsAt': values['startsAt'],
'endsAt': values['endsAt'],
'reason': values['reason'],
'version': 0,
};
_PendingSubmission? _readPending(SharedPreferences preferences) {
final encoded = preferences.getString(pendingStorageKey);
if (encoded == null) return null;
try {
final json = jsonDecode(encoded) as Map<String, Object?>;
return _PendingSubmission(
idempotencyKey: json['idempotencyKey']! as String,
payload: json['payload']! as String,
);
} catch (_) {
preferences.remove(pendingStorageKey);
return null;
}
}
String _newIdempotencyKey() {
final random = Random.secure();
final entropy = List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
return 'leave-${DateTime.now().microsecondsSinceEpoch}-$entropy';
}
String? _problemMessage(String body) {
try {
final problem = jsonDecode(body) as Map<String, Object?>;
return problem['detail'] as String? ?? problem['title'] as String?;
} catch (_) {
return null;
}
}
}
class _PendingSubmission {
const _PendingSubmission({
required this.idempotencyKey,
required this.payload,
});
final String idempotencyKey;
final String payload;
}
@@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class RestoredLeaveDraft {
const RestoredLeaveDraft({required this.values, required this.savedAt});
final Map<String, Object?> values;
final DateTime savedAt;
}
class LeaveLocalDraftRepository {
static const storageKey = 'leave-request.local-draft.v1';
Future<void> save(Map<String, Object?> values) async {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(
storageKey,
jsonEncode({
'version': 1,
'savedAt': DateTime.now().toUtc().toIso8601String(),
'values': values,
}),
);
}
Future<RestoredLeaveDraft?> load() async {
final preferences = await SharedPreferences.getInstance();
final encoded = preferences.getString(storageKey);
if (encoded == null) return null;
try {
final envelope = jsonDecode(encoded) as Map<String, Object?>;
if (envelope['version'] != 1) throw const FormatException();
final savedAt = DateTime.parse(envelope['savedAt']! as String);
final values = Map<String, Object?>.from(envelope['values']! as Map);
return RestoredLeaveDraft(values: values, savedAt: savedAt);
} catch (_) {
await preferences.remove(storageKey);
return null;
}
}
Future<void> clear() async {
final preferences = await SharedPreferences.getInstance();
await preferences.remove(storageKey);
}
}
@@ -0,0 +1,138 @@
import 'package:aioa_mobile/features/form/application/leave_attachment_controller.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LeaveAttachmentSheet extends ConsumerStatefulWidget {
const LeaveAttachmentSheet({required this.leaveRequestId, super.key});
final String leaveRequestId;
@override
ConsumerState<LeaveAttachmentSheet> createState() =>
_LeaveAttachmentSheetState();
}
class _LeaveAttachmentSheetState extends ConsumerState<LeaveAttachmentSheet> {
@override
void initState() {
super.initState();
Future.microtask(
() => ref
.read(leaveAttachmentProvider(widget.leaveRequestId).notifier)
.load(),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(leaveAttachmentProvider(widget.leaveRequestId));
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
20 + MediaQuery.viewInsetsOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'添加附件',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
const Text('支持 JPEG、PNG、PDF,单个文件不超过 10 MB。文件将直接上传至对象存储。'),
if (state.error != null) ...[
const SizedBox(height: 10),
Text(
state.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (state.uploading) ...[
const SizedBox(height: 14),
LinearProgressIndicator(value: state.progress),
const SizedBox(height: 6),
Text('正在上传 ${(state.progress * 100).round()}%'),
],
if (state.items.isNotEmpty) ...[
const SizedBox(height: 14),
for (final item in state.items)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
item.contentType == 'application/pdf'
? Icons.picture_as_pdf_outlined
: Icons.image_outlined,
),
title: Text(
item.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${_formatBytes(item.sizeBytes)} · ${item.status == 'READY' ? '已上传' : '处理中'}',
),
trailing: item.status == 'READY'
? const Icon(Icons.check_circle, color: Colors.green)
: null,
),
],
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: state.uploading ? null : _pickAndUpload,
icon: const Icon(Icons.attach_file),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('选择附件'),
),
),
const SizedBox(height: 8),
FilledButton(
onPressed: state.uploading ? null : () => Navigator.pop(context),
child: const Text('完成'),
),
],
),
),
);
}
Future<void> _pickAndUpload() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: const ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result == null || !mounted) return;
final file = result.files.single;
final bytes = file.bytes;
if (bytes == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法读取所选文件')));
return;
}
final contentType = switch (file.extension?.toLowerCase()) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'pdf' => 'application/pdf',
_ => null,
};
if (contentType == null) return;
await ref
.read(leaveAttachmentProvider(widget.leaveRequestId).notifier)
.upload(fileName: file.name, contentType: contentType, bytes: bytes);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
@@ -1,8 +1,13 @@
import 'dart:convert';
import 'package:aioa_mobile/core/forms/presentation/dynamic_form_card.dart';
import 'package:aioa_mobile/features/form/application/form_definition_controller.dart';
import 'package:aioa_mobile/features/form/application/ai_leave_suggestion_provider.dart';
import 'package:aioa_mobile/features/form/application/leave_draft_controller.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:aioa_mobile/features/form/application/leave_submission_controller.dart';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:aioa_mobile/features/form/presentation/leave_attachment_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -13,51 +18,154 @@ class LeaveFormPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final formState = ref.watch(leaveDraftProvider);
final controller = ref.read(leaveDraftProvider.notifier);
final loadedDefinition = ref.watch(leaveFormDefinitionProvider);
final submission = ref.watch(leaveSubmissionProvider);
return Scaffold(
appBar: AppBar(title: Text(leaveFormDefinition.dataSchema.title)),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_AiAssistCard(onApply: controller.applyAiSuggestion),
const SizedBox(height: 14),
DynamicFormCard(
definition: leaveFormDefinition,
state: formState,
onChanged: controller.setValue,
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () {
if (!controller.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
);
return;
}
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => _ConfirmationSheet(
values: ref.read(leaveDraftProvider).values,
),
);
},
icon: const Icon(Icons.check_circle_outline),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('检查并确认'),
appBar: AppBar(title: const Text('请假申请')),
body: loadedDefinition.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('表单定义加载失败:$error')),
data: (loaded) => ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_DefinitionSourceBanner(source: loaded.source),
if (formState.restoredAt != null) ...[
const SizedBox(height: 8),
_RestoredDraftBanner(onClear: controller.clear),
],
const SizedBox(height: 8),
_AiAssistCard(onApply: controller.applySuggestion),
const SizedBox(height: 14),
DynamicFormCard(
definition: loaded.definition,
state: formState,
onChanged: controller.setValue,
),
),
],
const SizedBox(height: 8),
FilledButton.icon(
onPressed: submission.submitting
? null
: () async {
if (!controller.validate(loaded.definition.dataSchema)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
);
return;
}
final confirmed = await showModalBottomSheet<bool>(
context: context,
showDragHandle: true,
builder: (context) => _ConfirmationSheet(
values: ref.read(leaveDraftProvider).values,
),
);
if (confirmed != true || !context.mounted) return;
final created = await ref
.read(leaveSubmissionProvider.notifier)
.submit(ref.read(leaveDraftProvider).values);
if (!context.mounted) return;
if (created == null) {
final message = ref.read(leaveSubmissionProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message ?? '创建草稿失败')),
);
return;
}
await controller.clear();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('后端草稿已创建:${created.id}')),
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
LeaveAttachmentSheet(leaveRequestId: created.id),
);
},
icon: submission.submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check_circle_outline),
label: Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text(submission.submitting ? '正在创建草稿…' : '检查并确认'),
),
),
],
),
),
);
}
}
class _AiAssistCard extends StatelessWidget {
class _RestoredDraftBanner extends StatelessWidget {
const _RestoredDraftBanner({required this.onClear});
final Future<void> Function() onClear;
@override
Widget build(BuildContext context) {
return MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
leading: const Icon(Icons.restore),
content: const Text('已恢复上次未完成的本地草稿'),
actions: [TextButton(onPressed: onClear, child: const Text('清除'))],
);
}
}
class _DefinitionSourceBanner extends StatelessWidget {
const _DefinitionSourceBanner({required this.source});
final FormDefinitionSource source;
@override
Widget build(BuildContext context) {
final (icon, text) = switch (source) {
FormDefinitionSource.remote => (Icons.cloud_done_outlined, '已加载最新表单定义'),
FormDefinitionSource.cache => (
Icons.offline_bolt_outlined,
'当前离线,使用已缓存表单定义',
),
FormDefinitionSource.bundled => (
Icons.inventory_2_outlined,
'当前离线,使用内置安全表单定义',
),
};
return Row(
children: [
Icon(icon, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(text, style: Theme.of(context).textTheme.bodySmall),
),
],
);
}
}
class _AiAssistCard extends ConsumerStatefulWidget {
const _AiAssistCard({required this.onApply});
final VoidCallback onApply;
final void Function(Map<String, Object?> values) onApply;
@override
ConsumerState<_AiAssistCard> createState() => _AiAssistCardState();
}
class _AiAssistCardState extends ConsumerState<_AiAssistCard> {
final textController = TextEditingController(text: '明天下午请事假四小时,办理个人事务');
bool loading = false;
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -67,29 +175,75 @@ class _AiAssistCard extends StatelessWidget {
).colorScheme.primaryContainer.withValues(alpha: 0.45),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CircleAvatar(child: Icon(Icons.auto_awesome)),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI 表单助手',
const Row(
children: [
CircleAvatar(child: Icon(Icons.auto_awesome)),
SizedBox(width: 12),
Expanded(
child: Text(
'千问表单助手',
style: TextStyle(fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
Text('示例:帮我填写明天下午的事假申请'),
],
),
],
),
const SizedBox(height: 12),
TextField(
controller: textController,
minLines: 2,
maxLines: 4,
maxLength: 2000,
decoration: const InputDecoration(
hintText: '例如:明天下午请事假四小时,办理个人事务',
border: OutlineInputBorder(),
),
),
TextButton(onPressed: onApply, child: const Text('自动填写')),
FilledButton.icon(
onPressed: loading ? null : _suggest,
icon: loading
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: Text(loading ? '正在生成建议…' : '生成草稿建议'),
),
],
),
),
);
}
Future<void> _suggest() async {
if (textController.text.trim().isEmpty) return;
setState(() => loading = true);
try {
final suggestion = await ref
.read(aiLeaveSuggestionRepositoryProvider)
.suggest(textController.text);
if (!mounted) return;
widget.onApply(suggestion.values);
final notes = [
...suggestion.assumptions,
...suggestion.needsClarification,
];
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(notes.isEmpty ? 'AI 建议已填入,请检查并确认' : notes.join('')),
),
);
} on AiLeaveSuggestionException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
} finally {
if (mounted) setState(() => loading = false);
}
}
}
class _ConfirmationSheet extends StatelessWidget {
@@ -128,10 +282,7 @@ class _ConfirmationSheet extends StatelessWidget {
const SizedBox(height: 16),
FilledButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('演示模式:草稿已通过本地校验,尚未调用后端')),
);
Navigator.pop(context, true);
},
child: const Text('确认创建草稿'),
),
@@ -0,0 +1,40 @@
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/profile/data/device_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final deviceRepositoryProvider = Provider(
(ref) => DeviceRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final deviceListProvider =
AsyncNotifierProvider<DeviceController, List<UserDeviceItem>>(
DeviceController.new,
);
class DeviceController extends AsyncNotifier<List<UserDeviceItem>> {
@override
Future<List<UserDeviceItem>> build() =>
ref.read(deviceRepositoryProvider).list();
Future<String?> revoke(String id) async {
try {
final repository = ref.read(deviceRepositoryProvider);
final current = await repository.isCurrent(id);
await repository.revoke(id);
if (current) {
await ref.read(authSessionProvider.notifier).invalidateDeviceSession();
} else {
state = AsyncData([
for (final item in state.value ?? const <UserDeviceItem>[])
if (item.id != id) item,
]);
}
return null;
} catch (error) {
return error.toString();
}
}
}
@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
class UserDeviceItem {
const UserDeviceItem({
required this.id,
required this.name,
required this.platform,
required this.status,
required this.lastSeenAt,
});
final String id, name, platform, status;
final DateTime lastSeenAt;
factory UserDeviceItem.fromJson(Map<String, Object?> json) => UserDeviceItem(
id: json['id']! as String,
name: json['name']! as String,
platform: json['platform']! as String,
status: json['status']! as String,
lastSeenAt: DateTime.parse(json['lastSeenAt']! as String),
);
}
class DeviceRepository {
DeviceRepository({required this.client, required this.baseUrl});
static const _storage = FlutterSecureStorage();
final http.Client client;
final String baseUrl;
Future<List<UserDeviceItem>> list() async {
final response = await client.get(Uri.parse('$baseUrl/devices'));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('设备列表加载失败');
}
return (jsonDecode(response.body) as List)
.map(
(item) =>
UserDeviceItem.fromJson(Map<String, Object?>.from(item as Map)),
)
.toList();
}
Future<void> revoke(String id) async {
final response = await client.delete(Uri.parse('$baseUrl/devices/$id'));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('撤销设备失败');
}
}
Future<bool> isCurrent(String id) async =>
await _storage.read(key: 'device_id') == id;
}
@@ -1,9 +1,75 @@
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:aioa_mobile/features/profile/application/device_controller.dart';
import 'package:intl/intl.dart';
class ProfilePage extends StatelessWidget {
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) =>
const Center(child: Text('员工小明 · 产品研发部'));
Widget build(BuildContext context, WidgetRef ref) {
final devices = ref.watch(deviceListProvider);
return ListView(
padding: const EdgeInsets.all(18),
children: [
const Card(
child: ListTile(
leading: CircleAvatar(child: Icon(Icons.person_outline)),
title: Text('企业账号'),
subtitle: Text('身份由 Keycloak OIDC 管理'),
),
),
const SizedBox(height: 12),
Text('登录设备', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...devices.when(
loading: () => const [Center(child: CircularProgressIndicator())],
error: (error, _) => [
Card(
child: ListTile(
title: const Text('设备列表加载失败'),
subtitle: Text('$error'),
),
),
],
data: (items) => items
.map(
(device) => Card(
child: ListTile(
leading: Icon(
device.platform == 'IOS'
? Icons.phone_iphone
: Icons.phone_android,
),
title: Text(device.name),
subtitle: Text(
'${device.status == 'ACTIVE' ? '已登录' : '已撤销'} · ${DateFormat('MM-dd HH:mm').format(device.lastSeenAt.toLocal())}',
),
trailing: device.status != 'ACTIVE'
? null
: IconButton(
tooltip: '撤销并退出',
icon: const Icon(Icons.logout),
onPressed: () => ref
.read(deviceListProvider.notifier)
.revoke(device.id),
),
),
),
)
.toList(),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: ref.read(authSessionProvider.notifier).logout,
icon: const Icon(Icons.logout),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('安全退出'),
),
),
],
);
}
}
@@ -0,0 +1,80 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveRequestRepositoryProvider = Provider<LeaveRequestRepository>(
(ref) => LeaveRequestRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveRequestListProvider =
AsyncNotifierProvider<LeaveRequestListController, List<LeaveRequestItem>>(
LeaveRequestListController.new,
);
class LeaveRequestListController extends AsyncNotifier<List<LeaveRequestItem>> {
@override
Future<List<LeaveRequestItem>> build() =>
ref.read(leaveRequestRepositoryProvider).list();
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(leaveRequestRepositoryProvider).list(),
);
}
}
class LeaveRequestDetail {
const LeaveRequestDetail({required this.request, required this.timeline});
final LeaveRequestItem request;
final List<LeaveTimelineEvent> timeline;
}
final leaveRequestDetailProvider =
AsyncNotifierProvider.family<
LeaveRequestDetailController,
LeaveRequestDetail,
String
>((id) => LeaveRequestDetailController(id));
class LeaveRequestDetailController extends AsyncNotifier<LeaveRequestDetail> {
LeaveRequestDetailController(this.id);
final String id;
@override
Future<LeaveRequestDetail> build() async {
final repository = ref.read(leaveRequestRepositoryProvider);
final results = await Future.wait([
repository.get(id),
repository.timeline(id),
]);
return LeaveRequestDetail(
request: results[0] as LeaveRequestItem,
timeline: results[1] as List<LeaveTimelineEvent>,
);
}
Future<String?> transition(String action) async {
final current = state.value;
if (current == null) return '申请尚未加载完成';
try {
final updated = await ref
.read(leaveRequestRepositoryProvider)
.transition(current.request, action);
final timeline = await ref
.read(leaveRequestRepositoryProvider)
.timeline(id);
state = AsyncData(
LeaveRequestDetail(request: updated, timeline: timeline),
);
ref.invalidate(leaveRequestListProvider);
return null;
} on LeaveRequestException catch (error) {
return error.message;
}
}
}
@@ -0,0 +1,213 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class LeaveRequestItem {
const LeaveRequestItem({
required this.id,
required this.type,
required this.startsAt,
required this.endsAt,
required this.reason,
required this.status,
required this.version,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String type;
final DateTime startsAt;
final DateTime endsAt;
final String reason;
final String status;
final int version;
final DateTime createdAt;
final DateTime updatedAt;
factory LeaveRequestItem.fromJson(Map<String, Object?> json) =>
LeaveRequestItem(
id: json['id']! as String,
type: json['type']! as String,
startsAt: DateTime.parse(json['startsAt']! as String),
endsAt: DateTime.parse(json['endsAt']! as String),
reason: json['reason']! as String,
status: json['status']! as String,
version: json['version']! as int,
createdAt: DateTime.parse(json['createdAt']! as String),
updatedAt: DateTime.parse(json['updatedAt']! as String),
);
}
class LeaveTimelineEvent {
const LeaveTimelineEvent({
required this.id,
required this.eventType,
required this.fromStatus,
required this.toStatus,
required this.occurredAt,
});
final String id;
final String eventType;
final String fromStatus;
final String toStatus;
final DateTime occurredAt;
factory LeaveTimelineEvent.fromJson(Map<String, Object?> json) =>
LeaveTimelineEvent(
id: json['id']! as String,
eventType: json['eventType']! as String,
fromStatus: json['fromStatus']! as String,
toStatus: json['toStatus']! as String,
occurredAt: DateTime.parse(json['occurredAt']! as String),
);
}
class LeaveRequestException implements Exception {
const LeaveRequestException(this.message);
final String message;
@override
String toString() => message;
}
class LeaveRequestRepository {
LeaveRequestRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<List<LeaveRequestItem>> list() async {
final response = await _client.get(
Uri.parse('$baseUrl/leave-requests'),
headers: _headers,
);
_requireSuccess(response);
return (jsonDecode(response.body) as List)
.map(
(item) =>
LeaveRequestItem.fromJson(Map<String, Object?>.from(item as Map)),
)
.toList();
}
Future<LeaveRequestItem> get(String id) async {
final response = await _client.get(
Uri.parse('$baseUrl/leave-requests/$id'),
headers: _headers,
);
_requireSuccess(response);
return LeaveRequestItem.fromJson(
jsonDecode(response.body) as Map<String, Object?>,
);
}
Future<List<LeaveTimelineEvent>> timeline(String id) async {
final response = await _client.get(
Uri.parse('$baseUrl/leave-requests/$id/timeline'),
headers: _headers,
);
_requireSuccess(response);
return (jsonDecode(response.body) as List)
.map(
(item) => LeaveTimelineEvent.fromJson(
Map<String, Object?>.from(item as Map),
),
)
.toList();
}
Future<LeaveRequestItem> transition(
LeaveRequestItem request,
String action,
) async {
final payload = jsonEncode({'version': request.version});
final preferences = await SharedPreferences.getInstance();
final storageKey = 'leave-transition.${request.id}.$action';
final existing = preferences.getString(storageKey);
final pending = existing == null ? null : _decodePending(existing);
final key = pending?.payload == payload ? pending!.key : _newKey(action);
await preferences.setString(
storageKey,
jsonEncode({'key': key, 'payload': payload}),
);
late http.Response response;
try {
response = await _client.post(
Uri.parse('$baseUrl/leave-requests/${request.id}/$action'),
headers: {
..._headers,
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: payload,
);
} catch (_) {
throw const LeaveRequestException('网络不可用,操作已保存,可安全重试');
}
if (response.statusCode < 200 || response.statusCode >= 300) {
if (response.statusCode < 500 && response.statusCode != 401) {
await preferences.remove(storageKey);
}
_requireSuccess(response);
}
await preferences.remove(storageKey);
return LeaveRequestItem.fromJson(
jsonDecode(response.body) as Map<String, Object?>,
);
}
Map<String, String> get _headers => {
'Accept': 'application/json',
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
};
void _requireSuccess(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) return;
String? message;
try {
message =
(jsonDecode(response.body) as Map<String, Object?>)['detail']
as String?;
} catch (_) {}
throw LeaveRequestException(message ?? '申请请求失败(${response.statusCode}');
}
_PendingTransition? _decodePending(String value) {
try {
final json = jsonDecode(value) as Map<String, Object?>;
return _PendingTransition(
json['key']! as String,
json['payload']! as String,
);
} catch (_) {
return null;
}
}
String _newKey(String action) {
final random = Random.secure();
final entropy = List.generate(
12,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
return 'leave-$action-${DateTime.now().microsecondsSinceEpoch}-$entropy';
}
}
class _PendingTransition {
const _PendingTransition(this.key, this.payload);
final String key;
final String payload;
}
@@ -0,0 +1,187 @@
import 'package:aioa_mobile/features/requests/application/leave_request_controller.dart';
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
class LeaveRequestDetailPage extends ConsumerWidget {
const LeaveRequestDetailPage({required this.id, super.key});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(leaveRequestDetailProvider(id));
return Scaffold(
appBar: AppBar(title: const Text('申请详情')),
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('详情加载失败:$error')),
data: (value) => _DetailBody(id: id, detail: value),
),
);
}
}
class _DetailBody extends ConsumerWidget {
const _DetailBody({required this.id, required this.detail});
final String id;
final LeaveRequestDetail detail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final request = detail.request;
final formatter = DateFormat('yyyy-MM-dd HH:mm');
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_statusLabel(request.status),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 14),
_row('请假类型', _typeLabel(request.type)),
_row('开始时间', formatter.format(request.startsAt.toLocal())),
_row('结束时间', formatter.format(request.endsAt.toLocal())),
_row('请假原因', request.reason),
],
),
),
),
const SizedBox(height: 14),
Text(
'流程时间线',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
if (detail.timeline.isEmpty)
const Card(
child: ListTile(
leading: Icon(Icons.edit_note),
title: Text('草稿已创建'),
),
)
else
for (final event in detail.timeline) _TimelineTile(event: event),
const SizedBox(height: 16),
if (request.status == 'DRAFT')
FilledButton.icon(
onPressed: () => _transition(context, ref, 'submit'),
icon: const Icon(Icons.send_outlined),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('提交审批'),
),
),
if (request.status == 'PENDING')
OutlinedButton.icon(
onPressed: () => _transition(context, ref, 'withdraw'),
icon: const Icon(Icons.undo),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('撤回申请'),
),
),
],
);
}
Future<void> _transition(
BuildContext context,
WidgetRef ref,
String action,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(action == 'submit' ? '提交审批' : '撤回申请'),
content: Text(action == 'submit' ? '提交后将进入审批流程,确认继续?' : '确认撤回当前申请?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final error = await ref
.read(leaveRequestDetailProvider(id).notifier)
.transition(action);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error ?? (action == 'submit' ? '已提交审批' : '已撤回'))),
);
}
Widget _row(String label, String value) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 78, child: Text(label)),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
String _typeLabel(String value) => switch (value) {
'PERSONAL' => '事假',
'SICK' => '病假',
'ANNUAL' => '年假',
_ => value,
};
String _statusLabel(String value) => switch (value) {
'DRAFT' => '草稿',
'PENDING' => '审批中',
'APPROVED' => '已通过',
'REJECTED' => '已驳回',
'WITHDRAWN' => '已撤回',
_ => value,
};
}
class _TimelineTile extends StatelessWidget {
const _TimelineTile({required this.event});
final LeaveTimelineEvent event;
@override
Widget build(BuildContext context) => Card(
child: ListTile(
leading: const Icon(Icons.radio_button_checked),
title: Text(_eventLabel(event.eventType)),
subtitle: Text(
'${event.fromStatus}${event.toStatus}\n${DateFormat('MM-dd HH:mm').format(event.occurredAt.toLocal())}',
),
isThreeLine: true,
),
);
String _eventLabel(String value) => switch (value) {
'LEAVE_REQUEST_SUBMITTED' => '申请已提交',
'LEAVE_REQUEST_WITHDRAWN' => '申请已撤回',
'LEAVE_REQUEST_APPROVED' => '申请已通过',
'LEAVE_REQUEST_REJECTED' => '申请已驳回',
'LEAVE_APPROVAL_TASK_APPROVED' => '审批节点已通过',
'LEAVE_APPROVAL_TASK_REJECTED' => '审批节点已驳回',
_ => value,
};
}
@@ -0,0 +1,93 @@
import 'package:aioa_mobile/features/requests/application/leave_request_controller.dart';
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
class LeaveRequestListPage extends ConsumerWidget {
const LeaveRequestListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final requests = ref.watch(leaveRequestListProvider);
return Scaffold(
appBar: AppBar(title: const Text('我的请假申请')),
body: requests.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: FilledButton(
onPressed: ref.read(leaveRequestListProvider.notifier).refresh,
child: Text('加载失败,点击重试\n$error'),
),
),
data: (items) {
if (items.isEmpty) return const Center(child: Text('暂无请假申请'));
return RefreshIndicator(
onRefresh: ref.read(leaveRequestListProvider.notifier).refresh,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_RequestCard(request: items[index]),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/leave/new'),
icon: const Icon(Icons.add),
label: const Text('发起请假'),
),
);
}
}
class _RequestCard extends StatelessWidget {
const _RequestCard({required this.request});
final LeaveRequestItem request;
@override
Widget build(BuildContext context) => Card(
child: ListTile(
onTap: () => context.push('/leave/${request.id}'),
leading: CircleAvatar(child: Icon(_statusIcon(request.status))),
title: Text(
'${_typeLabel(request.type)} · ${_statusLabel(request.status)}',
),
subtitle: Text(
'${DateFormat('MM-dd HH:mm').format(request.startsAt.toLocal())}${DateFormat('MM-dd HH:mm').format(request.endsAt.toLocal())}\n${request.reason}',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
),
);
String _typeLabel(String value) => switch (value) {
'PERSONAL' => '事假',
'SICK' => '病假',
'ANNUAL' => '年假',
_ => value,
};
String _statusLabel(String value) => switch (value) {
'DRAFT' => '草稿',
'PENDING' => '审批中',
'APPROVED' => '已通过',
'REJECTED' => '已驳回',
'WITHDRAWN' => '已撤回',
_ => value,
};
IconData _statusIcon(String value) => switch (value) {
'DRAFT' => Icons.edit_note,
'PENDING' => Icons.hourglass_top,
'APPROVED' => Icons.check_circle_outline,
'REJECTED' => Icons.cancel_outlined,
'WITHDRAWN' => Icons.undo,
_ => Icons.description_outlined,
};
}
@@ -0,0 +1,48 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final approvalTaskRepositoryProvider = Provider<ApprovalTaskRepository>(
(ref) => ApprovalTaskRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final approvalTaskProvider =
AsyncNotifierProvider<ApprovalTaskController, List<ApprovalTaskItem>>(
ApprovalTaskController.new,
);
class ApprovalTaskController extends AsyncNotifier<List<ApprovalTaskItem>> {
@override
Future<List<ApprovalTaskItem>> build() =>
ref.read(approvalTaskRepositoryProvider).list();
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(approvalTaskRepositoryProvider).list(),
);
}
Future<String?> decide({
required ApprovalTaskItem task,
required bool approved,
String? comment,
}) async {
try {
await ref
.read(approvalTaskRepositoryProvider)
.decide(task: task, approved: approved, comment: comment);
state = AsyncData([
for (final item in state.value ?? const <ApprovalTaskItem>[])
if (item.id != task.id) item,
]);
return null;
} on ApprovalTaskException catch (error) {
return error.message;
}
}
}
@@ -0,0 +1,39 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final notificationRepositoryProvider = Provider<NotificationRepository>(
(ref) => NotificationRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final notificationProvider =
AsyncNotifierProvider<NotificationController, List<AppNotification>>(
NotificationController.new,
);
class NotificationController extends AsyncNotifier<List<AppNotification>> {
@override
Future<List<AppNotification>> build() =>
ref.read(notificationRepositoryProvider).list();
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(notificationRepositoryProvider).list(),
);
}
Future<void> markRead(String id) async {
final current = state.value;
if (current == null) return;
final updated = await ref.read(notificationRepositoryProvider).markRead(id);
state = AsyncData([
for (final item in current)
if (item.id == id) updated else item,
]);
}
}
@@ -0,0 +1,185 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class ApprovalTaskItem {
const ApprovalTaskItem({
required this.id,
required this.name,
required this.createdAt,
required this.leaveRequest,
});
final String id;
final String name;
final DateTime createdAt;
final ApprovalLeaveRequest leaveRequest;
factory ApprovalTaskItem.fromJson(Map<String, Object?> json) =>
ApprovalTaskItem(
id: json['id']! as String,
name: json['name']! as String,
createdAt: DateTime.parse(json['createdAt']! as String),
leaveRequest: ApprovalLeaveRequest.fromJson(
Map<String, Object?>.from(json['leaveRequest']! as Map),
),
);
}
class ApprovalLeaveRequest {
const ApprovalLeaveRequest({
required this.id,
required this.type,
required this.startsAt,
required this.endsAt,
required this.reason,
required this.status,
required this.version,
});
final String id;
final String type;
final DateTime startsAt;
final DateTime endsAt;
final String reason;
final String status;
final int version;
factory ApprovalLeaveRequest.fromJson(Map<String, Object?> json) =>
ApprovalLeaveRequest(
id: json['id']! as String,
type: json['type']! as String,
startsAt: DateTime.parse(json['startsAt']! as String),
endsAt: DateTime.parse(json['endsAt']! as String),
reason: json['reason']! as String,
status: json['status']! as String,
version: json['version']! as int,
);
}
class ApprovalTaskException implements Exception {
const ApprovalTaskException(this.message);
final String message;
@override
String toString() => message;
}
class ApprovalTaskRepository {
ApprovalTaskRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<List<ApprovalTaskItem>> list() async {
final response = await _client.get(
Uri.parse('$baseUrl/approval-tasks'),
headers: _headers,
);
_requireSuccess(response);
return (jsonDecode(response.body) as List)
.map(
(item) =>
ApprovalTaskItem.fromJson(Map<String, Object?>.from(item as Map)),
)
.toList();
}
Future<void> decide({
required ApprovalTaskItem task,
required bool approved,
String? comment,
}) async {
final action = approved ? 'approve' : 'reject';
final payload = jsonEncode({
'version': task.leaveRequest.version,
if (comment != null && comment.trim().isNotEmpty)
'comment': comment.trim(),
});
final preferences = await SharedPreferences.getInstance();
final storageKey = 'approval.pending.${task.id}.$action';
final existing = preferences.getString(storageKey);
final pending = existing == null ? null : _decodePending(existing);
final idempotencyKey = pending?.payload == payload
? pending!.key
: _newKey(action);
await preferences.setString(
storageKey,
jsonEncode({'key': idempotencyKey, 'payload': payload}),
);
late http.Response response;
try {
response = await _client.post(
Uri.parse('$baseUrl/approval-tasks/${task.id}/$action'),
headers: {
..._headers,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: payload,
);
} catch (_) {
throw const ApprovalTaskException('网络不可用,审批请求已保存,可安全重试');
}
if (response.statusCode < 200 || response.statusCode >= 300) {
if (response.statusCode < 500 && response.statusCode != 401) {
await preferences.remove(storageKey);
}
_requireSuccess(response);
}
await preferences.remove(storageKey);
}
Map<String, String> get _headers => {
'Accept': 'application/json',
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
};
void _requireSuccess(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) return;
String? message;
try {
message =
(jsonDecode(response.body) as Map<String, Object?>)['detail']
as String?;
} catch (_) {}
throw ApprovalTaskException(message ?? '待办请求失败(${response.statusCode}');
}
_PendingApproval? _decodePending(String encoded) {
try {
final json = jsonDecode(encoded) as Map<String, Object?>;
return _PendingApproval(
json['key']! as String,
json['payload']! as String,
);
} catch (_) {
return null;
}
}
String _newKey(String action) {
final random = Random.secure();
final entropy = List.generate(
12,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
return 'approval-$action-${DateTime.now().microsecondsSinceEpoch}-$entropy';
}
}
class _PendingApproval {
const _PendingApproval(this.key, this.payload);
final String key;
final String payload;
}
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class AppNotification {
const AppNotification({
required this.id,
required this.type,
required this.title,
required this.body,
required this.createdAt,
this.resourceType,
this.resourceId,
this.readAt,
});
final String id;
final String type;
final String title;
final String body;
final String? resourceType;
final String? resourceId;
final DateTime createdAt;
final DateTime? readAt;
bool get isRead => readAt != null;
factory AppNotification.fromJson(Map<String, Object?> json) =>
AppNotification(
id: json['id']! as String,
type: json['type']! as String,
title: json['title']! as String,
body: json['body']! as String,
resourceType: json['resourceType'] as String?,
resourceId: json['resourceId'] as String?,
createdAt: DateTime.parse(json['createdAt']! as String),
readAt: json['readAt'] == null
? null
: DateTime.parse(json['readAt']! as String),
);
}
class NotificationRepository {
NotificationRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<List<AppNotification>> list() async {
final response = await _client.get(
Uri.parse('$baseUrl/notifications'),
headers: _headers,
);
_requireSuccess(response);
return (jsonDecode(response.body) as List)
.map(
(item) =>
AppNotification.fromJson(Map<String, Object?>.from(item as Map)),
)
.toList();
}
Future<AppNotification> markRead(String id) async {
final response = await _client.post(
Uri.parse('$baseUrl/notifications/$id/read'),
headers: _headers,
);
_requireSuccess(response);
return AppNotification.fromJson(
jsonDecode(response.body) as Map<String, Object?>,
);
}
Map<String, String> get _headers => {
'Accept': 'application/json',
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
};
void _requireSuccess(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) return;
throw Exception('通知请求失败(${response.statusCode}');
}
}
@@ -1,8 +1,280 @@
import 'package:aioa_mobile/features/tasks/application/approval_task_controller.dart';
import 'package:aioa_mobile/features/tasks/application/notification_controller.dart';
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
class TasksPage extends StatelessWidget {
class TasksPage extends ConsumerWidget {
const TasksPage({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text('暂无待办'));
Widget build(BuildContext context, WidgetRef ref) {
final notifications = ref.watch(notificationProvider);
final tasks = ref.watch(approvalTaskProvider);
final unread =
notifications.value?.where((item) => !item.isRead).length ?? 0;
final taskCount = tasks.value?.length ?? 0;
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('待办与通知'),
bottom: TabBar(
tabs: [
Tab(text: taskCount == 0 ? '待办' : '待办 ($taskCount)'),
Tab(text: unread == 0 ? '通知' : '通知 ($unread)'),
],
),
),
body: TabBarView(
children: [
_ApprovalTaskList(tasks: tasks),
_NotificationList(notifications: notifications),
],
),
),
);
}
}
class _ApprovalTaskList extends ConsumerWidget {
const _ApprovalTaskList({required this.tasks});
final AsyncValue<List<ApprovalTaskItem>> tasks;
@override
Widget build(BuildContext context, WidgetRef ref) => tasks.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('待办加载失败:$error'),
const SizedBox(height: 8),
FilledButton(
onPressed: ref.read(approvalTaskProvider.notifier).refresh,
child: const Text('重试'),
),
],
),
),
data: (items) {
if (items.isEmpty) return const Center(child: Text('暂无待办'));
return RefreshIndicator(
onRefresh: ref.read(approvalTaskProvider.notifier).refresh,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_ApprovalTaskCard(task: items[index]),
),
);
},
);
}
class _ApprovalTaskCard extends ConsumerWidget {
const _ApprovalTaskCard({required this.task});
final ApprovalTaskItem task;
@override
Widget build(BuildContext context, WidgetRef ref) {
final request = task.leaveRequest;
final formatter = DateFormat('MM-dd HH:mm');
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const CircleAvatar(child: Icon(Icons.assignment_ind_outlined)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.name,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(_typeLabel(request.type)),
],
),
),
],
),
const SizedBox(height: 12),
Text(
'${formatter.format(request.startsAt.toLocal())}${formatter.format(request.endsAt.toLocal())}',
),
const SizedBox(height: 6),
Text(request.reason),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _decide(context, ref, approved: false),
child: const Text('驳回'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: () => _decide(context, ref, approved: true),
child: const Text('批准'),
),
),
],
),
],
),
),
);
}
Future<void> _decide(
BuildContext context,
WidgetRef ref, {
required bool approved,
}) async {
final comment = await showDialog<String>(
context: context,
builder: (context) => _DecisionDialog(approved: approved),
);
if (comment == null || !context.mounted) return;
final error = await ref
.read(approvalTaskProvider.notifier)
.decide(task: task, approved: approved, comment: comment);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error ?? (approved ? '已批准' : '已驳回'))),
);
}
String _typeLabel(String type) => switch (type) {
'PERSONAL' => '事假',
'SICK' => '病假',
'ANNUAL' => '年假',
_ => type,
};
}
class _DecisionDialog extends StatefulWidget {
const _DecisionDialog({required this.approved});
final bool approved;
@override
State<_DecisionDialog> createState() => _DecisionDialogState();
}
class _DecisionDialogState extends State<_DecisionDialog> {
final controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: Text(widget.approved ? '批准申请' : '驳回申请'),
content: TextField(
controller: controller,
maxLength: 1000,
maxLines: 3,
decoration: InputDecoration(
labelText: widget.approved ? '审批意见(选填)' : '驳回原因',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text),
child: Text(widget.approved ? '确认批准' : '确认驳回'),
),
],
);
}
class _NotificationList extends ConsumerWidget {
const _NotificationList({required this.notifications});
final AsyncValue<List<AppNotification>> notifications;
@override
Widget build(BuildContext context, WidgetRef ref) => notifications.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('通知加载失败:$error'),
const SizedBox(height: 8),
FilledButton(
onPressed: ref.read(notificationProvider.notifier).refresh,
child: const Text('重试'),
),
],
),
),
data: (items) {
if (items.isEmpty) return const Center(child: Text('暂无通知'));
return RefreshIndicator(
onRefresh: ref.read(notificationProvider.notifier).refresh,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final item = items[index];
return Card(
color: item.isRead
? null
: Theme.of(
context,
).colorScheme.primaryContainer.withValues(alpha: 0.35),
child: ListTile(
leading: Icon(_icon(item.type)),
title: Text(
item.title,
style: TextStyle(
fontWeight: item.isRead ? FontWeight.w500 : FontWeight.w700,
),
),
subtitle: Text(
'${item.body}\n${DateFormat('MM-dd HH:mm').format(item.createdAt.toLocal())}',
),
isThreeLine: true,
trailing: item.isRead ? null : const Badge(),
onTap: item.isRead
? null
: () => ref
.read(notificationProvider.notifier)
.markRead(item.id),
),
);
},
),
);
},
);
IconData _icon(String type) => switch (type) {
'APPROVAL_TASK_ASSIGNED' => Icons.assignment_outlined,
'LEAVE_APPROVED' => Icons.check_circle_outline,
'LEAVE_REJECTED' => Icons.cancel_outlined,
_ => Icons.notifications_none,
};
}
@@ -53,6 +53,21 @@ class WorkspacePage extends StatelessWidget {
),
),
),
const SizedBox(height: 10),
Card(
child: ListTile(
onTap: () => context.push('/leave'),
leading: const CircleAvatar(
child: Icon(Icons.description_outlined),
),
title: const Text(
'我的请假申请',
style: TextStyle(fontWeight: FontWeight.w700),
),
subtitle: const Text('查看草稿、审批状态、时间线和撤回申请'),
trailing: const Icon(Icons.chevron_right),
),
),
const SizedBox(height: 16),
const _StatusCard(),
],
+369 -1
View File
@@ -9,6 +9,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "99.0.0"
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.75"
analyzer:
dependency: transitive
description:
@@ -65,6 +73,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -89,6 +105,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.15.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.5+4"
crypto:
dependency: transitive
description:
@@ -105,6 +129,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.14"
fake_async:
dependency: transitive
description:
@@ -113,6 +145,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
@@ -121,6 +161,62 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.3.10"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.12.1"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.0.0"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.9.1"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556"
url: "https://pub.flutter-io.cn"
source: hosted
version: "16.4.3"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.9.2"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.2.3"
fixnum:
dependency: transitive
description:
@@ -134,6 +230,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_appauth:
dependency: "direct main"
description:
name: flutter_appauth
sha256: aafcafdc7de1c412f361798c993de5737a3eddc6bf26fe38a71ad580b37b4ec0
url: "https://pub.flutter-io.cn"
source: hosted
version: "12.0.2"
flutter_appauth_platform_interface:
dependency: transitive
description:
name: flutter_appauth_platform_interface
sha256: b7c7d4f288af7b3119a9db0ea00cf5e93135d0e83c3687172848bc5c4fdec992
url: "https://pub.flutter-io.cn"
source: hosted
version: "12.0.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -142,6 +254,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.35"
flutter_riverpod:
dependency: "direct main"
description:
@@ -150,6 +270,54 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.3.2"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -184,6 +352,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "17.3.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@@ -216,6 +400,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
leak_tracker:
dependency: transitive
description:
@@ -296,6 +496,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
@@ -312,6 +520,78 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
@@ -328,6 +608,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
@@ -336,6 +624,62 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.3.2"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.27"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
@@ -533,6 +877,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
@@ -543,4 +911,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.38.0"
flutter: ">=3.44.0"
+7
View File
@@ -34,9 +34,16 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
file_picker: 10.3.10
firebase_core: ^4.2.1
firebase_messaging: ^16.0.4
flutter_appauth: ^12.0.2
flutter_riverpod: ^3.3.2
flutter_secure_storage: ^10.3.1
go_router: ^17.3.0
http: ^1.5.0
intl: ^0.20.3
shared_preferences: ^2.5.3
dev_dependencies:
flutter_test:
@@ -0,0 +1,27 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test(
'attaches refreshed bearer token only to the configured API origin',
() async {
final captured = <http.Request>[];
final client = AuthenticatedHttpClient(
inner: MockClient((request) async {
captured.add(request);
return http.Response('', 200);
}),
apiOrigin: 'https://api.example.test',
tokenProvider: () async => 'refreshed-token',
);
await client.get(Uri.parse('https://api.example.test/api/v1/me'));
await client.put(Uri.parse('https://minio.example.test/upload'));
expect(captured[0].headers['Authorization'], 'Bearer refreshed-token');
expect(captured[1].headers['Authorization'], isNull);
},
);
}
@@ -0,0 +1,56 @@
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('parses server data and UI schemas into safe control models', () {
final definition = DynamicFormDefinition.fromJson({
'key': 'leave-request',
'version': 1,
'dataSchema': {
r'$id': 'leave-request-v1',
'title': '请假申请',
'required': ['type'],
'properties': {
'type': {
'type': 'string',
'enum': ['PERSONAL', 'SICK'],
},
},
},
'uiSchema': {
'description': '远程定义',
'sections': [
{
'title': '请假信息',
'controls': [
{
'field': 'type',
'label': '请假类型',
'control': 'select',
'optionLabels': {'PERSONAL': '事假'},
},
],
},
],
},
});
expect(definition.dataSchema.id, 'leave-request-v1');
expect(definition.dataSchema.required, {'type'});
expect(definition.dataSchema.properties['type']!.enumValues, [
'PERSONAL',
'SICK',
]);
expect(
definition.uiSchema.sections.single.controls.single.control,
FormControlType.select,
);
});
test('unknown controls cannot enter the renderer whitelist', () {
expect(
() => FormControlType.values.byName('remoteScript'),
throwsA(isA<ArgumentError>()),
);
});
}
@@ -0,0 +1,69 @@
import 'dart:convert';
import 'package:aioa_mobile/features/assistant/data/leave_progress_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test('parses ambiguous own request candidates', () async {
final repository = LeaveProgressRepository(
baseUrl: 'https://api.example.test/api/v1',
client: MockClient((request) async {
expect(request.url.path, '/api/v1/ai/leave-progress-answers');
return http.Response(
jsonEncode({
'requiresSelection': true,
'candidates': [
{
'id': 'leave-1',
'type': 'ANNUAL',
'status': 'PENDING',
'startsAt': '2026-07-20T01:00:00Z',
'endsAt': '2026-07-20T09:00:00Z',
'createdAt': '2026-07-18T01:00:00Z',
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
final result = await repository.ask('我的年假进度?');
expect(result.requiresSelection, isTrue);
expect(result.candidates.single.id, 'leave-1');
});
test('sends selected request id and parses active tasks', () async {
final repository = LeaveProgressRepository(
baseUrl: 'https://api.example.test/api/v1',
client: MockClient((request) async {
expect(jsonDecode(request.body)['selectedRequestId'], 'leave-1');
return http.Response(
jsonEncode({
'requiresSelection': false,
'answer': '正在主管审批',
'request': {
'id': 'leave-1',
'type': 'ANNUAL',
'status': 'PENDING',
'startsAt': '2026-07-20T01:00:00Z',
'endsAt': '2026-07-20T09:00:00Z',
'createdAt': '2026-07-18T01:00:00Z',
},
'progress': {
'activeTaskNames': ['主管审批'],
'completedTaskNames': [],
'processEnded': false,
},
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
final result = await repository.ask('进度?', selectedRequestId: 'leave-1');
expect(result.activeTasks, ['主管审批']);
expect(result.answer, '正在主管审批');
});
}
@@ -0,0 +1,60 @@
import 'dart:convert';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test('parses a confirmation-required Qwen suggestion', () async {
final repository = AiLeaveSuggestionRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient(
(request) async => http.Response(
jsonEncode({
'suggestion': {
'type': 'PERSONAL',
'startsAt': '2026-07-19T05:30:00Z',
'endsAt': '2026-07-19T09:30:00Z',
'reason': '办理个人事务',
'assumptions': ['下午按 13:30 开始'],
'needsClarification': [],
},
'model': 'qwen-plus',
'requiresUserConfirmation': true,
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
),
),
);
final suggestion = await repository.suggest('明天下午请事假四小时');
expect(suggestion.values['type'], 'PERSONAL');
expect(suggestion.assumptions, ['下午按 13:30 开始']);
expect(suggestion.model, 'qwen-plus');
});
test('rejects AI responses that bypass user confirmation', () async {
final repository = AiLeaveSuggestionRepository(
accessToken: 'token',
client: MockClient(
(_) async => http.Response(
jsonEncode({
'suggestion': {'assumptions': [], 'needsClarification': []},
'model': 'qwen-plus',
'requiresUserConfirmation': false,
}),
200,
),
),
);
await expectLater(
repository.suggest('请假'),
throwsA(isA<AiLeaveSuggestionException>()),
);
});
}
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
const responseBody = {
'key': 'leave-request',
'version': 1,
'dataSchema': {
r'$id': 'leave-request-v1',
'title': '服务端请假申请',
'required': ['reason'],
'properties': {
'reason': {'type': 'string', 'minLength': 1},
},
},
'uiSchema': {
'description': '服务端定义',
'sections': [
{
'title': '说明',
'controls': [
{'field': 'reason', 'label': '原因', 'control': 'textArea'},
],
},
],
},
};
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('loads remote definition with bearer token and stores cache', () async {
late http.Request capturedRequest;
final repository = FormDefinitionRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'test-token',
client: MockClient((request) async {
capturedRequest = request;
return http.Response(
jsonEncode(responseBody),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
final loaded = await repository.loadLeaveRequest();
expect(loaded.source, FormDefinitionSource.remote);
expect(loaded.definition.dataSchema.title, '服务端请假申请');
expect(capturedRequest.headers['Authorization'], 'Bearer test-token');
final preferences = await SharedPreferences.getInstance();
expect(
preferences.getString('form-definition.leave-request.v1'),
isNotNull,
);
});
test('uses last valid cache when the network is unavailable', () async {
SharedPreferences.setMockInitialValues({
'form-definition.leave-request.v1': jsonEncode(responseBody),
});
final repository = FormDefinitionRepository(
baseUrl: 'https://api.example.test/api/v1',
client: MockClient((_) async => throw http.ClientException('offline')),
);
final loaded = await repository.loadLeaveRequest();
expect(loaded.source, FormDefinitionSource.cache);
expect(loaded.definition.uiSchema.description, '服务端定义');
});
}
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:aioa_mobile/features/form/data/leave_attachment_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test(
'creates task, streams bytes to MinIO and completes attachment',
() async {
final requests = <http.Request>[];
final repository = LeaveAttachmentRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient((request) async {
requests.add(request);
if (request.url.host == 'minio.test') {
return http.Response('', 200);
}
if (request.url.path.endsWith('/upload-tasks')) {
return http.Response(
jsonEncode({
'attachment': {
'id': 'attachment-1',
'fileName': 'proof.pdf',
'contentType': 'application/pdf',
'sizeBytes': 4,
'status': 'PENDING',
},
'uploadUrl': 'https://minio.test/upload/object',
}),
201,
);
}
return http.Response(
jsonEncode({
'id': 'attachment-1',
'fileName': 'proof.pdf',
'contentType': 'application/pdf',
'sizeBytes': 4,
'status': 'READY',
}),
200,
);
}),
);
final progress = <double>[];
final attachment = await repository.upload(
leaveRequestId: 'leave-1',
fileName: 'proof.pdf',
contentType: 'application/pdf',
bytes: Uint8List.fromList([1, 2, 3, 4]),
onProgress: progress.add,
);
expect(attachment.status, 'READY');
expect(requests.map((request) => request.method), [
'POST',
'PUT',
'POST',
]);
expect(requests[1].bodyBytes, [1, 2, 3, 4]);
expect(requests[1].headers['Content-Type'], 'application/pdf');
expect(progress.last, 1);
},
);
test('rejects oversized files before creating an upload task', () async {
final repository = LeaveAttachmentRepository(
accessToken: 'token',
client: MockClient((_) async => fail('request should not be sent')),
);
await expectLater(
repository.upload(
leaveRequestId: 'leave-1',
fileName: 'large.pdf',
contentType: 'application/pdf',
bytes: Uint8List(10 * 1024 * 1024 + 1),
onProgress: (_) {},
),
throwsA(isA<LeaveAttachmentException>()),
);
});
}
@@ -0,0 +1,99 @@
import 'dart:convert';
import 'package:aioa_mobile/features/form/data/leave_draft_submission_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
const values = {
'type': 'PERSONAL',
'startsAt': '2026-07-20T01:00:00Z',
'endsAt': '2026-07-20T05:00:00Z',
'reason': '办理个人事务',
};
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('creates a backend draft and clears the pending request', () async {
late http.Request captured;
final repository = LeaveDraftSubmissionRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient((request) async {
captured = request;
return http.Response(
jsonEncode({'id': 'draft-1', 'status': 'DRAFT', 'version': 0}),
201,
);
}),
);
final created = await repository.create(values);
expect(created.id, 'draft-1');
expect(captured.headers['Authorization'], 'Bearer token');
expect(captured.headers['Idempotency-Key']!.length, greaterThan(16));
final body = jsonDecode(captured.body) as Map<String, Object?>;
expect(body['version'], 0);
final preferences = await SharedPreferences.getInstance();
expect(
preferences.getString(LeaveDraftSubmissionRepository.pendingStorageKey),
isNull,
);
});
test('reuses the same idempotency key after a network failure', () async {
final keys = <String>[];
var attempts = 0;
final repository = LeaveDraftSubmissionRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient((request) async {
keys.add(request.headers['Idempotency-Key']!);
attempts += 1;
if (attempts == 1) throw http.ClientException('offline');
return http.Response(
jsonEncode({'id': 'draft-1', 'status': 'DRAFT', 'version': 0}),
201,
);
}),
);
await expectLater(
repository.create(values),
throwsA(isA<LeaveDraftSubmissionException>()),
);
final created = await repository.create(values);
expect(created.id, 'draft-1');
expect(keys, hasLength(2));
expect(keys[1], keys[0]);
});
test(
'surfaces an unauthorized response when no session token is attached',
() async {
final repository = LeaveDraftSubmissionRepository(
client: MockClient(
(_) async =>
http.Response(jsonEncode({'detail': 'Unauthorized'}), 401),
),
);
await expectLater(
repository.create(values),
throwsA(
isA<LeaveDraftSubmissionException>().having(
(error) => error.retryable,
'retryable',
isTrue,
),
),
);
},
);
}
@@ -0,0 +1,45 @@
import 'package:aioa_mobile/features/form/data/leave_local_draft_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('persists and restores an unfinished leave draft', () async {
final repository = LeaveLocalDraftRepository();
await repository.save({
'type': 'SICK',
'startsAt': '2026-07-20T01:00:00Z',
'reason': '身体不适',
});
final restored = await repository.load();
expect(restored, isNotNull);
expect(restored!.values['type'], 'SICK');
expect(restored.values['reason'], '身体不适');
expect(restored.savedAt.isUtc, isTrue);
});
test('removes corrupted local drafts instead of crashing the form', () async {
SharedPreferences.setMockInitialValues({
LeaveLocalDraftRepository.storageKey: '{broken-json',
});
final repository = LeaveLocalDraftRepository();
expect(await repository.load(), isNull);
final preferences = await SharedPreferences.getInstance();
expect(preferences.getString(LeaveLocalDraftRepository.storageKey), isNull);
});
test('clears a completed or discarded draft', () async {
final repository = LeaveLocalDraftRepository();
await repository.save({'reason': 'temporary'});
await repository.clear();
expect(await repository.load(), isNull);
});
}
@@ -0,0 +1,80 @@
import 'dart:convert';
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
final requestJson = {
'id': 'leave-1',
'type': 'PERSONAL',
'startsAt': '2026-07-20T01:00:00Z',
'endsAt': '2026-07-20T05:00:00Z',
'reason': '办理个人事务',
'status': 'DRAFT',
'version': 0,
'createdAt': '2026-07-18T08:00:00Z',
'updatedAt': '2026-07-18T08:00:00Z',
};
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('loads own leave requests', () async {
final repository = LeaveRequestRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient(
(_) async => http.Response(
jsonEncode([requestJson]),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
),
),
);
final requests = await repository.list();
expect(requests.single.status, 'DRAFT');
expect(requests.single.reason, '办理个人事务');
});
test('reuses transition idempotency key after network failure', () async {
final keys = <String>[];
var attempt = 0;
final repository = LeaveRequestRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient((request) async {
keys.add(
request.headers.entries
.firstWhere(
(entry) => entry.key.toLowerCase() == 'idempotency-key',
orElse: () => const MapEntry('', 'missing'),
)
.value,
);
attempt += 1;
if (attempt == 1) throw http.ClientException('offline');
return http.Response(
jsonEncode({...requestJson, 'status': 'PENDING', 'version': 1}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
final request = LeaveRequestItem.fromJson(requestJson);
await expectLater(
repository.transition(request, 'submit'),
throwsA(isA<LeaveRequestException>()),
);
final submitted = await repository.transition(request, 'submit');
expect(submitted.status, 'PENDING');
expect(keys[1], keys[0]);
});
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
final taskJson = {
'id': 'task-1',
'name': '部门主管审批',
'createdAt': '2026-07-18T08:00:00Z',
'leaveRequest': {
'id': 'leave-1',
'type': 'PERSONAL',
'startsAt': '2026-07-20T01:00:00Z',
'endsAt': '2026-07-20T05:00:00Z',
'reason': '办理个人事务',
'status': 'PENDING',
'version': 1,
},
};
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('loads assigned approval tasks', () async {
final repository = ApprovalTaskRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient(
(_) async => http.Response(
jsonEncode([taskJson]),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
),
),
);
final tasks = await repository.list();
expect(tasks.single.name, '部门主管审批');
expect(tasks.single.leaveRequest.version, 1);
});
test('reuses approval idempotency key after network failure', () async {
final keys = <String>[];
var attempt = 0;
final repository = ApprovalTaskRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient((request) async {
keys.add(
request.headers.entries
.firstWhere(
(entry) => entry.key.toLowerCase() == 'idempotency-key',
orElse: () => const MapEntry('', 'missing'),
)
.value,
);
attempt += 1;
if (attempt == 1) throw http.ClientException('offline');
return http.Response(
jsonEncode(taskJson['leaveRequest']),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
final task = ApprovalTaskItem.fromJson(taskJson);
await expectLater(
repository.decide(task: task, approved: true, comment: '同意'),
throwsA(isA<ApprovalTaskException>()),
);
await repository.decide(task: task, approved: true, comment: '同意');
expect(keys, hasLength(2));
expect(keys[1], keys[0]);
});
}
@@ -0,0 +1,59 @@
import 'dart:convert';
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
final unreadJson = {
'id': 'notification-1',
'type': 'LEAVE_APPROVED',
'title': '请假申请已通过',
'body': '你的请假申请已完成审批。',
'resourceType': 'LEAVE_REQUEST',
'resourceId': 'leave-1',
'createdAt': '2026-07-18T08:00:00Z',
'readAt': null,
};
test('loads own notifications with bearer token', () async {
late http.Request captured;
final repository = NotificationRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient((request) async {
captured = request;
return http.Response(
jsonEncode([unreadJson]),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
final notifications = await repository.list();
expect(notifications.single.isRead, isFalse);
expect(notifications.single.title, '请假申请已通过');
expect(captured.headers['Authorization'], 'Bearer token');
});
test('marks a notification read', () async {
final repository = NotificationRepository(
baseUrl: 'https://api.example.test/api/v1',
accessToken: 'token',
client: MockClient((request) async {
return http.Response(
jsonEncode({...unreadJson, 'readAt': '2026-07-18T08:05:00Z'}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
final notification = await repository.markRead('notification-1');
expect(notification.isRead, isTrue);
});
}