add dashboard to phone app
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
image: ghcr.io/cirruslabs/flutter:stable
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- verify
|
||||||
|
- build
|
||||||
|
|
||||||
|
variables:
|
||||||
|
PUB_CACHE: "$CI_PROJECT_DIR/.pub-cache"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files:
|
||||||
|
- pubspec.lock
|
||||||
|
paths:
|
||||||
|
- .pub-cache/
|
||||||
|
- .dart_tool/
|
||||||
|
|
||||||
|
before_script:
|
||||||
|
- flutter --version
|
||||||
|
- flutter pub get
|
||||||
|
|
||||||
|
analyze:
|
||||||
|
stage: verify
|
||||||
|
script:
|
||||||
|
- flutter analyze --no-fatal-infos
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: verify
|
||||||
|
script:
|
||||||
|
- flutter test
|
||||||
|
|
||||||
|
build_android_apk:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- flutter build apk --release
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 7 days
|
||||||
|
paths:
|
||||||
|
- build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
|
build_android_appbundle:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- flutter build appbundle --release
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 7 days
|
||||||
|
paths:
|
||||||
|
- build/app/outputs/bundle/release/app-release.aab
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<application
|
<application
|
||||||
android:label="car_rental_app"
|
android:label="car_rental_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
platform :ios, '13.0'
|
||||||
# platform :ios, '13.0'
|
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
PODS:
|
||||||
|
- Flutter (1.0.0)
|
||||||
|
- flutter_secure_storage (6.0.0):
|
||||||
|
- Flutter
|
||||||
|
- image_picker_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- sqflite_darwin (0.0.4):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
|
DEPENDENCIES:
|
||||||
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
|
|
||||||
|
EXTERNAL SOURCES:
|
||||||
|
Flutter:
|
||||||
|
:path: Flutter
|
||||||
|
flutter_secure_storage:
|
||||||
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
|
image_picker_ios:
|
||||||
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
|
sqflite_darwin:
|
||||||
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
|
|
||||||
|
SPEC CHECKSUMS:
|
||||||
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||||
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
|
|
||||||
|
PODFILE CHECKSUM: f8c2dcdfb50bb67645580d28a6bf814fca30bdec
|
||||||
|
|
||||||
|
COCOAPODS: 1.16.2
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
995A594C2412F61895CB2016 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF55B8E259192E16A3811CC1 /* Pods_Runner.framework */; };
|
||||||
|
F42061B85542064B2CAE079F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0055849AAFA1815289813A3B /* Pods_RunnerTests.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -41,14 +43,18 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
0055849AAFA1815289813A3B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; 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>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
2B8CB5C8A4FB36AEE69DD7FA /* 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>"; };
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; 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; };
|
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>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
6CBEB391E648AB4FC44FCDFE /* 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>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; 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>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
79F48B122F8D6BC8002A1A11 /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -57,6 +63,11 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
A7206B50C42F65222935848D /* 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>"; };
|
||||||
|
AA12F1DB701472A907C0C6B9 /* 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>"; };
|
||||||
|
B893C3A8BB7D1BA341320415 /* 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>"; };
|
||||||
|
DF55B8E259192E16A3811CC1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
FBDCE86BA6755F0F93243B5B /* 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>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -64,6 +75,15 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
995A594C2412F61895CB2016 /* Pods_Runner.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
EC4F91958B1958510DF60AFF /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
F42061B85542064B2CAE079F /* Pods_RunnerTests.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -83,6 +103,7 @@
|
|||||||
children = (
|
children = (
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
79F48B122F8D6BC8002A1A11 /* Profile.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||||
);
|
);
|
||||||
@@ -96,6 +117,8 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
D394CDA7A7BD6BDE41D8494A /* Pods */,
|
||||||
|
ACB44088637E3B4C0CAEDA11 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -124,6 +147,29 @@
|
|||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
ACB44088637E3B4C0CAEDA11 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DF55B8E259192E16A3811CC1 /* Pods_Runner.framework */,
|
||||||
|
0055849AAFA1815289813A3B /* Pods_RunnerTests.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D394CDA7A7BD6BDE41D8494A /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2B8CB5C8A4FB36AEE69DD7FA /* Pods-Runner.debug.xcconfig */,
|
||||||
|
6CBEB391E648AB4FC44FCDFE /* Pods-Runner.release.xcconfig */,
|
||||||
|
FBDCE86BA6755F0F93243B5B /* Pods-Runner.profile.xcconfig */,
|
||||||
|
A7206B50C42F65222935848D /* Pods-RunnerTests.debug.xcconfig */,
|
||||||
|
AA12F1DB701472A907C0C6B9 /* Pods-RunnerTests.release.xcconfig */,
|
||||||
|
B893C3A8BB7D1BA341320415 /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Pods;
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -131,8 +177,10 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
F3650807002DE913D3C084C2 /* [CP] Check Pods Manifest.lock */,
|
||||||
331C807D294A63A400263BE5 /* Sources */,
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
331C807F294A63A400263BE5 /* Resources */,
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
EC4F91958B1958510DF60AFF /* Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -148,12 +196,14 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
8ADD842875B4146703974D33 /* [CP] Check Pods Manifest.lock */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
0A3C24793BEDEF7D086CCEB8 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -225,6 +275,23 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
0A3C24793BEDEF7D086CCEB8 /* [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;
|
||||||
|
};
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -241,6 +308,28 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
};
|
};
|
||||||
|
8ADD842875B4146703974D33 /* [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;
|
||||||
|
};
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -256,6 +345,28 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
};
|
};
|
||||||
|
F3650807002DE913D3C084C2 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -361,13 +472,14 @@
|
|||||||
};
|
};
|
||||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
baseConfigurationReference = 79F48B122F8D6BC8002A1A11 /* Profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -382,6 +494,7 @@
|
|||||||
};
|
};
|
||||||
331C8088294A63A400263BE5 /* Debug */ = {
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = A7206B50C42F65222935848D /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -399,6 +512,7 @@
|
|||||||
};
|
};
|
||||||
331C8089294A63A400263BE5 /* Release */ = {
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = AA12F1DB701472A907C0C6B9 /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -414,6 +528,7 @@
|
|||||||
};
|
};
|
||||||
331C808A294A63A400263BE5 /* Profile */ = {
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = B893C3A8BB7D1BA341320415 /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -547,6 +662,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -569,6 +685,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
+3
@@ -4,4 +4,7 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
class AppConstants {
|
class AppConstants {
|
||||||
// Android emulator routes localhost as 10.0.2.2; iOS simulator uses localhost fine
|
static const String apiBaseUrlOverride =
|
||||||
static String get baseUrl => Platform.isAndroid
|
String.fromEnvironment('API_BASE_URL');
|
||||||
? 'http://10.0.2.2:4000/api/v1'
|
static const String developmentAndroidApiUrl = 'http://10.0.2.2:4000/api/v1';
|
||||||
: 'http://localhost:4000/api/v1';
|
static const String developmentLocalApiUrl = 'http://localhost:4000/api/v1';
|
||||||
|
|
||||||
|
// Dev builds stay local by default. Real phones still need a LAN-reachable
|
||||||
|
// override because their localhost is the phone itself, not the dev machine.
|
||||||
|
static String get baseUrl {
|
||||||
|
if (apiBaseUrlOverride.isNotEmpty) {
|
||||||
|
return apiBaseUrlOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return developmentAndroidApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return developmentLocalApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
static const String tokenKey = 'auth_token';
|
static const String tokenKey = 'auth_token';
|
||||||
static const String userTypeKey = 'user_type';
|
static const String userTypeKey = 'user_type';
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ class MarketplaceOffer {
|
|||||||
String get discountLabel {
|
String get discountLabel {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'PERCENTAGE':
|
case 'PERCENTAGE':
|
||||||
return '${discountValue}% off';
|
return '$discountValue% off';
|
||||||
case 'FIXED_AMOUNT':
|
case 'FIXED_AMOUNT':
|
||||||
return '\$${(discountValue / 100).toStringAsFixed(0)} off';
|
return '\$${(discountValue / 100).toStringAsFixed(0)} off';
|
||||||
case 'FREE_DAY':
|
case 'FREE_DAY':
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
class AppNotification {
|
||||||
|
final String id;
|
||||||
|
final String type;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final bool isRead;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final Map<String, dynamic>? data;
|
||||||
|
|
||||||
|
const AppNotification({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.isRead,
|
||||||
|
required this.createdAt,
|
||||||
|
this.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AppNotification.fromJson(Map<String, dynamic> json) =>
|
||||||
|
AppNotification(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type: json['type'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
body: json['body'] as String? ?? '',
|
||||||
|
isRead: json['isRead'] as bool? ?? false,
|
||||||
|
createdAt: DateTime.parse(
|
||||||
|
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
||||||
|
data: json['data'] as Map<String, dynamic>?,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
class CompanyOffer {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final String type; // PERCENTAGE | FIXED_AMOUNT | FREE_DAY | SPECIAL_RATE
|
||||||
|
final int discountValue;
|
||||||
|
final String? promoCode;
|
||||||
|
final bool isActive;
|
||||||
|
final bool isPublic;
|
||||||
|
final bool isFeatured;
|
||||||
|
final DateTime validFrom;
|
||||||
|
final DateTime validUntil;
|
||||||
|
final int? maxRedemptions;
|
||||||
|
final int redemptionCount;
|
||||||
|
final List<String> categories;
|
||||||
|
|
||||||
|
const CompanyOffer({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
required this.type,
|
||||||
|
required this.discountValue,
|
||||||
|
this.promoCode,
|
||||||
|
required this.isActive,
|
||||||
|
required this.isPublic,
|
||||||
|
required this.isFeatured,
|
||||||
|
required this.validFrom,
|
||||||
|
required this.validUntil,
|
||||||
|
this.maxRedemptions,
|
||||||
|
required this.redemptionCount,
|
||||||
|
required this.categories,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get discountLabel {
|
||||||
|
switch (type) {
|
||||||
|
case 'PERCENTAGE':
|
||||||
|
return '$discountValue% off';
|
||||||
|
case 'FIXED_AMOUNT':
|
||||||
|
return '\$${(discountValue / 100).toStringAsFixed(0)} off';
|
||||||
|
case 'FREE_DAY':
|
||||||
|
return '$discountValue free day${discountValue == 1 ? '' : 's'}';
|
||||||
|
case 'SPECIAL_RATE':
|
||||||
|
return 'Special rate';
|
||||||
|
default:
|
||||||
|
return 'Offer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
factory CompanyOffer.fromJson(Map<String, dynamic> json) => CompanyOffer(
|
||||||
|
id: json['id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
type: json['type'] as String? ?? 'PERCENTAGE',
|
||||||
|
discountValue: json['discountValue'] as int? ?? 0,
|
||||||
|
promoCode: json['promoCode'] as String?,
|
||||||
|
isActive: json['isActive'] as bool? ?? true,
|
||||||
|
isPublic: json['isPublic'] as bool? ?? false,
|
||||||
|
isFeatured: json['isFeatured'] as bool? ?? false,
|
||||||
|
validFrom: DateTime.parse(json['validFrom'] as String),
|
||||||
|
validUntil: DateTime.parse(json['validUntil'] as String),
|
||||||
|
maxRedemptions: json['maxRedemptions'] as int?,
|
||||||
|
redemptionCount: json['redemptionCount'] as int? ?? 0,
|
||||||
|
categories: (json['categories'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
class ReportSummary {
|
||||||
|
final int totalReservations;
|
||||||
|
final double rentalRevenue;
|
||||||
|
final double totalPaid;
|
||||||
|
final double totalOutstanding;
|
||||||
|
final List<ReportItem> items;
|
||||||
|
|
||||||
|
const ReportSummary({
|
||||||
|
required this.totalReservations,
|
||||||
|
required this.rentalRevenue,
|
||||||
|
required this.totalPaid,
|
||||||
|
required this.totalOutstanding,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ReportSummary.fromJson(Map<String, dynamic> json) => ReportSummary(
|
||||||
|
totalReservations: json['totalReservations'] as int? ?? 0,
|
||||||
|
rentalRevenue: (json['rentalRevenue'] as num?)?.toDouble() ?? 0,
|
||||||
|
totalPaid: (json['totalPaid'] as num?)?.toDouble() ?? 0,
|
||||||
|
totalOutstanding:
|
||||||
|
(json['totalOutstanding'] as num?)?.toDouble() ?? 0,
|
||||||
|
items: (json['reservations'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => ReportItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReportItem {
|
||||||
|
final String id;
|
||||||
|
final String? customerName;
|
||||||
|
final String? vehicleName;
|
||||||
|
final DateTime startDate;
|
||||||
|
final DateTime endDate;
|
||||||
|
final double totalAmount;
|
||||||
|
final String status;
|
||||||
|
final String paymentStatus;
|
||||||
|
final String? source;
|
||||||
|
|
||||||
|
const ReportItem({
|
||||||
|
required this.id,
|
||||||
|
this.customerName,
|
||||||
|
this.vehicleName,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.status,
|
||||||
|
required this.paymentStatus,
|
||||||
|
this.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ReportItem.fromJson(Map<String, dynamic> json) => ReportItem(
|
||||||
|
id: json['id'] as String,
|
||||||
|
customerName: json['customerName'] as String?,
|
||||||
|
vehicleName: json['vehicleName'] as String?,
|
||||||
|
startDate: DateTime.parse(json['startDate'] as String),
|
||||||
|
endDate: DateTime.parse(json['endDate'] as String),
|
||||||
|
totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0,
|
||||||
|
status: json['status'] as String? ?? '',
|
||||||
|
paymentStatus: json['paymentStatus'] as String? ?? '',
|
||||||
|
source: json['source'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ class Reservation {
|
|||||||
final CustomerSummary? customer;
|
final CustomerSummary? customer;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? source;
|
final String? source;
|
||||||
|
final String? contractNumber;
|
||||||
|
final String? invoiceNumber;
|
||||||
|
|
||||||
const Reservation({
|
const Reservation({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -25,6 +27,8 @@ class Reservation {
|
|||||||
this.customer,
|
this.customer,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.source,
|
this.source,
|
||||||
|
this.contractNumber,
|
||||||
|
this.invoiceNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
int get days => endDate.difference(startDate).inDays;
|
int get days => endDate.difference(startDate).inDays;
|
||||||
@@ -49,6 +53,8 @@ class Reservation {
|
|||||||
createdAt: DateTime.parse(
|
createdAt: DateTime.parse(
|
||||||
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
||||||
source: json['source'] as String?,
|
source: json['source'] as String?,
|
||||||
|
contractNumber: json['contractNumber'] as String?,
|
||||||
|
invoiceNumber: json['invoiceNumber'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
class BrandSettings {
|
||||||
|
final String displayName;
|
||||||
|
final String? tagline;
|
||||||
|
final String? publicEmail;
|
||||||
|
final String? publicPhone;
|
||||||
|
final String? publicCity;
|
||||||
|
final String? publicCountry;
|
||||||
|
final String? websiteUrl;
|
||||||
|
final String? whatsappNumber;
|
||||||
|
final String? logoUrl;
|
||||||
|
final String primaryColor;
|
||||||
|
final bool isListedOnMarketplace;
|
||||||
|
final String? defaultLocale;
|
||||||
|
final String? defaultCurrency;
|
||||||
|
|
||||||
|
const BrandSettings({
|
||||||
|
required this.displayName,
|
||||||
|
this.tagline,
|
||||||
|
this.publicEmail,
|
||||||
|
this.publicPhone,
|
||||||
|
this.publicCity,
|
||||||
|
this.publicCountry,
|
||||||
|
this.websiteUrl,
|
||||||
|
this.whatsappNumber,
|
||||||
|
this.logoUrl,
|
||||||
|
required this.primaryColor,
|
||||||
|
required this.isListedOnMarketplace,
|
||||||
|
this.defaultLocale,
|
||||||
|
this.defaultCurrency,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BrandSettings.fromJson(Map<String, dynamic> json) => BrandSettings(
|
||||||
|
displayName: json['displayName'] as String? ?? '',
|
||||||
|
tagline: json['tagline'] as String?,
|
||||||
|
publicEmail: json['publicEmail'] as String?,
|
||||||
|
publicPhone: json['publicPhone'] as String?,
|
||||||
|
publicCity: json['publicCity'] as String?,
|
||||||
|
publicCountry: json['publicCountry'] as String?,
|
||||||
|
websiteUrl: json['websiteUrl'] as String?,
|
||||||
|
whatsappNumber: json['whatsappNumber'] as String?,
|
||||||
|
logoUrl: json['logoUrl'] as String?,
|
||||||
|
primaryColor: json['primaryColor'] as String? ?? '#1A56DB',
|
||||||
|
isListedOnMarketplace:
|
||||||
|
json['isListedOnMarketplace'] as bool? ?? false,
|
||||||
|
defaultLocale: json['defaultLocale'] as String?,
|
||||||
|
defaultCurrency: json['defaultCurrency'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'displayName': displayName,
|
||||||
|
if (tagline != null) 'tagline': tagline,
|
||||||
|
if (publicEmail != null) 'publicEmail': publicEmail,
|
||||||
|
if (publicPhone != null) 'publicPhone': publicPhone,
|
||||||
|
if (publicCity != null) 'publicCity': publicCity,
|
||||||
|
if (publicCountry != null) 'publicCountry': publicCountry,
|
||||||
|
if (websiteUrl != null) 'websiteUrl': websiteUrl,
|
||||||
|
if (whatsappNumber != null) 'whatsappNumber': whatsappNumber,
|
||||||
|
'primaryColor': primaryColor,
|
||||||
|
'isListedOnMarketplace': isListedOnMarketplace,
|
||||||
|
if (defaultLocale != null) 'defaultLocale': defaultLocale,
|
||||||
|
if (defaultCurrency != null) 'defaultCurrency': defaultCurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ class Vehicle {
|
|||||||
final int? seats;
|
final int? seats;
|
||||||
final String? transmission;
|
final String? transmission;
|
||||||
final String? fuelType;
|
final String? fuelType;
|
||||||
|
final String? color;
|
||||||
final bool isPublished;
|
final bool isPublished;
|
||||||
|
|
||||||
const Vehicle({
|
const Vehicle({
|
||||||
@@ -28,6 +29,7 @@ class Vehicle {
|
|||||||
this.seats,
|
this.seats,
|
||||||
this.transmission,
|
this.transmission,
|
||||||
this.fuelType,
|
this.fuelType,
|
||||||
|
this.color,
|
||||||
required this.isPublished,
|
required this.isPublished,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ class Vehicle {
|
|||||||
seats: json['seats'] as int?,
|
seats: json['seats'] as int?,
|
||||||
transmission: json['transmission'] as String?,
|
transmission: json['transmission'] as String?,
|
||||||
fuelType: json['fuelType'] as String?,
|
fuelType: json['fuelType'] as String?,
|
||||||
|
color: json['color'] as String?,
|
||||||
isPublished: json['isPublished'] as bool? ?? false,
|
isPublished: json['isPublished'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import '../../features/auth/screens/splash_screen.dart';
|
|||||||
import '../../features/auth/screens/landing_screen.dart';
|
import '../../features/auth/screens/landing_screen.dart';
|
||||||
import '../../features/auth/screens/role_screen.dart';
|
import '../../features/auth/screens/role_screen.dart';
|
||||||
import '../../features/auth/screens/employee_login_screen.dart';
|
import '../../features/auth/screens/employee_login_screen.dart';
|
||||||
import '../../features/dashboard/screens/dashboard_shell.dart';
|
|
||||||
import '../../features/dashboard/screens/home_screen.dart';
|
import '../../features/dashboard/screens/home_screen.dart';
|
||||||
import '../../features/dashboard/screens/vehicles_screen.dart';
|
import '../../features/dashboard/screens/vehicles_screen.dart';
|
||||||
import '../../features/dashboard/screens/vehicle_detail_screen.dart';
|
import '../../features/dashboard/screens/vehicle_detail_screen.dart';
|
||||||
@@ -15,7 +14,14 @@ import '../../features/dashboard/screens/reservations_screen.dart';
|
|||||||
import '../../features/dashboard/screens/reservation_detail_screen.dart';
|
import '../../features/dashboard/screens/reservation_detail_screen.dart';
|
||||||
import '../../features/dashboard/screens/customers_screen.dart';
|
import '../../features/dashboard/screens/customers_screen.dart';
|
||||||
import '../../features/dashboard/screens/customer_detail_screen.dart';
|
import '../../features/dashboard/screens/customer_detail_screen.dart';
|
||||||
import '../../features/client/screens/client_shell.dart';
|
import '../../features/dashboard/screens/notifications_screen.dart';
|
||||||
|
import '../../features/dashboard/screens/online_reservations_screen.dart';
|
||||||
|
import '../../features/dashboard/screens/contract_screen.dart';
|
||||||
|
import '../../features/dashboard/screens/contracts_screen.dart';
|
||||||
|
import '../../features/dashboard/screens/offers_screen.dart';
|
||||||
|
import '../../features/dashboard/screens/reports_screen.dart';
|
||||||
|
import '../../features/dashboard/screens/settings_screen.dart';
|
||||||
|
import '../../features/dashboard/screens/team_screen.dart';
|
||||||
import '../../features/client/screens/client_home_screen.dart';
|
import '../../features/client/screens/client_home_screen.dart';
|
||||||
import '../../features/client/screens/client_vehicle_detail_screen.dart';
|
import '../../features/client/screens/client_vehicle_detail_screen.dart';
|
||||||
import '../../features/client/screens/booking_screen.dart';
|
import '../../features/client/screens/booking_screen.dart';
|
||||||
@@ -25,7 +31,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/splash',
|
initialLocation: '/splash',
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
// Read (not watch) so redirect is evaluated on router.refresh()
|
|
||||||
final auth = ref.read(authProvider);
|
final auth = ref.read(authProvider);
|
||||||
final status = auth.status;
|
final status = auth.status;
|
||||||
final loc = state.matchedLocation;
|
final loc = state.matchedLocation;
|
||||||
@@ -33,12 +38,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final isLanding = loc == '/landing';
|
final isLanding = loc == '/landing';
|
||||||
final isLogin = loc.startsWith('/login');
|
final isLogin = loc.startsWith('/login');
|
||||||
|
|
||||||
// Still initialising — stay on splash
|
|
||||||
if (status == AuthStatus.unknown) {
|
if (status == AuthStatus.unknown) {
|
||||||
return isSplash ? null : '/splash';
|
return isSplash ? null : '/splash';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth resolved — always leave splash
|
|
||||||
if (isSplash) {
|
if (isSplash) {
|
||||||
if (status == AuthStatus.authenticated) {
|
if (status == AuthStatus.authenticated) {
|
||||||
return auth.userType == AppConstants.userTypeEmployee
|
return auth.userType == AppConstants.userTypeEmployee
|
||||||
@@ -49,7 +52,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status == AuthStatus.unauthenticated) {
|
if (status == AuthStatus.unauthenticated) {
|
||||||
// Allow landing, login routes, and the entire client section without auth
|
|
||||||
if (isLanding || isLogin || loc.startsWith('/client')) return null;
|
if (isLanding || isLogin || loc.startsWith('/client')) return null;
|
||||||
return '/landing';
|
return '/landing';
|
||||||
}
|
}
|
||||||
@@ -64,95 +66,99 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(path: '/splash', builder: (context, _) => const SplashScreen()),
|
||||||
path: '/splash',
|
GoRoute(path: '/landing', builder: (context, _) => const LandingScreen()),
|
||||||
builder: (context, _) => const SplashScreen(),
|
GoRoute(path: '/role', builder: (context, _) => const RoleScreen()),
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/landing',
|
|
||||||
builder: (context, _) => const LandingScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/role',
|
|
||||||
builder: (context, _) => const RoleScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login/employee',
|
path: '/login/employee',
|
||||||
builder: (context, _) => const EmployeeLoginScreen(),
|
builder: (context, _) => const EmployeeLoginScreen(),
|
||||||
),
|
),
|
||||||
ShellRoute(
|
GoRoute(path: '/dashboard', builder: (context, _) => const HomeScreen()),
|
||||||
builder: (context, state, child) => DashboardShell(child: child),
|
GoRoute(
|
||||||
routes: [
|
path: '/dashboard/vehicles',
|
||||||
GoRoute(
|
builder: (context, _) => const VehiclesScreen(),
|
||||||
path: '/dashboard',
|
|
||||||
builder: (context, _) => const HomeScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/dashboard/vehicles',
|
|
||||||
builder: (context, _) => const VehiclesScreen(),
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: ':id',
|
|
||||||
builder: (_, state) =>
|
|
||||||
VehicleDetailScreen(id: state.pathParameters['id']!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/dashboard/reservations',
|
|
||||||
builder: (context, _) => const ReservationsScreen(),
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: ':id',
|
|
||||||
builder: (_, state) =>
|
|
||||||
ReservationDetailScreen(id: state.pathParameters['id']!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/dashboard/customers',
|
|
||||||
builder: (context, _) => const CustomersScreen(),
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: ':id',
|
|
||||||
builder: (_, state) =>
|
|
||||||
CustomerDetailScreen(id: state.pathParameters['id']!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
ShellRoute(
|
GoRoute(
|
||||||
builder: (context, state, child) => ClientShell(child: child),
|
path: '/dashboard/reservations',
|
||||||
routes: [
|
builder: (context, _) => const ReservationsScreen(),
|
||||||
GoRoute(
|
),
|
||||||
path: '/client',
|
GoRoute(
|
||||||
builder: (context, _) => const ClientHomeScreen(),
|
path: '/dashboard/customers',
|
||||||
),
|
builder: (context, _) => const CustomersScreen(),
|
||||||
GoRoute(
|
),
|
||||||
path: '/client/vehicles/:id',
|
GoRoute(
|
||||||
builder: (_, state) => ClientVehicleDetailScreen(
|
path: '/dashboard/vehicles/:id',
|
||||||
id: state.pathParameters['id']!,
|
builder: (_, state) =>
|
||||||
vehicle: state.extra as MarketplaceVehicle?,
|
VehicleDetailScreen(id: state.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
),
|
GoRoute(
|
||||||
GoRoute(
|
path: '/dashboard/reservations/:id',
|
||||||
path: '/client/book/:vehicleId',
|
builder: (_, state) =>
|
||||||
builder: (_, state) => BookingScreen(
|
ReservationDetailScreen(id: state.pathParameters['id']!),
|
||||||
vehicleId: state.pathParameters['vehicleId']!,
|
),
|
||||||
vehicle: state.extra as MarketplaceVehicle?,
|
GoRoute(
|
||||||
),
|
path: '/dashboard/reservations/:id/contract',
|
||||||
),
|
builder: (_, state) =>
|
||||||
GoRoute(
|
ContractScreen(reservationId: state.pathParameters['id']!),
|
||||||
path: '/client/bookings',
|
),
|
||||||
builder: (context, _) => const MyBookingsScreen(),
|
GoRoute(
|
||||||
),
|
path: '/dashboard/customers/:id',
|
||||||
],
|
builder: (_, state) =>
|
||||||
|
CustomerDetailScreen(id: state.pathParameters['id']!),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/notifications',
|
||||||
|
builder: (context, _) => const NotificationsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/online-reservations',
|
||||||
|
builder: (context, _) => const OnlineReservationsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/contracts',
|
||||||
|
builder: (context, _) => const ContractsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/offers',
|
||||||
|
builder: (context, _) => const OffersScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/reports',
|
||||||
|
builder: (context, _) => const ReportsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/settings',
|
||||||
|
builder: (context, _) => const SettingsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/dashboard/team',
|
||||||
|
builder: (context, _) => const TeamScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/client',
|
||||||
|
builder: (context, _) => const ClientHomeScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/client/bookings',
|
||||||
|
builder: (context, _) => const MyBookingsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/client/vehicles/:id',
|
||||||
|
builder: (_, state) => ClientVehicleDetailScreen(
|
||||||
|
id: state.pathParameters['id']!,
|
||||||
|
vehicle: state.extra as MarketplaceVehicle?,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/client/book/:vehicleId',
|
||||||
|
builder: (_, state) => BookingScreen(
|
||||||
|
vehicleId: state.pathParameters['vehicleId']!,
|
||||||
|
vehicle: state.extra as MarketplaceVehicle?,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger redirect re-evaluation whenever auth state changes
|
|
||||||
ref.listen<AuthState>(authProvider, (_, _) => router.refresh());
|
ref.listen<AuthState>(authProvider, (_, _) => router.refresh());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../models/analytics.dart';
|
import '../models/analytics.dart';
|
||||||
|
import '../models/report.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
class AnalyticsService {
|
class AnalyticsService {
|
||||||
@@ -8,4 +9,18 @@ class AnalyticsService {
|
|||||||
final res = await _client.get('/analytics/dashboard');
|
final res = await _client.get('/analytics/dashboard');
|
||||||
return DashboardMetrics.fromJson(res.data as Map<String, dynamic>);
|
return DashboardMetrics.fromJson(res.data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ReportSummary> getReport({
|
||||||
|
required String startDate,
|
||||||
|
required String endDate,
|
||||||
|
String? status,
|
||||||
|
}) async {
|
||||||
|
final res = await _client.get('/analytics/report', params: {
|
||||||
|
'startDate': startDate,
|
||||||
|
'endDate': endDate,
|
||||||
|
'status': ?status,
|
||||||
|
'format': 'JSON',
|
||||||
|
});
|
||||||
|
return ReportSummary.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ApiClient {
|
|||||||
_dio.interceptors.add(InterceptorsWrapper(
|
_dio.interceptors.add(InterceptorsWrapper(
|
||||||
onRequest: (options, handler) async {
|
onRequest: (options, handler) async {
|
||||||
final token = await TokenStorage.getToken();
|
final token = await TokenStorage.getToken();
|
||||||
if (token != null) {
|
if (token != null && !_shouldSkipAuthHeader(options.path)) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
handler.next(options);
|
handler.next(options);
|
||||||
@@ -41,6 +41,11 @@ class ApiClient {
|
|||||||
|
|
||||||
static ApiClient get instance => _instance;
|
static ApiClient get instance => _instance;
|
||||||
|
|
||||||
|
bool _shouldSkipAuthHeader(String path) {
|
||||||
|
return path.endsWith('/auth/employee/login') ||
|
||||||
|
path.endsWith('/auth/renter/login');
|
||||||
|
}
|
||||||
|
|
||||||
// baseUrl is dynamic (platform-aware), so recreate when it may have changed.
|
// baseUrl is dynamic (platform-aware), so recreate when it may have changed.
|
||||||
// In practice this is called once and cached via the singleton.
|
// In practice this is called once and cached via the singleton.
|
||||||
Dio get dio => _dio;
|
Dio get dio => _dio;
|
||||||
@@ -54,5 +59,8 @@ class ApiClient {
|
|||||||
Future<Response> patch(String path, {dynamic data}) =>
|
Future<Response> patch(String path, {dynamic data}) =>
|
||||||
_dio.patch(path, data: data);
|
_dio.patch(path, data: data);
|
||||||
|
|
||||||
|
Future<Response> put(String path, {dynamic data}) =>
|
||||||
|
_dio.put(path, data: data);
|
||||||
|
|
||||||
Future<Response> delete(String path) => _dio.delete(path);
|
Future<Response> delete(String path) => _dio.delete(path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AuthService {
|
|||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
final res = await _client.post('/auth/employee/login', data: {
|
final res = await _client.post('/auth/employee/login', data: {
|
||||||
'email': email,
|
'email': email.trim().toLowerCase(),
|
||||||
'password': password,
|
'password': password,
|
||||||
});
|
});
|
||||||
final data = res.data as Map<String, dynamic>;
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class ContractCompany {
|
||||||
|
final String name;
|
||||||
|
final String? email;
|
||||||
|
final String? phone;
|
||||||
|
final String? address;
|
||||||
|
|
||||||
|
const ContractCompany(
|
||||||
|
{required this.name, this.email, this.phone, this.address});
|
||||||
|
|
||||||
|
factory ContractCompany.fromJson(Map<String, dynamic> j) {
|
||||||
|
final parts = <String>[];
|
||||||
|
final addr = j['address'];
|
||||||
|
if (addr is String && addr.trim().isNotEmpty) parts.add(addr.trim());
|
||||||
|
if (addr is Map) {
|
||||||
|
for (final v in addr.values) {
|
||||||
|
if (v is String && v.trim().isNotEmpty) parts.add(v.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j['city'] is String) parts.add(j['city'] as String);
|
||||||
|
if (j['country'] is String) parts.add(j['country'] as String);
|
||||||
|
return ContractCompany(
|
||||||
|
name: (j['legalName'] as String?)?.isNotEmpty == true
|
||||||
|
? j['legalName'] as String
|
||||||
|
: j['name'] as String,
|
||||||
|
email: j['email'] as String?,
|
||||||
|
phone: j['phone'] as String?,
|
||||||
|
address: parts.isEmpty ? null : parts.join(', '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractDriver {
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final String email;
|
||||||
|
final String? phone;
|
||||||
|
final String? driverLicense;
|
||||||
|
final String? licenseCountry;
|
||||||
|
final String? licenseExpiry;
|
||||||
|
final String? nationality;
|
||||||
|
|
||||||
|
const ContractDriver({
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.email,
|
||||||
|
this.phone,
|
||||||
|
this.driverLicense,
|
||||||
|
this.licenseCountry,
|
||||||
|
this.licenseExpiry,
|
||||||
|
this.nationality,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName => '$firstName $lastName';
|
||||||
|
|
||||||
|
factory ContractDriver.fromJson(Map<String, dynamic> j) => ContractDriver(
|
||||||
|
firstName: j['firstName'] as String,
|
||||||
|
lastName: j['lastName'] as String,
|
||||||
|
email: j['email'] as String,
|
||||||
|
phone: j['phone'] as String?,
|
||||||
|
driverLicense: j['driverLicense'] as String?,
|
||||||
|
licenseCountry: j['licenseCountry'] as String?,
|
||||||
|
licenseExpiry: j['licenseExpiry'] as String?,
|
||||||
|
nationality: j['nationality'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractVehicle {
|
||||||
|
final String make;
|
||||||
|
final String model;
|
||||||
|
final int year;
|
||||||
|
final String? color;
|
||||||
|
final String licensePlate;
|
||||||
|
final String? vin;
|
||||||
|
final String category;
|
||||||
|
|
||||||
|
const ContractVehicle({
|
||||||
|
required this.make,
|
||||||
|
required this.model,
|
||||||
|
required this.year,
|
||||||
|
this.color,
|
||||||
|
required this.licensePlate,
|
||||||
|
this.vin,
|
||||||
|
required this.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get displayName => '$year $make $model';
|
||||||
|
|
||||||
|
factory ContractVehicle.fromJson(Map<String, dynamic> j) => ContractVehicle(
|
||||||
|
make: j['make'] as String,
|
||||||
|
model: j['model'] as String,
|
||||||
|
year: j['year'] as int,
|
||||||
|
color: j['color'] as String?,
|
||||||
|
licensePlate: j['licensePlate'] as String,
|
||||||
|
vin: j['vin'] as String?,
|
||||||
|
category: j['category'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractLineItem {
|
||||||
|
final String description;
|
||||||
|
final int qty;
|
||||||
|
final int unitPrice;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
const ContractLineItem({
|
||||||
|
required this.description,
|
||||||
|
required this.qty,
|
||||||
|
required this.unitPrice,
|
||||||
|
required this.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ContractLineItem.fromJson(Map<String, dynamic> j) => ContractLineItem(
|
||||||
|
description: j['description'] as String,
|
||||||
|
qty: (j['qty'] as num).toInt(),
|
||||||
|
unitPrice: (j['unitPrice'] as num).toInt(),
|
||||||
|
total: (j['total'] as num).toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractPayment {
|
||||||
|
final String id;
|
||||||
|
final int amount;
|
||||||
|
final String currency;
|
||||||
|
final String type;
|
||||||
|
final String status;
|
||||||
|
final String? paymentMethod;
|
||||||
|
final DateTime? paidAt;
|
||||||
|
|
||||||
|
const ContractPayment({
|
||||||
|
required this.id,
|
||||||
|
required this.amount,
|
||||||
|
required this.currency,
|
||||||
|
required this.type,
|
||||||
|
required this.status,
|
||||||
|
this.paymentMethod,
|
||||||
|
this.paidAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ContractPayment.fromJson(Map<String, dynamic> j) => ContractPayment(
|
||||||
|
id: j['id'] as String,
|
||||||
|
amount: (j['amount'] as num).toInt(),
|
||||||
|
currency: j['currency'] as String? ?? 'USD',
|
||||||
|
type: j['type'] as String,
|
||||||
|
status: j['status'] as String,
|
||||||
|
paymentMethod: j['paymentMethod'] as String?,
|
||||||
|
paidAt: j['paidAt'] != null
|
||||||
|
? DateTime.tryParse(j['paidAt'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractInspection {
|
||||||
|
final int? mileage;
|
||||||
|
final String? fuelLevel;
|
||||||
|
final String? generalCondition;
|
||||||
|
final String? employeeNotes;
|
||||||
|
|
||||||
|
const ContractInspection(
|
||||||
|
{this.mileage, this.fuelLevel, this.generalCondition, this.employeeNotes});
|
||||||
|
|
||||||
|
factory ContractInspection.fromJson(Map<String, dynamic> j) =>
|
||||||
|
ContractInspection(
|
||||||
|
mileage: j['mileage'] as int?,
|
||||||
|
fuelLevel: j['fuelLevel'] as String?,
|
||||||
|
generalCondition: j['generalCondition'] as String?,
|
||||||
|
employeeNotes: j['employeeNotes'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractPayload {
|
||||||
|
final String reservationId;
|
||||||
|
final String? contractNumber;
|
||||||
|
final String? invoiceNumber;
|
||||||
|
final String status;
|
||||||
|
final String paymentStatus;
|
||||||
|
final String? notes;
|
||||||
|
final DateTime generatedAt;
|
||||||
|
final ContractCompany company;
|
||||||
|
final ContractDriver driver;
|
||||||
|
final ContractVehicle vehicle;
|
||||||
|
final DateTime startDate;
|
||||||
|
final DateTime endDate;
|
||||||
|
final int totalDays;
|
||||||
|
final String? pickupLocation;
|
||||||
|
final String? returnLocation;
|
||||||
|
final List<ContractLineItem> lineItems;
|
||||||
|
final int subtotal;
|
||||||
|
final int taxTotal;
|
||||||
|
final int total;
|
||||||
|
final int amountPaid;
|
||||||
|
final int balanceDue;
|
||||||
|
final String currency;
|
||||||
|
final List<ContractPayment> payments;
|
||||||
|
final ContractInspection? checkIn;
|
||||||
|
final ContractInspection? checkOut;
|
||||||
|
final String? terms;
|
||||||
|
final String? fuelPolicy;
|
||||||
|
final String? depositPolicy;
|
||||||
|
|
||||||
|
const ContractPayload({
|
||||||
|
required this.reservationId,
|
||||||
|
this.contractNumber,
|
||||||
|
this.invoiceNumber,
|
||||||
|
required this.status,
|
||||||
|
required this.paymentStatus,
|
||||||
|
this.notes,
|
||||||
|
required this.generatedAt,
|
||||||
|
required this.company,
|
||||||
|
required this.driver,
|
||||||
|
required this.vehicle,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
required this.totalDays,
|
||||||
|
this.pickupLocation,
|
||||||
|
this.returnLocation,
|
||||||
|
required this.lineItems,
|
||||||
|
required this.subtotal,
|
||||||
|
required this.taxTotal,
|
||||||
|
required this.total,
|
||||||
|
required this.amountPaid,
|
||||||
|
required this.balanceDue,
|
||||||
|
required this.currency,
|
||||||
|
required this.payments,
|
||||||
|
this.checkIn,
|
||||||
|
this.checkOut,
|
||||||
|
this.terms,
|
||||||
|
this.fuelPolicy,
|
||||||
|
this.depositPolicy,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ContractPayload.fromJson(Map<String, dynamic> j) {
|
||||||
|
final period = j['rentalPeriod'] as Map<String, dynamic>;
|
||||||
|
final invoice = j['invoice'] as Map<String, dynamic>;
|
||||||
|
final inspections =
|
||||||
|
j['inspections'] as Map<String, dynamic>? ?? {};
|
||||||
|
final termsData = j['terms'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
return ContractPayload(
|
||||||
|
reservationId: j['reservationId'] as String,
|
||||||
|
contractNumber: j['contractNumber'] as String?,
|
||||||
|
invoiceNumber: j['invoiceNumber'] as String?,
|
||||||
|
status: j['status'] as String,
|
||||||
|
paymentStatus: j['paymentStatus'] as String,
|
||||||
|
notes: j['notes'] as String?,
|
||||||
|
generatedAt: DateTime.parse(j['generatedAt'] as String),
|
||||||
|
company:
|
||||||
|
ContractCompany.fromJson(j['company'] as Map<String, dynamic>),
|
||||||
|
driver:
|
||||||
|
ContractDriver.fromJson(j['driver'] as Map<String, dynamic>),
|
||||||
|
vehicle:
|
||||||
|
ContractVehicle.fromJson(j['vehicle'] as Map<String, dynamic>),
|
||||||
|
startDate: DateTime.parse(period['startDate'] as String),
|
||||||
|
endDate: DateTime.parse(period['endDate'] as String),
|
||||||
|
totalDays: (period['totalDays'] as num).toInt(),
|
||||||
|
pickupLocation: period['pickupLocation'] as String?,
|
||||||
|
returnLocation: period['returnLocation'] as String?,
|
||||||
|
lineItems: (invoice['lineItems'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => ContractLineItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
subtotal: (invoice['subtotal'] as num).toInt(),
|
||||||
|
taxTotal: (invoice['taxTotal'] as num).toInt(),
|
||||||
|
total: (invoice['total'] as num).toInt(),
|
||||||
|
amountPaid: (invoice['amountPaid'] as num).toInt(),
|
||||||
|
balanceDue: (invoice['balanceDue'] as num).toInt(),
|
||||||
|
currency: invoice['currency'] as String? ?? 'USD',
|
||||||
|
payments: (invoice['payments'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => ContractPayment.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
checkIn: inspections['checkIn'] != null
|
||||||
|
? ContractInspection.fromJson(
|
||||||
|
inspections['checkIn'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
checkOut: inspections['checkOut'] != null
|
||||||
|
? ContractInspection.fromJson(
|
||||||
|
inspections['checkOut'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
terms: termsData['terms'] as String?,
|
||||||
|
fuelPolicy: termsData['fuelPolicy'] as String?,
|
||||||
|
depositPolicy: termsData['depositPolicy'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractService {
|
||||||
|
final _client = ApiClient.instance;
|
||||||
|
|
||||||
|
Future<ContractPayload> getContract(String reservationId) async {
|
||||||
|
final res = await _client.get('/reservations/$reservationId/contract');
|
||||||
|
return ContractPayload.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import '../models/customer.dart';
|
import '../models/customer.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
@@ -26,4 +27,40 @@ class CustomerService {
|
|||||||
final res = await _client.post('/customers', data: data);
|
final res = await _client.post('/customers', data: data);
|
||||||
return Customer.fromJson(res.data as Map<String, dynamic>);
|
return Customer.fromJson(res.data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Customer> updateCustomer(String id, Map<String, dynamic> data) async {
|
||||||
|
final res = await _client.patch('/customers/$id', data: data);
|
||||||
|
return Customer.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> flagCustomer(String id, String reason) async {
|
||||||
|
await _client.post('/customers/$id/flag', data: {'reason': reason});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unflagCustomer(String id) async {
|
||||||
|
await _client.delete('/customers/$id/flag');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Customer> approveLicense(
|
||||||
|
String id, String decision, {String? note}) async {
|
||||||
|
final res = await _client.post('/customers/$id/approve-license',
|
||||||
|
data: {'decision': decision, 'note': ?note});
|
||||||
|
return Customer.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Customer> uploadLicenseImage(String id, String filePath) async {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': await MultipartFile.fromFile(filePath),
|
||||||
|
});
|
||||||
|
final res = await _client.dio.post(
|
||||||
|
'/customers/$id/license-image',
|
||||||
|
data: formData,
|
||||||
|
options: Options(headers: {'Content-Type': 'multipart/form-data'}),
|
||||||
|
);
|
||||||
|
final body = res.data;
|
||||||
|
final payload = (body is Map<String, dynamic> && body.containsKey('data'))
|
||||||
|
? body['data']
|
||||||
|
: body;
|
||||||
|
return Customer.fromJson(payload as Map<String, dynamic>);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ class MarketplaceService {
|
|||||||
'startDate': '${startDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
'startDate': '${startDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||||
if (endDate != null)
|
if (endDate != null)
|
||||||
'endDate': '${endDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
'endDate': '${endDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||||
if (category != null) 'category': category,
|
'category': ?category,
|
||||||
if (transmission != null) 'transmission': transmission,
|
'transmission': ?transmission,
|
||||||
if (make != null && make.isNotEmpty) 'make': make,
|
if (make != null && make.isNotEmpty) 'make': make,
|
||||||
if (maxPriceCents != null) 'maxPrice': maxPriceCents,
|
'maxPrice': ?maxPriceCents,
|
||||||
});
|
});
|
||||||
|
|
||||||
final data = res.data;
|
final data = res.data;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import '../models/notification.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
final _client = ApiClient.instance;
|
||||||
|
|
||||||
|
Future<List<AppNotification>> getNotifications({bool? unreadOnly}) async {
|
||||||
|
final res = await _client.get('/notifications/company', params: {
|
||||||
|
if (unreadOnly == true) 'unread': true,
|
||||||
|
});
|
||||||
|
final list = res.data as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => AppNotification.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getUnreadCount() async {
|
||||||
|
final res = await _client.get('/notifications/unread-count');
|
||||||
|
return (res.data as Map<String, dynamic>)['unread'] as int? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markRead(String id) async {
|
||||||
|
await _client.post('/notifications/company/$id/read');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markAllRead() async {
|
||||||
|
await _client.post('/notifications/company/read-all');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import '../models/offer.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class OfferService {
|
||||||
|
final _client = ApiClient.instance;
|
||||||
|
|
||||||
|
Future<List<CompanyOffer>> getOffers() async {
|
||||||
|
final res = await _client.get('/offers');
|
||||||
|
final list = res.data as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => CompanyOffer.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CompanyOffer> getOffer(String id) async {
|
||||||
|
final res = await _client.get('/offers/$id');
|
||||||
|
return CompanyOffer.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CompanyOffer> createOffer(Map<String, dynamic> data) async {
|
||||||
|
final res = await _client.post('/offers', data: data);
|
||||||
|
return CompanyOffer.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CompanyOffer> updateOffer(String id, Map<String, dynamic> data) async {
|
||||||
|
final res = await _client.patch('/offers/$id', data: data);
|
||||||
|
return CompanyOffer.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteOffer(String id) async {
|
||||||
|
await _client.delete('/offers/$id');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> activateOffer(String id) async {
|
||||||
|
await _client.post('/offers/$id/activate');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deactivateOffer(String id) async {
|
||||||
|
await _client.post('/offers/$id/deactivate');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class Payment {
|
||||||
|
final String id;
|
||||||
|
final int amount;
|
||||||
|
final String currency;
|
||||||
|
final String type;
|
||||||
|
final String paymentMethod;
|
||||||
|
final String status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const Payment({
|
||||||
|
required this.id,
|
||||||
|
required this.amount,
|
||||||
|
required this.currency,
|
||||||
|
required this.type,
|
||||||
|
required this.paymentMethod,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Payment.fromJson(Map<String, dynamic> json) => Payment(
|
||||||
|
id: json['id'] as String,
|
||||||
|
amount: json['amount'] as int? ?? 0,
|
||||||
|
currency: json['currency'] as String? ?? 'USD',
|
||||||
|
type: json['type'] as String? ?? 'CHARGE',
|
||||||
|
paymentMethod: json['paymentMethod'] as String? ?? 'CASH',
|
||||||
|
status: json['status'] as String? ?? 'COMPLETED',
|
||||||
|
createdAt: DateTime.parse(
|
||||||
|
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentService {
|
||||||
|
final _client = ApiClient.instance;
|
||||||
|
|
||||||
|
Future<List<Payment>> getPayments(String reservationId) async {
|
||||||
|
final res = await _client.get('/payments/reservations/$reservationId');
|
||||||
|
final list = res.data as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => Payment.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Payment> recordManualPayment(
|
||||||
|
String reservationId, Map<String, dynamic> data) async {
|
||||||
|
final res = await _client.post(
|
||||||
|
'/payments/reservations/$reservationId/manual',
|
||||||
|
data: data);
|
||||||
|
return Payment.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,38 @@
|
|||||||
import '../models/reservation.dart';
|
import '../models/reservation.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class ReservationInspection {
|
||||||
|
final String id;
|
||||||
|
final String type; // CHECKIN | CHECKOUT
|
||||||
|
final int? mileage;
|
||||||
|
final String? fuelLevel;
|
||||||
|
final String? generalCondition;
|
||||||
|
final String? employeeNotes;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const ReservationInspection({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
this.mileage,
|
||||||
|
this.fuelLevel,
|
||||||
|
this.generalCondition,
|
||||||
|
this.employeeNotes,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ReservationInspection.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ReservationInspection(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type: json['type'] as String,
|
||||||
|
mileage: json['mileage'] as int?,
|
||||||
|
fuelLevel: json['fuelLevel'] as String?,
|
||||||
|
generalCondition: json['generalCondition'] as String?,
|
||||||
|
employeeNotes: json['employeeNotes'] as String?,
|
||||||
|
createdAt: DateTime.parse(
|
||||||
|
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class ReservationService {
|
class ReservationService {
|
||||||
final _client = ApiClient.instance;
|
final _client = ApiClient.instance;
|
||||||
|
|
||||||
@@ -8,13 +40,17 @@ class ReservationService {
|
|||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
String? status,
|
String? status,
|
||||||
|
String? source,
|
||||||
String? search,
|
String? search,
|
||||||
|
String? customerId,
|
||||||
}) async {
|
}) async {
|
||||||
final res = await _client.get('/reservations', params: {
|
final res = await _client.get('/reservations', params: {
|
||||||
'page': page,
|
'page': page,
|
||||||
'pageSize': pageSize,
|
'pageSize': pageSize,
|
||||||
if (status != null) 'status': status,
|
'status': ?status,
|
||||||
|
'source': ?source,
|
||||||
if (search != null && search.isNotEmpty) 'search': search,
|
if (search != null && search.isNotEmpty) 'search': search,
|
||||||
|
'customerId': ?customerId,
|
||||||
});
|
});
|
||||||
return ReservationListResponse.fromJson(res.data as Map<String, dynamic>);
|
return ReservationListResponse.fromJson(res.data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
@@ -44,4 +80,26 @@ class ReservationService {
|
|||||||
Future<void> checkout(String id, int mileage) async {
|
Future<void> checkout(String id, int mileage) async {
|
||||||
await _client.post('/reservations/$id/checkout', data: {'mileage': mileage});
|
await _client.post('/reservations/$id/checkout', data: {'mileage': mileage});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Reservation> closeReservation(String id) async {
|
||||||
|
final res = await _client.post('/reservations/$id/close');
|
||||||
|
return Reservation.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ReservationInspection>> getInspections(String id) async {
|
||||||
|
final res = await _client.get('/reservations/$id/inspections');
|
||||||
|
final list = res.data as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) =>
|
||||||
|
ReservationInspection.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ReservationInspection> upsertInspection(
|
||||||
|
String id, String type, Map<String, dynamic> data) async {
|
||||||
|
final res = await _client.put(
|
||||||
|
'/reservations/$id/inspections/$type',
|
||||||
|
data: data);
|
||||||
|
return ReservationInspection.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import '../models/settings.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class SettingsService {
|
||||||
|
final _client = ApiClient.instance;
|
||||||
|
|
||||||
|
Future<BrandSettings> getBrand() async {
|
||||||
|
final res = await _client.get('/companies/me/brand');
|
||||||
|
return BrandSettings.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BrandSettings> updateBrand(Map<String, dynamic> data) async {
|
||||||
|
final res = await _client.patch('/companies/me/brand', data: data);
|
||||||
|
return BrandSettings.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class TeamMember {
|
||||||
|
final String id;
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final String email;
|
||||||
|
final String role;
|
||||||
|
final bool isActive;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const TeamMember({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.email,
|
||||||
|
required this.role,
|
||||||
|
required this.isActive,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName => '$firstName $lastName';
|
||||||
|
|
||||||
|
factory TeamMember.fromJson(Map<String, dynamic> json) => TeamMember(
|
||||||
|
id: json['id'] as String,
|
||||||
|
firstName: json['firstName'] as String,
|
||||||
|
lastName: json['lastName'] as String,
|
||||||
|
email: json['email'] as String,
|
||||||
|
role: json['role'] as String? ?? 'AGENT',
|
||||||
|
isActive: json['isActive'] as bool? ?? true,
|
||||||
|
createdAt: DateTime.parse(
|
||||||
|
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TeamService {
|
||||||
|
final _client = ApiClient.instance;
|
||||||
|
|
||||||
|
Future<List<TeamMember>> getMembers() async {
|
||||||
|
final res = await _client.get('/team');
|
||||||
|
final list = res.data as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => TeamMember.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> inviteMember(Map<String, dynamic> data) async {
|
||||||
|
await _client.post('/team/invite', data: data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateRole(String id, String role) async {
|
||||||
|
await _client.patch('/team/$id/role', data: {'role': role});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deactivateMember(String id) async {
|
||||||
|
await _client.post('/team/$id/deactivate');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reactivateMember(String id) async {
|
||||||
|
await _client.post('/team/$id/reactivate');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,69 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class MaintenanceLog {
|
||||||
|
final String id;
|
||||||
|
final String type;
|
||||||
|
final String? description;
|
||||||
|
final int? cost;
|
||||||
|
final int? mileage;
|
||||||
|
final DateTime performedAt;
|
||||||
|
final DateTime? nextDueAt;
|
||||||
|
final int? nextDueMileage;
|
||||||
|
|
||||||
|
const MaintenanceLog({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
this.description,
|
||||||
|
this.cost,
|
||||||
|
this.mileage,
|
||||||
|
required this.performedAt,
|
||||||
|
this.nextDueAt,
|
||||||
|
this.nextDueMileage,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MaintenanceLog.fromJson(Map<String, dynamic> json) => MaintenanceLog(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type: json['type'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
cost: json['cost'] as int?,
|
||||||
|
mileage: json['mileage'] as int?,
|
||||||
|
performedAt: DateTime.parse(json['performedAt'] as String),
|
||||||
|
nextDueAt: json['nextDueAt'] != null
|
||||||
|
? DateTime.parse(json['nextDueAt'] as String)
|
||||||
|
: null,
|
||||||
|
nextDueMileage: json['nextDueMileage'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CalendarEvent {
|
||||||
|
final String id;
|
||||||
|
final String type; // RESERVATION | MAINTENANCE | BLOCK
|
||||||
|
final DateTime startDate;
|
||||||
|
final DateTime endDate;
|
||||||
|
final String? status;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const CalendarEvent({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
this.status,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type: json['type'] as String,
|
||||||
|
startDate: DateTime.parse(json['startDate'] as String),
|
||||||
|
endDate: DateTime.parse(json['endDate'] as String),
|
||||||
|
status: json['status'] as String?,
|
||||||
|
label: json['label'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class VehicleService {
|
class VehicleService {
|
||||||
final _client = ApiClient.instance;
|
final _client = ApiClient.instance;
|
||||||
|
|
||||||
@@ -13,7 +76,7 @@ class VehicleService {
|
|||||||
final res = await _client.get('/vehicles', params: {
|
final res = await _client.get('/vehicles', params: {
|
||||||
'page': page,
|
'page': page,
|
||||||
'pageSize': pageSize,
|
'pageSize': pageSize,
|
||||||
if (status != null) 'status': status,
|
'status': ?status,
|
||||||
if (search != null && search.isNotEmpty) 'search': search,
|
if (search != null && search.isNotEmpty) 'search': search,
|
||||||
});
|
});
|
||||||
return VehicleListResponse.fromJson(res.data as Map<String, dynamic>);
|
return VehicleListResponse.fromJson(res.data as Map<String, dynamic>);
|
||||||
@@ -34,7 +97,70 @@ class VehicleService {
|
|||||||
return Vehicle.fromJson(res.data as Map<String, dynamic>);
|
return Vehicle.fromJson(res.data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteVehicle(String id) async {
|
||||||
|
await _client.delete('/vehicles/$id');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> togglePublish(String id, bool publish) async {
|
Future<void> togglePublish(String id, bool publish) async {
|
||||||
await _client.patch('/vehicles/$id/publish', data: {'isPublished': publish});
|
await _client.patch('/vehicles/$id/publish', data: {'isPublished': publish});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Vehicle> uploadPhotos(String id, List<String> filePaths) async {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'photos': [
|
||||||
|
for (final path in filePaths)
|
||||||
|
await MultipartFile.fromFile(path),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
final res = await _client.dio.post(
|
||||||
|
'/vehicles/$id/photos',
|
||||||
|
data: formData,
|
||||||
|
options: Options(headers: {'Content-Type': 'multipart/form-data'}),
|
||||||
|
);
|
||||||
|
final body = res.data;
|
||||||
|
final payload = (body is Map<String, dynamic> && body.containsKey('data'))
|
||||||
|
? body['data']
|
||||||
|
: body;
|
||||||
|
return Vehicle.fromJson(payload as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Vehicle> deletePhoto(String id, int idx) async {
|
||||||
|
final res = await _client.delete('/vehicles/$id/photos/$idx');
|
||||||
|
return Vehicle.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MaintenanceLog>> getMaintenance(String id) async {
|
||||||
|
final res = await _client.get('/vehicles/$id/maintenance');
|
||||||
|
final list = res.data as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => MaintenanceLog.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<MaintenanceLog> addMaintenance(
|
||||||
|
String id, Map<String, dynamic> data) async {
|
||||||
|
final res = await _client.post('/vehicles/$id/maintenance', data: data);
|
||||||
|
return MaintenanceLog.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<CalendarEvent>> getCalendar(
|
||||||
|
String id, int year, int month) async {
|
||||||
|
final res = await _client.get('/vehicles/$id/calendar',
|
||||||
|
params: {'year': year, 'month': month});
|
||||||
|
final list = res.data as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CalendarEvent> createCalendarBlock(
|
||||||
|
String id, Map<String, dynamic> data) async {
|
||||||
|
final res =
|
||||||
|
await _client.post('/vehicles/$id/calendar/blocks', data: data);
|
||||||
|
return CalendarEvent.fromJson(res.data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteCalendarBlock(String id, String blockId) async {
|
||||||
|
await _client.delete('/vehicles/$id/calendar/blocks/$blockId');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import '../../../core/providers/auth_provider.dart';
|
import '../../../core/providers/auth_provider.dart';
|
||||||
|
|
||||||
class EmployeeLoginScreen extends ConsumerStatefulWidget {
|
class EmployeeLoginScreen extends ConsumerStatefulWidget {
|
||||||
@@ -36,15 +37,36 @@ class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
|
|||||||
_emailController.text.trim(),
|
_emailController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} on DioException catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Invalid email or password. Please try again.';
|
_error = _messageForDioError(e);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Sign in failed. Please try again.';
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _messageForDioError(DioException error) {
|
||||||
|
final statusCode = error.response?.statusCode;
|
||||||
|
if (statusCode == 401 || statusCode == 403) {
|
||||||
|
return 'Invalid email or password. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (error.type) {
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
return 'Unable to reach the server. Check the API URL or your connection and try again.';
|
||||||
|
default:
|
||||||
|
return 'Sign in failed. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -107,6 +129,10 @@ class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
|
|||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
|
textCapitalization: TextCapitalization.none,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.username, AutofillHints.email],
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
prefixIcon: Icon(Icons.email_outlined),
|
prefixIcon: Icon(Icons.email_outlined),
|
||||||
@@ -120,6 +146,9 @@ class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
|
|||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: _obscurePassword,
|
obscureText: _obscurePassword,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
onFieldSubmitted: (_) => _login(),
|
onFieldSubmitted: (_) => _login(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Password',
|
labelText: 'Password',
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ import '../providers/client_providers.dart';
|
|||||||
import '../../../core/models/marketplace.dart';
|
import '../../../core/models/marketplace.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../widgets/preferences_bar.dart';
|
import '../../../widgets/preferences_bar.dart';
|
||||||
|
import 'client_shell.dart';
|
||||||
import 'widgets/marketplace_vehicle_card.dart';
|
import 'widgets/marketplace_vehicle_card.dart';
|
||||||
|
import 'widgets/offer_banner.dart';
|
||||||
|
|
||||||
const _orange = Color(0xFFFF6B00);
|
const _orange = Color(0xFFFF6B00);
|
||||||
const _blue = Color(0xFF1A56DB);
|
const _blue = Color(0xFF1A56DB);
|
||||||
|
|
||||||
const _allCarTypes = [
|
const _allCarTypes = [
|
||||||
'ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE',
|
'ECONOMY',
|
||||||
'SUV', 'LUXURY', 'VAN', 'TRUCK',
|
'COMPACT',
|
||||||
|
'MIDSIZE',
|
||||||
|
'FULLSIZE',
|
||||||
|
'SUV',
|
||||||
|
'LUXURY',
|
||||||
|
'VAN',
|
||||||
|
'TRUCK',
|
||||||
];
|
];
|
||||||
|
|
||||||
String _capitalize(String s) =>
|
String _capitalize(String s) =>
|
||||||
@@ -62,8 +70,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
endDate: _endDate,
|
endDate: _endDate,
|
||||||
categories: _selectedTypes.isEmpty ? null : Set.from(_selectedTypes),
|
categories: _selectedTypes.isEmpty ? null : Set.from(_selectedTypes),
|
||||||
maxPriceCents: _priceFilterActive ? _maxPriceCents : null,
|
maxPriceCents: _priceFilterActive ? _maxPriceCents : null,
|
||||||
minPriceCents:
|
minPriceCents: _priceFilterActive && _minPriceCents > 0
|
||||||
_priceFilterActive && _minPriceCents > 0 ? _minPriceCents : null,
|
? _minPriceCents
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +87,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
? DateTimeRange(start: _startDate!, end: _endDate!)
|
? DateTimeRange(start: _startDate!, end: _endDate!)
|
||||||
: DateTimeRange(start: now, end: now.add(const Duration(days: 1))),
|
: DateTimeRange(start: now, end: now.add(const Duration(days: 1))),
|
||||||
builder: (context, child) => Theme(
|
builder: (context, child) => Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(
|
||||||
colorScheme: cs.copyWith(primary: _orange),
|
context,
|
||||||
),
|
).copyWith(colorScheme: cs.copyWith(primary: _orange)),
|
||||||
child: child!,
|
child: child!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -174,7 +183,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<MarketplaceVehicle> _applyClientFilters(
|
List<MarketplaceVehicle> _applyClientFilters(
|
||||||
List<MarketplaceVehicle> all, SearchParams params) {
|
List<MarketplaceVehicle> all,
|
||||||
|
SearchParams params,
|
||||||
|
) {
|
||||||
var result = all;
|
var result = all;
|
||||||
final cats = params.categories;
|
final cats = params.categories;
|
||||||
if (cats != null && cats.length > 1) {
|
if (cats != null && cats.length > 1) {
|
||||||
@@ -199,14 +210,14 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
final typeLabel = _selectedTypes.isEmpty
|
final typeLabel = _selectedTypes.isEmpty
|
||||||
? l10n.allCarTypes
|
? l10n.allCarTypes
|
||||||
: _selectedTypes.length == 1
|
: _selectedTypes.length == 1
|
||||||
? _capitalize(_selectedTypes.first)
|
? _capitalize(_selectedTypes.first)
|
||||||
: '${_selectedTypes.length} types';
|
: '${_selectedTypes.length} types';
|
||||||
|
|
||||||
final priceLabel = _priceFilterActive
|
final priceLabel = _priceFilterActive
|
||||||
? '\$${(_minPriceCents / 100).toStringAsFixed(0)} – \$${(_maxPriceCents / 100).toStringAsFixed(0)}/day'
|
? '\$${(_minPriceCents / 100).toStringAsFixed(0)} – \$${(_maxPriceCents / 100).toStringAsFixed(0)}/day'
|
||||||
: l10n.anyPrice;
|
: l10n.anyPrice;
|
||||||
|
|
||||||
return Scaffold(
|
return ClientShell(
|
||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -268,12 +279,14 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
// Pickup
|
// Pickup
|
||||||
_SearchInputBox(
|
_SearchInputBox(
|
||||||
onTap: () => citiesAsync
|
onTap: () => citiesAsync.whenData(
|
||||||
.whenData((c) => _showCitySheet(c, l10n)),
|
(c) => _showCitySheet(c, l10n),
|
||||||
|
),
|
||||||
child: _LocationRow(
|
child: _LocationRow(
|
||||||
icon: Icons.radio_button_checked,
|
icon: Icons.radio_button_checked,
|
||||||
iconColor: _orange,
|
iconColor: _orange,
|
||||||
text: _city ??
|
text:
|
||||||
|
_city ??
|
||||||
(_sameDropoff
|
(_sameDropoff
|
||||||
? l10n.findCarToRent
|
? l10n.findCarToRent
|
||||||
: l10n.pickupLocation),
|
: l10n.pickupLocation),
|
||||||
@@ -307,17 +320,20 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
// Drop-off box
|
// Drop-off box
|
||||||
_SearchInputBox(
|
_SearchInputBox(
|
||||||
onTap: () => citiesAsync.whenData(
|
onTap: () => citiesAsync.whenData(
|
||||||
(c) => _showDropoffCitySheet(c, l10n)),
|
(c) => _showDropoffCitySheet(c, l10n),
|
||||||
|
),
|
||||||
child: _LocationRow(
|
child: _LocationRow(
|
||||||
icon: Icons.location_on,
|
icon: Icons.location_on,
|
||||||
iconColor: _blue,
|
iconColor: _blue,
|
||||||
text: _dropoffCity ??
|
text:
|
||||||
|
_dropoffCity ??
|
||||||
l10n.dropoffLocation,
|
l10n.dropoffLocation,
|
||||||
placeholder: _dropoffCity == null,
|
placeholder: _dropoffCity == null,
|
||||||
cs: cs,
|
cs: cs,
|
||||||
onClear: _dropoffCity != null
|
onClear: _dropoffCity != null
|
||||||
? () => setState(
|
? () => setState(
|
||||||
() => _dropoffCity = null)
|
() => _dropoffCity = null,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -343,13 +359,18 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
)
|
)
|
||||||
: Row(
|
: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.calendar_today_outlined,
|
Icon(
|
||||||
size: 18, color: cs.onSurfaceVariant),
|
Icons.calendar_today_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
l10n.pickDates,
|
l10n.pickDates,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurfaceVariant, fontSize: 15),
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -382,10 +403,10 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
onTap: () => _showPriceSheet(l10n),
|
onTap: () => _showPriceSheet(l10n),
|
||||||
onClear: _priceFilterActive
|
onClear: _priceFilterActive
|
||||||
? () => setState(() {
|
? () => setState(() {
|
||||||
_priceFilterActive = false;
|
_priceFilterActive = false;
|
||||||
_minPriceCents = 0;
|
_minPriceCents = 0;
|
||||||
_maxPriceCents = 50000;
|
_maxPriceCents = 50000;
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -426,28 +447,38 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
|
||||||
child: searchAsync.when(
|
child: searchAsync.when(
|
||||||
loading: () => Row(children: [
|
loading: () => Row(
|
||||||
SizedBox(
|
children: [
|
||||||
width: 12,
|
SizedBox(
|
||||||
height: 12,
|
width: 12,
|
||||||
child: CircularProgressIndicator(
|
height: 12,
|
||||||
strokeWidth: 1.5,
|
child: CircularProgressIndicator(
|
||||||
color: cs.onSurfaceVariant,
|
strokeWidth: 1.5,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
Text(
|
||||||
Text(l10n.searching,
|
l10n.searching,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurfaceVariant, fontSize: 13)),
|
color: cs.onSurfaceVariant,
|
||||||
]),
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
error: (_, _) => const SizedBox.shrink(),
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
data: (r) {
|
data: (r) {
|
||||||
final count =
|
final count = _applyClientFilters(
|
||||||
_applyClientFilters(r.data, params).length;
|
r.data,
|
||||||
|
params,
|
||||||
|
).length;
|
||||||
return Text(
|
return Text(
|
||||||
l10n.vehiclesFound(count),
|
l10n.vehiclesFound(count),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurfaceVariant, fontSize: 13),
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -458,12 +489,60 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── Active offers ────────────────────────────────────────────────
|
||||||
|
ref
|
||||||
|
.watch(offersProvider)
|
||||||
|
.when(
|
||||||
|
loading: () =>
|
||||||
|
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||||
|
error: (_, _) =>
|
||||||
|
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||||
|
data: (offers) {
|
||||||
|
if (offers.isEmpty) {
|
||||||
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
|
}
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
|
||||||
|
child: Text(
|
||||||
|
'Special Offers',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 110,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: offers.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
itemBuilder: (_, i) =>
|
||||||
|
OfferBanner(offer: offers[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// ── Results ──────────────────────────────────────────────────────
|
// ── Results ──────────────────────────────────────────────────────
|
||||||
searchAsync.when(
|
searchAsync.when(
|
||||||
loading: () => const SliverFillRemaining(
|
loading: () => const SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: _orange, strokeWidth: 2.5),
|
color: _orange,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (_, _) => SliverFillRemaining(
|
error: (_, _) => SliverFillRemaining(
|
||||||
@@ -477,7 +556,8 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
|||||||
final vehicles = _applyClientFilters(result.data, params);
|
final vehicles = _applyClientFilters(result.data, params);
|
||||||
if (vehicles.isEmpty) {
|
if (vehicles.isEmpty) {
|
||||||
return SliverFillRemaining(
|
return SliverFillRemaining(
|
||||||
child: _EmptyBody(l10n: l10n, cs: cs));
|
child: _EmptyBody(l10n: l10n, cs: cs),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||||
@@ -511,8 +591,11 @@ class _DropoffTab extends StatelessWidget {
|
|||||||
final bool active;
|
final bool active;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _DropoffTab(
|
const _DropoffTab({
|
||||||
{required this.label, required this.active, required this.onTap});
|
required this.label,
|
||||||
|
required this.active,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -579,26 +662,25 @@ class _LocationRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Row(
|
Widget build(BuildContext context) => Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 18, color: iconColor),
|
Icon(icon, size: 18, color: iconColor),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: placeholder ? cs.onSurfaceVariant : cs.onSurface,
|
color: placeholder ? cs.onSurfaceVariant : cs.onSurface,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (onClear != null)
|
),
|
||||||
GestureDetector(
|
),
|
||||||
onTap: onClear,
|
if (onClear != null)
|
||||||
child:
|
GestureDetector(
|
||||||
Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
|
onTap: onClear,
|
||||||
),
|
child: Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Date range display ────────────────────────────────────────────────────────
|
// ── Date range display ────────────────────────────────────────────────────────
|
||||||
@@ -629,8 +711,11 @@ class _DateRangeRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
child: Icon(Icons.arrow_forward,
|
child: Icon(
|
||||||
size: 14, color: cs.onSurfaceVariant),
|
Icons.arrow_forward,
|
||||||
|
size: 14,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_DateChunk(
|
_DateChunk(
|
||||||
date: dayFmt.format(end),
|
date: dayFmt.format(end),
|
||||||
@@ -647,23 +732,23 @@ class _DateChunk extends StatelessWidget {
|
|||||||
final String sub;
|
final String sub;
|
||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
|
|
||||||
const _DateChunk(
|
const _DateChunk({required this.date, required this.sub, required this.cs});
|
||||||
{required this.date, required this.sub, required this.cs});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Row(
|
Widget build(BuildContext context) => Row(
|
||||||
children: [
|
children: [
|
||||||
Text(date,
|
Text(
|
||||||
style: TextStyle(
|
date,
|
||||||
color: cs.onSurface,
|
style: TextStyle(
|
||||||
fontSize: 16,
|
color: cs.onSurface,
|
||||||
fontWeight: FontWeight.w600)),
|
fontSize: 16,
|
||||||
const SizedBox(width: 6),
|
fontWeight: FontWeight.w600,
|
||||||
Text(sub,
|
),
|
||||||
style:
|
),
|
||||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
const SizedBox(width: 6),
|
||||||
],
|
Text(sub, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||||
);
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Option tile (car type / price) ────────────────────────────────────────────
|
// ── Option tile (car type / price) ────────────────────────────────────────────
|
||||||
@@ -696,15 +781,11 @@ class _OptionTile extends StatelessWidget {
|
|||||||
? _blue.withValues(alpha: 0.12)
|
? _blue.withValues(alpha: 0.12)
|
||||||
: cs.surfaceContainerHigh,
|
: cs.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(color: active ? _blue : cs.outlineVariant),
|
||||||
color: active ? _blue : cs.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon,
|
Icon(icon, size: 16, color: active ? _blue : cs.onSurfaceVariant),
|
||||||
size: 16,
|
|
||||||
color: active ? _blue : cs.onSurfaceVariant),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -712,8 +793,7 @@ class _OptionTile extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight:
|
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
|
||||||
active ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
color: cs.onSurface,
|
color: cs.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -722,9 +802,11 @@ class _OptionTile extends StatelessWidget {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onClear,
|
onTap: onClear,
|
||||||
child: Icon(Icons.close,
|
child: Icon(
|
||||||
size: 14,
|
Icons.close,
|
||||||
color: active ? _blue : cs.onSurfaceVariant),
|
size: 14,
|
||||||
|
color: active ? _blue : cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -843,7 +925,11 @@ class _CarTypeSheetState extends State<_CarTypeSheet> {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
16, 10, 16, 16 + MediaQuery.of(context).padding.bottom),
|
16,
|
||||||
|
10,
|
||||||
|
16,
|
||||||
|
16 + MediaQuery.of(context).padding.bottom,
|
||||||
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -853,12 +939,17 @@ class _CarTypeSheetState extends State<_CarTypeSheet> {
|
|||||||
backgroundColor: _orange,
|
backgroundColor: _orange,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(14)),
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: Text(l10n.apply,
|
child: Text(
|
||||||
style: const TextStyle(
|
l10n.apply,
|
||||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -903,8 +994,9 @@ class _PriceSheetState extends State<_PriceSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fmt(double v) =>
|
String _fmt(double v) => v >= _PriceSheet._cap
|
||||||
v >= _PriceSheet._cap ? '\$${v.toStringAsFixed(0)}+' : '\$${v.toStringAsFixed(0)}';
|
? '\$${v.toStringAsFixed(0)}+'
|
||||||
|
: '\$${v.toStringAsFixed(0)}';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -912,8 +1004,9 @@ class _PriceSheetState extends State<_PriceSheet> {
|
|||||||
final l10n = widget.l10n;
|
final l10n = widget.l10n;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding:
|
padding: EdgeInsets.only(
|
||||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -941,8 +1034,10 @@ class _PriceSheetState extends State<_PriceSheet> {
|
|||||||
minimumSize: Size.zero,
|
minimumSize: Size.zero,
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
child: Text(l10n.clearAll,
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 14)),
|
l10n.clearAll,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -955,8 +1050,7 @@ class _PriceSheetState extends State<_PriceSheet> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_PriceTag(label: _fmt(_range.start), cs: cs),
|
_PriceTag(label: _fmt(_range.start), cs: cs),
|
||||||
Icon(Icons.arrow_forward,
|
Icon(Icons.arrow_forward, size: 14, color: cs.onSurfaceVariant),
|
||||||
size: 14, color: cs.onSurfaceVariant),
|
|
||||||
_PriceTag(label: '${_fmt(_range.end)}/day', cs: cs),
|
_PriceTag(label: '${_fmt(_range.end)}/day', cs: cs),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -987,7 +1081,11 @@ class _PriceSheetState extends State<_PriceSheet> {
|
|||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
16, 4, 16, 16 + MediaQuery.of(context).padding.bottom),
|
16,
|
||||||
|
4,
|
||||||
|
16,
|
||||||
|
16 + MediaQuery.of(context).padding.bottom,
|
||||||
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -1001,12 +1099,17 @@ class _PriceSheetState extends State<_PriceSheet> {
|
|||||||
backgroundColor: _orange,
|
backgroundColor: _orange,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(14)),
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: Text(l10n.apply,
|
child: Text(
|
||||||
style: const TextStyle(
|
l10n.apply,
|
||||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1024,21 +1127,21 @@ class _PriceTag extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => Container(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: cs.surfaceContainerHigh,
|
||||||
color: cs.surfaceContainerHigh,
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderRadius: BorderRadius.circular(8),
|
border: Border.all(color: cs.outlineVariant),
|
||||||
border: Border.all(color: cs.outlineVariant),
|
),
|
||||||
),
|
child: Text(
|
||||||
child: Text(
|
label,
|
||||||
label,
|
style: TextStyle(
|
||||||
style: TextStyle(
|
color: cs.onSurface,
|
||||||
color: cs.onSurface,
|
fontWeight: FontWeight.w600,
|
||||||
fontWeight: FontWeight.w600,
|
fontSize: 15,
|
||||||
fontSize: 15),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── City Sheet ────────────────────────────────────────────────────────────────
|
// ── City Sheet ────────────────────────────────────────────────────────────────
|
||||||
@@ -1089,9 +1192,10 @@ class _CitySheetState extends State<_CitySheet> {
|
|||||||
Text(
|
Text(
|
||||||
widget.titleOverride ?? widget.l10n.selectCity,
|
widget.titleOverride ?? widget.l10n.selectCity,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: cs.onSurface),
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -1099,10 +1203,12 @@ class _CitySheetState extends State<_CitySheet> {
|
|||||||
style: TextStyle(color: cs.onSurface),
|
style: TextStyle(color: cs.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: widget.l10n.searchCities,
|
hintText: widget.l10n.searchCities,
|
||||||
prefixIcon: Icon(Icons.search,
|
prefixIcon: Icon(
|
||||||
size: 20, color: cs.onSurfaceVariant),
|
Icons.search,
|
||||||
contentPadding:
|
size: 20,
|
||||||
const EdgeInsets.symmetric(vertical: 8),
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
),
|
),
|
||||||
onChanged: (v) => setState(() => _query = v),
|
onChanged: (v) => setState(() => _query = v),
|
||||||
),
|
),
|
||||||
@@ -1114,10 +1220,14 @@ class _CitySheetState extends State<_CitySheet> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.public_outlined,
|
leading: Icon(
|
||||||
color: cs.onSurfaceVariant),
|
Icons.public_outlined,
|
||||||
title: Text(widget.l10n.allCities,
|
color: cs.onSurfaceVariant,
|
||||||
style: TextStyle(color: cs.onSurface)),
|
),
|
||||||
|
title: Text(
|
||||||
|
widget.l10n.allCities,
|
||||||
|
style: TextStyle(color: cs.onSurface),
|
||||||
|
),
|
||||||
selected: widget.selected == null,
|
selected: widget.selected == null,
|
||||||
selectedColor: _orange,
|
selectedColor: _orange,
|
||||||
trailing: widget.selected == null
|
trailing: widget.selected == null
|
||||||
@@ -1125,19 +1235,21 @@ class _CitySheetState extends State<_CitySheet> {
|
|||||||
: null,
|
: null,
|
||||||
onTap: () => widget.onSelect(null),
|
onTap: () => widget.onSelect(null),
|
||||||
),
|
),
|
||||||
...filtered.map((city) => ListTile(
|
...filtered.map(
|
||||||
leading: Icon(Icons.location_city_outlined,
|
(city) => ListTile(
|
||||||
color: cs.onSurfaceVariant),
|
leading: Icon(
|
||||||
title: Text(city,
|
Icons.location_city_outlined,
|
||||||
style: TextStyle(color: cs.onSurface)),
|
color: cs.onSurfaceVariant,
|
||||||
selected: widget.selected == city,
|
),
|
||||||
selectedColor: _orange,
|
title: Text(city, style: TextStyle(color: cs.onSurface)),
|
||||||
trailing: widget.selected == city
|
selected: widget.selected == city,
|
||||||
? const Icon(Icons.check,
|
selectedColor: _orange,
|
||||||
color: _orange, size: 18)
|
trailing: widget.selected == city
|
||||||
: null,
|
? const Icon(Icons.check, color: _orange, size: 18)
|
||||||
onTap: () => widget.onSelect(city),
|
: null,
|
||||||
)),
|
onTap: () => widget.onSelect(city),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1155,13 +1267,13 @@ class _SheetHandle extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 4,
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.outlineVariant,
|
color: cs.outlineVariant,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Error / Empty states ─────────────────────────────────────────────────────
|
// ── Error / Empty states ─────────────────────────────────────────────────────
|
||||||
@@ -1171,46 +1283,48 @@ class _ErrorBody extends StatelessWidget {
|
|||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
|
|
||||||
const _ErrorBody(
|
const _ErrorBody({
|
||||||
{required this.onRetry, required this.l10n, required this.cs});
|
required this.onRetry,
|
||||||
|
required this.l10n,
|
||||||
|
required this.cs,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Center(
|
Widget build(BuildContext context) => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.wifi_off_outlined,
|
Icon(Icons.wifi_off_outlined, size: 48, color: cs.onSurfaceVariant),
|
||||||
size: 48, color: cs.onSurfaceVariant),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
Text(
|
||||||
Text(
|
l10n.couldNotLoadVehicles,
|
||||||
l10n.couldNotLoadVehicles,
|
style: TextStyle(
|
||||||
style: TextStyle(
|
color: cs.onSurface,
|
||||||
color: cs.onSurface,
|
fontSize: 16,
|
||||||
fontSize: 16,
|
fontWeight: FontWeight.w600,
|
||||||
fontWeight: FontWeight.w600),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.checkConnectionRetry,
|
|
||||||
style:
|
|
||||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: onRetry,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: _orange,
|
|
||||||
side: const BorderSide(color: _orange),
|
|
||||||
),
|
|
||||||
child: Text(l10n.retry),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
);
|
Text(
|
||||||
|
l10n.checkConnectionRetry,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: _orange,
|
||||||
|
side: const BorderSide(color: _orange),
|
||||||
|
),
|
||||||
|
child: Text(l10n.retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmptyBody extends StatelessWidget {
|
class _EmptyBody extends StatelessWidget {
|
||||||
@@ -1221,29 +1335,29 @@ class _EmptyBody extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Center(
|
Widget build(BuildContext context) => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.search_off, size: 56, color: cs.outlineVariant),
|
Icon(Icons.search_off, size: 56, color: cs.outlineVariant),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
l10n.noVehiclesFound,
|
l10n.noVehiclesFound,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurface,
|
color: cs.onSurface,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600),
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.tryDifferentSearch,
|
|
||||||
style:
|
|
||||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
);
|
Text(
|
||||||
|
l10n.tryDifferentSearch,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class ClientShell extends ConsumerWidget {
|
class ClientShell extends StatelessWidget {
|
||||||
final Widget child;
|
final PreferredSizeWidget? appBar;
|
||||||
|
final Widget body;
|
||||||
|
final Widget? floatingActionButton;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
|
||||||
const ClientShell({super.key, required this.child});
|
const ClientShell({
|
||||||
|
super.key,
|
||||||
|
this.appBar,
|
||||||
|
required this.body,
|
||||||
|
this.floatingActionButton,
|
||||||
|
this.backgroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final location = GoRouterState.of(context).matchedLocation;
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
@@ -17,7 +25,10 @@ class ClientShell extends ConsumerWidget {
|
|||||||
if (location == '/client/bookings') currentIndex = 1;
|
if (location == '/client/bookings') currentIndex = 1;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: child,
|
appBar: appBar,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
body: body,
|
||||||
|
floatingActionButton: floatingActionButton,
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: currentIndex,
|
selectedIndex: currentIndex,
|
||||||
onDestinationSelected: (i) {
|
onDestinationSelected: (i) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/providers/auth_provider.dart';
|
import '../../../core/providers/auth_provider.dart';
|
||||||
|
import 'client_shell.dart';
|
||||||
|
|
||||||
class MyBookingsScreen extends ConsumerWidget {
|
class MyBookingsScreen extends ConsumerWidget {
|
||||||
const MyBookingsScreen({super.key});
|
const MyBookingsScreen({super.key});
|
||||||
@@ -11,7 +12,7 @@ class MyBookingsScreen extends ConsumerWidget {
|
|||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
|
|
||||||
if (auth.status != AuthStatus.authenticated) {
|
if (auth.status != AuthStatus.authenticated) {
|
||||||
return Scaffold(
|
return ClientShell(
|
||||||
appBar: AppBar(title: const Text('My Bookings')),
|
appBar: AppBar(title: const Text('My Bookings')),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -19,8 +20,11 @@ class MyBookingsScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.bookmark_outline,
|
const Icon(
|
||||||
size: 64, color: Color(0xFFD1D5DB)),
|
Icons.bookmark_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Color(0xFFD1D5DB),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
'Sign in to view your bookings',
|
'Sign in to view your bookings',
|
||||||
@@ -48,7 +52,7 @@ class MyBookingsScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return ClientShell(
|
||||||
appBar: AppBar(title: const Text('My Bookings')),
|
appBar: AppBar(title: const Text('My Bookings')),
|
||||||
body: const Center(
|
body: const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -58,10 +62,7 @@ class MyBookingsScreen extends ConsumerWidget {
|
|||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No bookings yet',
|
'No bookings yet',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 16, color: Color(0xFF6B7280)),
|
||||||
fontSize: 16,
|
|
||||||
color: Color(0xFF6B7280),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,20 +3,48 @@ import '../../../core/models/analytics.dart';
|
|||||||
import '../../../core/models/vehicle.dart';
|
import '../../../core/models/vehicle.dart';
|
||||||
import '../../../core/models/reservation.dart';
|
import '../../../core/models/reservation.dart';
|
||||||
import '../../../core/models/customer.dart';
|
import '../../../core/models/customer.dart';
|
||||||
|
import '../../../core/models/notification.dart';
|
||||||
|
import '../../../core/models/offer.dart';
|
||||||
|
import '../../../core/models/settings.dart';
|
||||||
|
import '../../../core/models/report.dart';
|
||||||
import '../../../core/services/analytics_service.dart';
|
import '../../../core/services/analytics_service.dart';
|
||||||
import '../../../core/services/vehicle_service.dart';
|
import '../../../core/services/vehicle_service.dart';
|
||||||
import '../../../core/services/reservation_service.dart';
|
import '../../../core/services/reservation_service.dart';
|
||||||
import '../../../core/services/customer_service.dart';
|
import '../../../core/services/customer_service.dart';
|
||||||
|
import '../../../core/services/notification_service.dart';
|
||||||
|
import '../../../core/services/offer_service.dart';
|
||||||
|
import '../../../core/services/settings_service.dart';
|
||||||
|
import '../../../core/services/payment_service.dart';
|
||||||
|
import '../../../core/services/team_service.dart';
|
||||||
|
import '../../../core/services/contract_service.dart';
|
||||||
|
|
||||||
|
// ─── Service providers ────────────────────────────────────────
|
||||||
|
|
||||||
final analyticsServiceProvider = Provider((_) => AnalyticsService());
|
final analyticsServiceProvider = Provider((_) => AnalyticsService());
|
||||||
final vehicleServiceProvider = Provider((_) => VehicleService());
|
final vehicleServiceProvider = Provider((_) => VehicleService());
|
||||||
final reservationServiceProvider = Provider((_) => ReservationService());
|
final reservationServiceProvider = Provider((_) => ReservationService());
|
||||||
final customerServiceProvider = Provider((_) => CustomerService());
|
final customerServiceProvider = Provider((_) => CustomerService());
|
||||||
|
final notificationServiceProvider = Provider((_) => NotificationService());
|
||||||
|
final offerServiceProvider = Provider((_) => OfferService());
|
||||||
|
final settingsServiceProvider = Provider((_) => SettingsService());
|
||||||
|
|
||||||
|
// ─── Analytics ────────────────────────────────────────────────
|
||||||
|
|
||||||
final dashboardMetricsProvider = FutureProvider<DashboardMetrics>((ref) async {
|
final dashboardMetricsProvider = FutureProvider<DashboardMetrics>((ref) async {
|
||||||
return ref.read(analyticsServiceProvider).getDashboard();
|
return ref.read(analyticsServiceProvider).getDashboard();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final reportProvider =
|
||||||
|
FutureProvider.family<ReportSummary, Map<String, String>>((ref, params) async {
|
||||||
|
return ref.read(analyticsServiceProvider).getReport(
|
||||||
|
startDate: params['startDate']!,
|
||||||
|
endDate: params['endDate']!,
|
||||||
|
status: params['status'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Vehicles ─────────────────────────────────────────────────
|
||||||
|
|
||||||
final vehiclesProvider = FutureProvider.family<VehicleListResponse, Map<String, dynamic>>(
|
final vehiclesProvider = FutureProvider.family<VehicleListResponse, Map<String, dynamic>>(
|
||||||
(ref, params) async {
|
(ref, params) async {
|
||||||
return ref.read(vehicleServiceProvider).getVehicles(
|
return ref.read(vehicleServiceProvider).getVehicles(
|
||||||
@@ -32,6 +60,21 @@ final vehicleDetailProvider = FutureProvider.family<Vehicle, String>((ref, id) {
|
|||||||
return ref.read(vehicleServiceProvider).getVehicle(id);
|
return ref.read(vehicleServiceProvider).getVehicle(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final maintenanceProvider =
|
||||||
|
FutureProvider.family<List<MaintenanceLog>, String>((ref, vehicleId) {
|
||||||
|
return ref.read(vehicleServiceProvider).getMaintenance(vehicleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
typedef CalendarKey = ({String vehicleId, int year, int month});
|
||||||
|
|
||||||
|
final calendarProvider =
|
||||||
|
FutureProvider.family<List<CalendarEvent>, CalendarKey>((ref, key) {
|
||||||
|
return ref.read(vehicleServiceProvider)
|
||||||
|
.getCalendar(key.vehicleId, key.year, key.month);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reservations ─────────────────────────────────────────────
|
||||||
|
|
||||||
final reservationsProvider =
|
final reservationsProvider =
|
||||||
FutureProvider.family<ReservationListResponse, Map<String, dynamic>>(
|
FutureProvider.family<ReservationListResponse, Map<String, dynamic>>(
|
||||||
(ref, params) async {
|
(ref, params) async {
|
||||||
@@ -39,7 +82,9 @@ final reservationsProvider =
|
|||||||
page: params['page'] as int? ?? 1,
|
page: params['page'] as int? ?? 1,
|
||||||
pageSize: params['pageSize'] as int? ?? 20,
|
pageSize: params['pageSize'] as int? ?? 20,
|
||||||
status: params['status'] as String?,
|
status: params['status'] as String?,
|
||||||
|
source: params['source'] as String?,
|
||||||
search: params['search'] as String?,
|
search: params['search'] as String?,
|
||||||
|
customerId: params['customerId'] as String?,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -49,6 +94,13 @@ final reservationDetailProvider =
|
|||||||
return ref.read(reservationServiceProvider).getReservation(id);
|
return ref.read(reservationServiceProvider).getReservation(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final inspectionsProvider =
|
||||||
|
FutureProvider.family<List<ReservationInspection>, String>((ref, id) {
|
||||||
|
return ref.read(reservationServiceProvider).getInspections(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Customers ────────────────────────────────────────────────
|
||||||
|
|
||||||
final customersProvider =
|
final customersProvider =
|
||||||
FutureProvider.family<CustomerListResponse, Map<String, dynamic>>(
|
FutureProvider.family<CustomerListResponse, Map<String, dynamic>>(
|
||||||
(ref, params) async {
|
(ref, params) async {
|
||||||
@@ -64,3 +116,57 @@ final customerDetailProvider =
|
|||||||
FutureProvider.family<Customer, String>((ref, id) {
|
FutureProvider.family<Customer, String>((ref, id) {
|
||||||
return ref.read(customerServiceProvider).getCustomer(id);
|
return ref.read(customerServiceProvider).getCustomer(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Notifications ────────────────────────────────────────────
|
||||||
|
|
||||||
|
final notificationsProvider =
|
||||||
|
FutureProvider<List<AppNotification>>((ref) async {
|
||||||
|
return ref.read(notificationServiceProvider).getNotifications();
|
||||||
|
});
|
||||||
|
|
||||||
|
final unreadCountProvider = FutureProvider<int>((ref) async {
|
||||||
|
return ref.read(notificationServiceProvider).getUnreadCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Offers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final offersProvider = FutureProvider<List<CompanyOffer>>((ref) async {
|
||||||
|
return ref.read(offerServiceProvider).getOffers();
|
||||||
|
});
|
||||||
|
|
||||||
|
final offerDetailProvider =
|
||||||
|
FutureProvider.family<CompanyOffer, String>((ref, id) {
|
||||||
|
return ref.read(offerServiceProvider).getOffer(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Settings ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final brandSettingsProvider = FutureProvider<BrandSettings>((ref) async {
|
||||||
|
return ref.read(settingsServiceProvider).getBrand();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Payments ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final paymentServiceProvider = Provider((_) => PaymentService());
|
||||||
|
|
||||||
|
final reservationPaymentsProvider =
|
||||||
|
FutureProvider.family<List<Payment>, String>((ref, reservationId) {
|
||||||
|
return ref.read(paymentServiceProvider).getPayments(reservationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Team ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final teamServiceProvider = Provider((_) => TeamService());
|
||||||
|
|
||||||
|
final teamProvider = FutureProvider<List<TeamMember>>((ref) {
|
||||||
|
return ref.read(teamServiceProvider).getMembers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Contracts ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final contractServiceProvider = Provider((_) => ContractService());
|
||||||
|
|
||||||
|
final contractProvider =
|
||||||
|
FutureProvider.family<ContractPayload, String>((ref, reservationId) {
|
||||||
|
return ref.read(contractServiceProvider).getContract(reservationId);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../core/services/contract_service.dart';
|
||||||
|
import '../../../widgets/error_view.dart';
|
||||||
|
import '../../../widgets/status_badge.dart';
|
||||||
|
|
||||||
|
class ContractScreen extends ConsumerWidget {
|
||||||
|
final String reservationId;
|
||||||
|
|
||||||
|
const ContractScreen({super.key, required this.reservationId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final async = ref.watch(contractProvider(reservationId));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Rental Contract'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () => ref.invalidate(contractProvider(reservationId)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: 'Could not load contract',
|
||||||
|
onRetry: () => ref.invalidate(contractProvider(reservationId)),
|
||||||
|
),
|
||||||
|
data: (c) => _ContractView(contract: c),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContractView extends StatelessWidget {
|
||||||
|
final ContractPayload contract;
|
||||||
|
|
||||||
|
const _ContractView({required this.contract});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('MMM d, yyyy');
|
||||||
|
final fmtMoney =
|
||||||
|
NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// ── Header ──────────────────────────────────────────────
|
||||||
|
_Section(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (contract.contractNumber != null)
|
||||||
|
Text(
|
||||||
|
'Contract ${contract.contractNumber}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF111928),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (contract.invoiceNumber != null)
|
||||||
|
Text(
|
||||||
|
'Invoice ${contract.invoiceNumber}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StatusBadge(status: contract.status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Generated ${fmt.format(contract.generatedAt)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
if (contract.notes != null && contract.notes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF3CD),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.notes_outlined,
|
||||||
|
size: 14, color: Color(0xFF92400E)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
contract.notes!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF92400E)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Rental Period ────────────────────────────────────────
|
||||||
|
_Section(
|
||||||
|
title: 'Rental Period',
|
||||||
|
icon: Icons.calendar_today_outlined,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_Row2(
|
||||||
|
left: _LabelValue(
|
||||||
|
label: 'Check-out',
|
||||||
|
value: fmt.format(contract.startDate)),
|
||||||
|
right: _LabelValue(
|
||||||
|
label: 'Check-in',
|
||||||
|
value: fmt.format(contract.endDate)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_Row2(
|
||||||
|
left: _LabelValue(
|
||||||
|
label: 'Duration',
|
||||||
|
value:
|
||||||
|
'${contract.totalDays} day${contract.totalDays == 1 ? '' : 's'}'),
|
||||||
|
right: _LabelValue(
|
||||||
|
label: 'Payment',
|
||||||
|
value: _paymentLabel(contract.paymentStatus)),
|
||||||
|
),
|
||||||
|
if (contract.pickupLocation != null ||
|
||||||
|
contract.returnLocation != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (contract.pickupLocation != null)
|
||||||
|
_LabelValue(
|
||||||
|
label: 'Pick-up location',
|
||||||
|
value: contract.pickupLocation!),
|
||||||
|
if (contract.returnLocation != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_LabelValue(
|
||||||
|
label: 'Return location',
|
||||||
|
value: contract.returnLocation!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Vehicle ──────────────────────────────────────────────
|
||||||
|
_Section(
|
||||||
|
title: 'Vehicle',
|
||||||
|
icon: Icons.directions_car_outlined,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_Row2(
|
||||||
|
left: _LabelValue(
|
||||||
|
label: 'Vehicle',
|
||||||
|
value: contract.vehicle.displayName),
|
||||||
|
right: _LabelValue(
|
||||||
|
label: 'Plate',
|
||||||
|
value: contract.vehicle.licensePlate),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_Row2(
|
||||||
|
left: _LabelValue(
|
||||||
|
label: 'Category',
|
||||||
|
value: contract.vehicle.category),
|
||||||
|
right: _LabelValue(
|
||||||
|
label: 'Color',
|
||||||
|
value: contract.vehicle.color ?? '—'),
|
||||||
|
),
|
||||||
|
if (contract.vehicle.vin != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_LabelValue(
|
||||||
|
label: 'VIN', value: contract.vehicle.vin!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Customer ─────────────────────────────────────────────
|
||||||
|
_Section(
|
||||||
|
title: 'Customer',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_Row2(
|
||||||
|
left: _LabelValue(
|
||||||
|
label: 'Name',
|
||||||
|
value: contract.driver.fullName),
|
||||||
|
right: _LabelValue(
|
||||||
|
label: 'Email', value: contract.driver.email),
|
||||||
|
),
|
||||||
|
if (contract.driver.phone != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_LabelValue(
|
||||||
|
label: 'Phone', value: contract.driver.phone!),
|
||||||
|
],
|
||||||
|
if (contract.driver.driverLicense != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_Row2(
|
||||||
|
left: _LabelValue(
|
||||||
|
label: 'License #',
|
||||||
|
value: contract.driver.driverLicense!),
|
||||||
|
right: _LabelValue(
|
||||||
|
label: 'Country',
|
||||||
|
value:
|
||||||
|
contract.driver.licenseCountry ?? '—'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (contract.driver.licenseExpiry != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_LabelValue(
|
||||||
|
label: 'License expiry',
|
||||||
|
value: contract.driver.licenseExpiry!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Company ──────────────────────────────────────────────
|
||||||
|
_Section(
|
||||||
|
title: 'Rental Company',
|
||||||
|
icon: Icons.business_outlined,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
contract.company.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF111928)),
|
||||||
|
),
|
||||||
|
if (contract.company.address != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
contract.company.address!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (contract.company.email != null ||
|
||||||
|
contract.company.phone != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_Row2(
|
||||||
|
left: _LabelValue(
|
||||||
|
label: 'Email',
|
||||||
|
value: contract.company.email ?? '—'),
|
||||||
|
right: _LabelValue(
|
||||||
|
label: 'Phone',
|
||||||
|
value: contract.company.phone ?? '—'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ── Invoice ──────────────────────────────────────────────
|
||||||
|
_Section(
|
||||||
|
title: 'Invoice',
|
||||||
|
icon: Icons.receipt_outlined,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
...contract.lineItems.map((item) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(item.description,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFF374151))),
|
||||||
|
if (item.qty > 1)
|
||||||
|
Text(
|
||||||
|
'${item.qty} × ${fmtMoney.format(item.unitPrice / 100)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
fmtMoney.format(item.total / 100),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF111928)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
const Divider(height: 16),
|
||||||
|
if (contract.taxTotal > 0) ...[
|
||||||
|
_AmountRow(
|
||||||
|
label: 'Subtotal',
|
||||||
|
amount: contract.subtotal,
|
||||||
|
currency: contract.currency),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_AmountRow(
|
||||||
|
label: 'Taxes',
|
||||||
|
amount: contract.taxTotal,
|
||||||
|
currency: contract.currency),
|
||||||
|
const Divider(height: 12),
|
||||||
|
],
|
||||||
|
_AmountRow(
|
||||||
|
label: 'Total',
|
||||||
|
amount: contract.total,
|
||||||
|
currency: contract.currency,
|
||||||
|
bold: true,
|
||||||
|
color: const Color(0xFF1A56DB),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_AmountRow(
|
||||||
|
label: 'Amount paid',
|
||||||
|
amount: contract.amountPaid,
|
||||||
|
currency: contract.currency,
|
||||||
|
color: const Color(0xFF057A55),
|
||||||
|
),
|
||||||
|
if (contract.balanceDue > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_AmountRow(
|
||||||
|
label: 'Balance due',
|
||||||
|
amount: contract.balanceDue,
|
||||||
|
currency: contract.currency,
|
||||||
|
bold: true,
|
||||||
|
color: const Color(0xFFE02424),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Payments ─────────────────────────────────────────────
|
||||||
|
if (contract.payments.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_Section(
|
||||||
|
title: 'Payments',
|
||||||
|
icon: Icons.payments_outlined,
|
||||||
|
child: Column(
|
||||||
|
children: contract.payments
|
||||||
|
.map((p) => _PaymentRow(payment: p))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Inspections ──────────────────────────────────────────
|
||||||
|
if (contract.checkIn != null || contract.checkOut != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_Section(
|
||||||
|
title: 'Vehicle Inspections',
|
||||||
|
icon: Icons.fact_check_outlined,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (contract.checkIn != null) ...[
|
||||||
|
_InspectionRow(
|
||||||
|
label: 'Check-out',
|
||||||
|
inspection: contract.checkIn!),
|
||||||
|
if (contract.checkOut != null)
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
if (contract.checkOut != null)
|
||||||
|
_InspectionRow(
|
||||||
|
label: 'Check-in',
|
||||||
|
inspection: contract.checkOut!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Terms ────────────────────────────────────────────────
|
||||||
|
if (contract.terms != null && contract.terms!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_Section(
|
||||||
|
title: 'Terms & Conditions',
|
||||||
|
icon: Icons.gavel_outlined,
|
||||||
|
child: Text(
|
||||||
|
contract.terms!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
height: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (contract.fuelPolicy != null &&
|
||||||
|
contract.fuelPolicy!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_Section(
|
||||||
|
title: 'Fuel Policy',
|
||||||
|
icon: Icons.local_gas_station_outlined,
|
||||||
|
child: Text(
|
||||||
|
contract.fuelPolicy!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
height: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _Section extends StatelessWidget {
|
||||||
|
final String? title;
|
||||||
|
final IconData? icon;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _Section({this.title, this.icon, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (title != null) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: 16, color: const Color(0xFF6B7280)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
title!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFF374151),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LabelValue extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const _LabelValue({required this.label, required this.value});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10, color: Color(0xFF9CA3AF))),
|
||||||
|
Text(value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF111928))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Row2 extends StatelessWidget {
|
||||||
|
final Widget left;
|
||||||
|
final Widget right;
|
||||||
|
|
||||||
|
const _Row2({required this.left, required this.right});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: left),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: right),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmountRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final int amount;
|
||||||
|
final String currency;
|
||||||
|
final bool bold;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const _AmountRow({
|
||||||
|
required this.label,
|
||||||
|
required this.amount,
|
||||||
|
required this.currency,
|
||||||
|
this.bold = false,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||||
|
final style = TextStyle(
|
||||||
|
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: color ?? const Color(0xFF374151),
|
||||||
|
fontSize: bold ? 15 : 13,
|
||||||
|
);
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: style),
|
||||||
|
Text(fmt.format(amount / 100), style: style),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaymentRow extends StatelessWidget {
|
||||||
|
final ContractPayment payment;
|
||||||
|
|
||||||
|
const _PaymentRow({required this.payment});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||||
|
final fmtDate = DateFormat('MMM d, yyyy');
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
payment.paymentMethod ?? payment.type,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF374151)),
|
||||||
|
),
|
||||||
|
if (payment.paidAt != null)
|
||||||
|
Text(
|
||||||
|
fmtDate.format(payment.paidAt!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
fmt.format(payment.amount / 100),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: payment.status == 'PAID'
|
||||||
|
? const Color(0xFF057A55)
|
||||||
|
: const Color(0xFF374151),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InspectionRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final ContractInspection inspection;
|
||||||
|
|
||||||
|
const _InspectionRow(
|
||||||
|
{required this.label, required this.inspection});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF374151)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (inspection.mileage != null)
|
||||||
|
Expanded(
|
||||||
|
child: _LabelValue(
|
||||||
|
label: 'Mileage',
|
||||||
|
value: '${inspection.mileage} km'),
|
||||||
|
),
|
||||||
|
if (inspection.fuelLevel != null)
|
||||||
|
Expanded(
|
||||||
|
child: _LabelValue(
|
||||||
|
label: 'Fuel', value: inspection.fuelLevel!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (inspection.generalCondition != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_LabelValue(
|
||||||
|
label: 'Condition',
|
||||||
|
value: inspection.generalCondition!),
|
||||||
|
],
|
||||||
|
if (inspection.employeeNotes != null &&
|
||||||
|
inspection.employeeNotes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
inspection.employeeNotes!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _paymentLabel(String status) {
|
||||||
|
switch (status.toUpperCase()) {
|
||||||
|
case 'PAID':
|
||||||
|
return 'Paid';
|
||||||
|
case 'PARTIAL':
|
||||||
|
return 'Partial';
|
||||||
|
case 'PENDING':
|
||||||
|
return 'Pending';
|
||||||
|
default:
|
||||||
|
return status[0] + status.substring(1).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../core/models/reservation.dart';
|
||||||
|
|
||||||
|
class ContractsScreen extends ConsumerStatefulWidget {
|
||||||
|
const ContractsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ContractsScreen> createState() => _ContractsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContractsScreenState extends ConsumerState<ContractsScreen> {
|
||||||
|
final _searchCtrl = TextEditingController();
|
||||||
|
String _search = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final params = {
|
||||||
|
'page': 1,
|
||||||
|
'pageSize': 100,
|
||||||
|
'status': 'CONFIRMED,ACTIVE,COMPLETED,CLOSED',
|
||||||
|
if (_search.isNotEmpty) 'search': _search,
|
||||||
|
};
|
||||||
|
|
||||||
|
final asyncData = ref.watch(reservationsProvider(params));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Contracts'),
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(56),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search by customer, vehicle, contract…',
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
suffixIcon: _search.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
_searchCtrl.clear();
|
||||||
|
setState(() => _search = '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
onChanged: (v) => setState(() => _search = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: asyncData.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
|
data: (response) {
|
||||||
|
final contracts = response.data
|
||||||
|
.where((r) => r.contractNumber != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (contracts.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.description_outlined,
|
||||||
|
size: 64, color: Color(0xFF9CA3AF)),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'No contracts yet',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async =>
|
||||||
|
ref.invalidate(reservationsProvider(params)),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: contracts.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (_, i) => _ContractCard(reservation: contracts[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContractCard extends StatelessWidget {
|
||||||
|
final Reservation reservation;
|
||||||
|
|
||||||
|
const _ContractCard({required this.reservation});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final r = reservation;
|
||||||
|
final vehicle = r.vehicle;
|
||||||
|
final customer = r.customer;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: () =>
|
||||||
|
context.push('/dashboard/reservations/${r.id}/contract'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.description_outlined,
|
||||||
|
size: 18, color: Color(0xFF1A56DB)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
r.contractNumber!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFF1A56DB),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_StatusChip(status: r.status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (r.invoiceNumber != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Invoice: ${r.invoiceNumber}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_outline,
|
||||||
|
size: 16, color: Color(0xFF6B7280)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
customer?.fullName ?? '—',
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.directions_car_outlined,
|
||||||
|
size: 16, color: Color(0xFF6B7280)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
vehicle?.displayName ?? '—',
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today_outlined,
|
||||||
|
size: 14, color: Color(0xFF9CA3AF)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${_fmt(r.startDate)} → ${_fmt(r.endDate)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${(r.totalAmount / 100).toStringAsFixed(2)} DA',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmt(DateTime d) =>
|
||||||
|
'${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusChip extends StatelessWidget {
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
const _StatusChip({required this.status});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final (label, bg, fg) = switch (status) {
|
||||||
|
'CONFIRMED' => ('Confirmed', const Color(0xFFEBF5FB), const Color(0xFF1A56DB)),
|
||||||
|
'ACTIVE' => ('Active', const Color(0xFFECFDF5), const Color(0xFF059669)),
|
||||||
|
'COMPLETED' => ('Completed', const Color(0xFFF0FDF4), const Color(0xFF16A34A)),
|
||||||
|
'CLOSED' => ('Closed', const Color(0xFFF9FAFB), const Color(0xFF6B7280)),
|
||||||
|
_ => (status, const Color(0xFFF9FAFB), const Color(0xFF6B7280)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: fg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../providers/dashboard_providers.dart';
|
import '../providers/dashboard_providers.dart';
|
||||||
import '../../../widgets/status_badge.dart';
|
import '../../../widgets/status_badge.dart';
|
||||||
import '../../../widgets/error_view.dart';
|
import '../../../widgets/error_view.dart';
|
||||||
|
import '../../../widgets/reservation_card.dart';
|
||||||
|
import 'customer_form_screen.dart';
|
||||||
|
|
||||||
class CustomerDetailScreen extends ConsumerWidget {
|
class CustomerDetailScreen extends ConsumerWidget {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -13,6 +16,9 @@ class CustomerDetailScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final async = ref.watch(customerDetailProvider(id));
|
final async = ref.watch(customerDetailProvider(id));
|
||||||
|
final reservationsAsync = ref.watch(
|
||||||
|
reservationsProvider({'customerId': id, 'page': 1, 'pageSize': 10}),
|
||||||
|
);
|
||||||
|
|
||||||
return async.when(
|
return async.when(
|
||||||
loading: () =>
|
loading: () =>
|
||||||
@@ -27,7 +33,62 @@ class CustomerDetailScreen extends ConsumerWidget {
|
|||||||
data: (customer) {
|
data: (customer) {
|
||||||
final fmt = DateFormat('MMM d, yyyy');
|
final fmt = DateFormat('MMM d, yyyy');
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(customer.fullName)),
|
appBar: AppBar(
|
||||||
|
title: Text(customer.fullName),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
CustomerFormScreen(customer: customer)),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
ref.invalidate(customerDetailProvider(id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) async {
|
||||||
|
if (v == 'flag') {
|
||||||
|
await _showFlagDialog(context, ref, customer.isFlagged);
|
||||||
|
} else if (v == 'approve') {
|
||||||
|
await _showApproveLicenseDialog(context, ref);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'flag',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
customer.isFlagged ? Icons.flag_outlined : Icons.flag,
|
||||||
|
size: 18,
|
||||||
|
color: const Color(0xFFE02424),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
customer.isFlagged ? 'Remove Flag' : 'Flag Customer',
|
||||||
|
style: const TextStyle(color: Color(0xFFE02424)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'approve',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.badge_outlined, size: 18),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Review License'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
@@ -128,12 +189,213 @@ class CustomerDetailScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (customer.licenseStatus == 'PENDING')
|
||||||
|
_ActionButton(
|
||||||
|
label: 'Approve License',
|
||||||
|
color: const Color(0xFF0E9F6E),
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
onTap: () => _showApproveLicenseDialog(context, ref),
|
||||||
|
),
|
||||||
|
if (customer.licenseStatus == 'PENDING')
|
||||||
|
_ActionButton(
|
||||||
|
label: 'Reject License',
|
||||||
|
color: const Color(0xFFE02424),
|
||||||
|
icon: Icons.cancel_outlined,
|
||||||
|
onTap: () => _showRejectLicenseDialog(context, ref),
|
||||||
|
),
|
||||||
|
if (!customer.isFlagged)
|
||||||
|
_ActionButton(
|
||||||
|
label: 'Flag Customer',
|
||||||
|
color: const Color(0xFFE02424),
|
||||||
|
icon: Icons.flag_outlined,
|
||||||
|
onTap: () => _showFlagDialog(context, ref, false),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_ActionButton(
|
||||||
|
label: 'Remove Flag',
|
||||||
|
color: const Color(0xFF6B7280),
|
||||||
|
icon: Icons.flag_outlined,
|
||||||
|
onTap: () => _showFlagDialog(context, ref, true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Reservations',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF111928),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.push('/dashboard/reservations'),
|
||||||
|
child: const Text('View all'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
reservationsAsync.when(
|
||||||
|
loading: () =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
|
data: (res) => res.data.isEmpty
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'No reservations yet',
|
||||||
|
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: res.data
|
||||||
|
.map(
|
||||||
|
(r) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: ReservationCard(
|
||||||
|
reservation: r,
|
||||||
|
onTap: () => context.push(
|
||||||
|
'/dashboard/reservations/${r.id}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showFlagDialog(
|
||||||
|
BuildContext context, WidgetRef ref, bool isFlagged) async {
|
||||||
|
if (isFlagged) {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Remove Flag'),
|
||||||
|
content: const Text('Remove the flag from this customer?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Remove Flag')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true && context.mounted) {
|
||||||
|
await ref.read(customerServiceProvider).unflagCustomer(id);
|
||||||
|
ref.invalidate(customerDetailProvider(id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Flag Customer'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Reason'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFE02424)),
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Flag'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true && ctrl.text.isNotEmpty && context.mounted) {
|
||||||
|
await ref
|
||||||
|
.read(customerServiceProvider)
|
||||||
|
.flagCustomer(id, ctrl.text.trim());
|
||||||
|
ref.invalidate(customerDetailProvider(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showApproveLicenseDialog(
|
||||||
|
BuildContext context, WidgetRef ref) async {
|
||||||
|
final noteCtrl = TextEditingController();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Approve License'),
|
||||||
|
content: TextField(
|
||||||
|
controller: noteCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Note (optional)'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0E9F6E)),
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Approve'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true && context.mounted) {
|
||||||
|
await ref.read(customerServiceProvider).approveLicense(
|
||||||
|
id,
|
||||||
|
'APPROVED',
|
||||||
|
note: noteCtrl.text.isNotEmpty ? noteCtrl.text : null,
|
||||||
|
);
|
||||||
|
ref.invalidate(customerDetailProvider(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showRejectLicenseDialog(
|
||||||
|
BuildContext context, WidgetRef ref) async {
|
||||||
|
final noteCtrl = TextEditingController();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Reject License'),
|
||||||
|
content: TextField(
|
||||||
|
controller: noteCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Reason'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFE02424)),
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Reject'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true && context.mounted) {
|
||||||
|
await ref.read(customerServiceProvider).approveLicense(
|
||||||
|
id,
|
||||||
|
'REJECTED',
|
||||||
|
note: noteCtrl.text.isNotEmpty ? noteCtrl.text : null,
|
||||||
|
);
|
||||||
|
ref.invalidate(customerDetailProvider(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InfoRow extends StatelessWidget {
|
class _InfoRow extends StatelessWidget {
|
||||||
@@ -151,11 +413,40 @@ class _InfoRow extends StatelessWidget {
|
|||||||
Icon(icon, size: 18, color: const Color(0xFF6B7280)),
|
Icon(icon, size: 18, color: const Color(0xFF6B7280)),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(value,
|
child: Text(value, style: const TextStyle(fontSize: 14)),
|
||||||
style: const TextStyle(fontSize: 14)),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ActionButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ActionButton({
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: color,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
icon: Icon(icon),
|
||||||
|
label: Text(label),
|
||||||
|
onPressed: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../core/models/customer.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
|
||||||
|
class CustomerFormScreen extends ConsumerStatefulWidget {
|
||||||
|
final Customer? customer;
|
||||||
|
|
||||||
|
const CustomerFormScreen({super.key, this.customer});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CustomerFormScreen> createState() => _CustomerFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerFormScreenState extends ConsumerState<CustomerFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
final _firstName = TextEditingController();
|
||||||
|
final _lastName = TextEditingController();
|
||||||
|
final _email = TextEditingController();
|
||||||
|
final _phone = TextEditingController();
|
||||||
|
final _nationality = TextEditingController();
|
||||||
|
DateTime? _dob;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final c = widget.customer;
|
||||||
|
if (c != null) {
|
||||||
|
_firstName.text = c.firstName;
|
||||||
|
_lastName.text = c.lastName;
|
||||||
|
_email.text = c.email ?? '';
|
||||||
|
_phone.text = c.phone ?? '';
|
||||||
|
_nationality.text = c.nationality ?? '';
|
||||||
|
_dob = c.dateOfBirth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_firstName, _lastName, _email, _phone, _nationality]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDob() async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _dob ?? DateTime(1990),
|
||||||
|
firstDate: DateTime(1920),
|
||||||
|
lastDate: DateTime.now().subtract(const Duration(days: 365 * 16)),
|
||||||
|
);
|
||||||
|
if (picked != null) setState(() => _dob = picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final data = {
|
||||||
|
'firstName': _firstName.text.trim(),
|
||||||
|
'lastName': _lastName.text.trim(),
|
||||||
|
if (_email.text.isNotEmpty) 'email': _email.text.trim(),
|
||||||
|
if (_phone.text.isNotEmpty) 'phone': _phone.text.trim(),
|
||||||
|
if (_nationality.text.isNotEmpty)
|
||||||
|
'nationality': _nationality.text.trim(),
|
||||||
|
if (_dob != null)
|
||||||
|
'dateOfBirth':
|
||||||
|
'${_dob!.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
final svc = ref.read(customerServiceProvider);
|
||||||
|
if (widget.customer == null) {
|
||||||
|
await svc.createCustomer(data);
|
||||||
|
} else {
|
||||||
|
await svc.updateCustomer(widget.customer!.id, data);
|
||||||
|
}
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isEdit = widget.customer != null;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(isEdit ? 'Edit Customer' : 'New Customer'),
|
||||||
|
actions: [
|
||||||
|
if (_loading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextButton(
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_field('First Name', _firstName, required: true),
|
||||||
|
_field('Last Name', _lastName, required: true),
|
||||||
|
_field('Email', _email,
|
||||||
|
inputType: TextInputType.emailAddress),
|
||||||
|
_field('Phone', _phone, inputType: TextInputType.phone),
|
||||||
|
_field('Nationality', _nationality),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _pickDob,
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(labelText: 'Date of Birth'),
|
||||||
|
child: Text(
|
||||||
|
_dob != null
|
||||||
|
? DateFormat('MMM d, yyyy').format(_dob!)
|
||||||
|
: 'Tap to select',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _dob != null
|
||||||
|
? null
|
||||||
|
: const Color(0xFF9CA3AF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: Text(isEdit ? 'Save Changes' : 'Create Customer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field(String label, TextEditingController ctrl,
|
||||||
|
{bool required = false, TextInputType? inputType}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: inputType,
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
validator: required
|
||||||
|
? (v) => v == null || v.isEmpty ? 'Required' : null
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart';
|
|||||||
import '../../../widgets/loading_list.dart';
|
import '../../../widgets/loading_list.dart';
|
||||||
import '../../../widgets/error_view.dart';
|
import '../../../widgets/error_view.dart';
|
||||||
import '../../../widgets/status_badge.dart';
|
import '../../../widgets/status_badge.dart';
|
||||||
|
import 'dashboard_shell.dart';
|
||||||
|
import 'customer_form_screen.dart';
|
||||||
|
|
||||||
class CustomersScreen extends ConsumerStatefulWidget {
|
class CustomersScreen extends ConsumerStatefulWidget {
|
||||||
const CustomersScreen({super.key});
|
const CustomersScreen({super.key});
|
||||||
@@ -24,16 +26,25 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> get _params => {
|
Map<String, dynamic> get _params => {
|
||||||
'page': 1,
|
'page': 1,
|
||||||
'pageSize': 50,
|
'pageSize': 50,
|
||||||
if (_search.isNotEmpty) 'search': _search,
|
if (_search.isNotEmpty) 'search': _search,
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final customersAsync = ref.watch(customersProvider(_params));
|
final customersAsync = ref.watch(customersProvider(_params));
|
||||||
|
|
||||||
return Scaffold(
|
return DashboardShell(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(builder: (_) => const CustomerFormScreen()),
|
||||||
|
);
|
||||||
|
if (result == true) ref.invalidate(customersProvider(_params));
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.person_add_outlined),
|
||||||
|
),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Customers'),
|
title: const Text('Customers'),
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
@@ -60,8 +71,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
|||||||
),
|
),
|
||||||
data: (res) => res.data.isEmpty
|
data: (res) => res.data.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text('No customers found',
|
child: Text(
|
||||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
'No customers found',
|
||||||
|
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async =>
|
onRefresh: () async =>
|
||||||
ref.invalidate(customersProvider(_params)),
|
ref.invalidate(customersProvider(_params)),
|
||||||
@@ -98,8 +112,7 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment:
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -111,9 +124,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
|||||||
),
|
),
|
||||||
if (c.isFlagged) ...[
|
if (c.isFlagged) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
const Icon(Icons.flag,
|
const Icon(
|
||||||
size: 14,
|
Icons.flag,
|
||||||
color: Color(0xFFE02424)),
|
size: 14,
|
||||||
|
color: Color(0xFFE02424),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,11 +2,21 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/providers/auth_provider.dart';
|
import '../../../core/providers/auth_provider.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
|
||||||
class DashboardShell extends ConsumerWidget {
|
class DashboardShell extends ConsumerWidget {
|
||||||
final Widget child;
|
final PreferredSizeWidget? appBar;
|
||||||
|
final Widget body;
|
||||||
|
final Widget? floatingActionButton;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
|
||||||
const DashboardShell({super.key, required this.child});
|
const DashboardShell({
|
||||||
|
super.key,
|
||||||
|
this.appBar,
|
||||||
|
required this.body,
|
||||||
|
this.floatingActionButton,
|
||||||
|
this.backgroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -19,8 +29,11 @@ class DashboardShell extends ConsumerWidget {
|
|||||||
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
|
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
drawer: _Drawer(employee: employee, ref: ref),
|
appBar: appBar,
|
||||||
body: child,
|
backgroundColor: backgroundColor,
|
||||||
|
drawer: _Drawer(employee: employee),
|
||||||
|
body: body,
|
||||||
|
floatingActionButton: floatingActionButton,
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: currentIndex,
|
selectedIndex: currentIndex,
|
||||||
onDestinationSelected: (i) {
|
onDestinationSelected: (i) {
|
||||||
@@ -62,14 +75,16 @@ class DashboardShell extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Drawer extends StatelessWidget {
|
class _Drawer extends ConsumerWidget {
|
||||||
final dynamic employee;
|
final dynamic employee;
|
||||||
final WidgetRef ref;
|
|
||||||
|
|
||||||
const _Drawer({required this.employee, required this.ref});
|
const _Drawer({required this.employee});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final unreadAsync = ref.watch(unreadCountProvider);
|
||||||
|
final unread = unreadAsync.valueOrNull ?? 0;
|
||||||
|
|
||||||
return Drawer(
|
return Drawer(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -84,7 +99,9 @@ class _Drawer extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
employee?.firstName.substring(0, 1).toUpperCase() ?? 'U',
|
employee?.firstName.substring(0, 1).toUpperCase() ?? 'U',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white, fontWeight: FontWeight.bold),
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -99,7 +116,9 @@ class _Drawer extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
employee?.role ?? '',
|
employee?.role ?? '',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12, color: Color(0xFF6B7280)),
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -108,14 +127,115 @@ class _Drawer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const Spacer(),
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.dashboard_outlined,
|
||||||
|
label: 'Dashboard',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.go('/dashboard');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.directions_car_outlined,
|
||||||
|
label: 'Vehicles',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.go('/dashboard/vehicles');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.event_note_outlined,
|
||||||
|
label: 'Reservations',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.go('/dashboard/reservations');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.people_outlined,
|
||||||
|
label: 'Customers',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.go('/dashboard/customers');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.public_outlined,
|
||||||
|
label: 'Online Requests',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/dashboard/online-reservations');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.description_outlined,
|
||||||
|
label: 'Contracts',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/dashboard/contracts');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.notifications_outlined,
|
||||||
|
label: 'Notifications',
|
||||||
|
badge: unread > 0 ? '$unread' : null,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/dashboard/notifications');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.local_offer_outlined,
|
||||||
|
label: 'Offers',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/dashboard/offers');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.bar_chart_outlined,
|
||||||
|
label: 'Reports',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/dashboard/reports');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
label: 'Team',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/dashboard/team');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.settings_outlined,
|
||||||
|
label: 'Settings',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/dashboard/settings');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.logout, color: Color(0xFFE02424)),
|
leading: const Icon(Icons.logout, color: Color(0xFFE02424)),
|
||||||
title: const Text('Sign Out',
|
title: const Text(
|
||||||
style: TextStyle(color: Color(0xFFE02424))),
|
'Sign Out',
|
||||||
|
style: TextStyle(color: Color(0xFFE02424)),
|
||||||
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
final router = GoRouter.of(context);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
await ref.read(authProvider.notifier).logout();
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
router.go('/landing');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -125,3 +245,28 @@ class _Drawer extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _NavItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String? badge;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _NavItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
this.badge,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: badge != null
|
||||||
|
? Badge(label: Text(badge!), child: Icon(icon))
|
||||||
|
: Icon(icon),
|
||||||
|
title: Text(label),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../core/providers/auth_provider.dart';
|
import '../../../core/providers/auth_provider.dart';
|
||||||
import '../providers/dashboard_providers.dart';
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import 'dashboard_shell.dart';
|
||||||
import '../../../widgets/stat_card.dart';
|
import '../../../widgets/stat_card.dart';
|
||||||
import '../../../widgets/error_view.dart';
|
import '../../../widgets/error_view.dart';
|
||||||
import '../../../widgets/reservation_card.dart';
|
import '../../../widgets/reservation_card.dart';
|
||||||
@@ -18,8 +19,18 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
final recentAsync = ref.watch(
|
final recentAsync = ref.watch(
|
||||||
reservationsProvider({'page': 1, 'pageSize': 5}),
|
reservationsProvider({'page': 1, 'pageSize': 5}),
|
||||||
);
|
);
|
||||||
|
final onlineAsync = ref.watch(
|
||||||
|
reservationsProvider({
|
||||||
|
'status': 'DRAFT',
|
||||||
|
'source': 'MARKETPLACE',
|
||||||
|
'page': 1,
|
||||||
|
'pageSize': 100,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final pendingOnline = onlineAsync.valueOrNull?.data.length ?? 0;
|
||||||
|
final unreadCount = ref.watch(unreadCountProvider).valueOrNull ?? 0;
|
||||||
|
|
||||||
return Scaffold(
|
return DashboardShell(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Column(
|
title: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -30,18 +41,21 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
DateFormat('EEEE, MMMM d').format(DateTime.now()),
|
DateFormat('EEEE, MMMM d').format(DateTime.now()),
|
||||||
style:
|
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
|
||||||
const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
Builder(
|
IconButton(
|
||||||
builder: (ctx) => IconButton(
|
icon: unreadCount > 0
|
||||||
icon: const Icon(Icons.menu),
|
? Badge(
|
||||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
label: Text('$unreadCount'),
|
||||||
),
|
child: const Icon(Icons.notifications_outlined),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.notifications_outlined),
|
||||||
|
onPressed: () => context.push('/dashboard/notifications'),
|
||||||
),
|
),
|
||||||
|
_AvatarMenu(employee: employee),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
@@ -92,7 +106,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
child: StatCard(
|
child: StatCard(
|
||||||
title: 'Revenue',
|
title: 'Revenue',
|
||||||
value:
|
value:
|
||||||
'\$${NumberFormat.compact().format(metrics.totalRevenue)}',
|
'\$${NumberFormat.compact().format(metrics.totalRevenue / 100)}',
|
||||||
icon: Icons.attach_money,
|
icon: Icons.attach_money,
|
||||||
color: const Color(0xFF5521B5),
|
color: const Color(0xFF5521B5),
|
||||||
),
|
),
|
||||||
@@ -127,7 +141,8 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
value: metrics.occupancyRate / 100,
|
value: metrics.occupancyRate / 100,
|
||||||
backgroundColor: const Color(0xFFE5E7EB),
|
backgroundColor: const Color(0xFFE5E7EB),
|
||||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
Color(0xFF1A56DB)),
|
Color(0xFF1A56DB),
|
||||||
|
),
|
||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
@@ -146,6 +161,44 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (pendingOnline > 0) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => context.push('/dashboard/online-reservations'),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF3CD),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFF6B00)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.public,
|
||||||
|
color: Color(0xFFFF6B00),
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$pendingOnline online request${pendingOnline == 1 ? '' : 's'} pending approval',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF92400E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -168,7 +221,8 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
recentAsync.when(
|
recentAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => const ErrorView(
|
error: (e, _) => const ErrorView(
|
||||||
message: 'Could not load recent reservations'),
|
message: 'Could not load recent reservations',
|
||||||
|
),
|
||||||
data: (res) => res.data.isEmpty
|
data: (res) => res.data.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -181,15 +235,17 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
children: res.data
|
children: res.data
|
||||||
.map((r) => Padding(
|
.map(
|
||||||
padding:
|
(r) => Padding(
|
||||||
const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
child: ReservationCard(
|
child: ReservationCard(
|
||||||
reservation: r,
|
reservation: r,
|
||||||
onTap: () => context.push(
|
onTap: () => context.push(
|
||||||
'/dashboard/reservations/${r.id}'),
|
'/dashboard/reservations/${r.id}',
|
||||||
),
|
),
|
||||||
))
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -199,3 +255,103 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AvatarMenu extends ConsumerWidget {
|
||||||
|
final dynamic employee;
|
||||||
|
|
||||||
|
const _AvatarMenu({required this.employee});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final initial = employee?.firstName.substring(0, 1).toUpperCase() ?? 'U';
|
||||||
|
final name = employee?.fullName ?? 'Employee';
|
||||||
|
final role = employee?.role ?? '';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: PopupMenuButton<String>(
|
||||||
|
onSelected: (value) async {
|
||||||
|
if (value == 'logout') {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Sign out'),
|
||||||
|
content: const Text('Are you sure you want to sign out?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFE02424),
|
||||||
|
),
|
||||||
|
child: const Text('Sign out'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
if (context.mounted) context.go('/landing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
offset: const Offset(0, 44),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
enabled: false,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF111928),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (role.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
role,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Divider(height: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'logout',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.logout, size: 18, color: Color(0xFFE02424)),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text('Sign out', style: TextStyle(color: Color(0xFFE02424))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: const Color(0xFF1A56DB),
|
||||||
|
radius: 18,
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
|
||||||
|
class NewReservationScreen extends ConsumerStatefulWidget {
|
||||||
|
const NewReservationScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<NewReservationScreen> createState() =>
|
||||||
|
_NewReservationScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewReservationScreenState extends ConsumerState<NewReservationScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
String? _vehicleId;
|
||||||
|
String? _vehicleName;
|
||||||
|
String? _customerId;
|
||||||
|
String? _customerName;
|
||||||
|
DateTime? _startDate;
|
||||||
|
DateTime? _endDate;
|
||||||
|
final _pickup = TextEditingController();
|
||||||
|
final _returnLoc = TextEditingController();
|
||||||
|
final _notes = TextEditingController();
|
||||||
|
final _deposit = TextEditingController(text: '0');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_pickup, _returnLoc, _notes, _deposit]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDateRange() async {
|
||||||
|
final range = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime.now(),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
initialDateRange: _startDate != null && _endDate != null
|
||||||
|
? DateTimeRange(start: _startDate!, end: _endDate!)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
if (range != null) {
|
||||||
|
setState(() {
|
||||||
|
_startDate = range.start;
|
||||||
|
_endDate = range.end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickVehicle() async {
|
||||||
|
final vehicles = await ref
|
||||||
|
.read(vehicleServiceProvider)
|
||||||
|
.getVehicles(status: 'AVAILABLE', pageSize: 100);
|
||||||
|
if (!mounted) return;
|
||||||
|
final result = await showModalBottomSheet<Map<String, String>>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
expand: false,
|
||||||
|
builder: (_, scroll) => ListView.builder(
|
||||||
|
controller: scroll,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: vehicles.data.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final v = vehicles.data[i];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(v.displayName),
|
||||||
|
subtitle: Text(v.licensePlate),
|
||||||
|
onTap: () => Navigator.pop(
|
||||||
|
context, {'id': v.id, 'name': v.displayName}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
setState(() {
|
||||||
|
_vehicleId = result['id'];
|
||||||
|
_vehicleName = result['name'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickCustomer() async {
|
||||||
|
final customers =
|
||||||
|
await ref.read(customerServiceProvider).getCustomers(pageSize: 100);
|
||||||
|
if (!mounted) return;
|
||||||
|
final result = await showModalBottomSheet<Map<String, String>>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
expand: false,
|
||||||
|
builder: (_, scroll) => ListView.builder(
|
||||||
|
controller: scroll,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: customers.data.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final c = customers.data[i];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(c.fullName),
|
||||||
|
subtitle: Text(c.email ?? c.phone ?? ''),
|
||||||
|
onTap: () => Navigator.pop(
|
||||||
|
context, {'id': c.id, 'name': c.fullName}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
setState(() {
|
||||||
|
_customerId = result['id'];
|
||||||
|
_customerName = result['name'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
if (_vehicleId == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please select a vehicle')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_customerId == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please select a customer')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_startDate == null || _endDate == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please select dates')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final data = {
|
||||||
|
'vehicleId': _vehicleId!,
|
||||||
|
'customerId': _customerId!,
|
||||||
|
'startDate':
|
||||||
|
'${_startDate!.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||||
|
'endDate':
|
||||||
|
'${_endDate!.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||||
|
if (_pickup.text.isNotEmpty) 'pickupLocation': _pickup.text.trim(),
|
||||||
|
if (_returnLoc.text.isNotEmpty)
|
||||||
|
'returnLocation': _returnLoc.text.trim(),
|
||||||
|
if (_notes.text.isNotEmpty) 'notes': _notes.text.trim(),
|
||||||
|
'depositAmount': ((double.tryParse(_deposit.text) ?? 0) * 100).round(),
|
||||||
|
};
|
||||||
|
await ref.read(reservationServiceProvider).createReservation(data);
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('MMM d, yyyy');
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('New Reservation'),
|
||||||
|
actions: [
|
||||||
|
if (_loading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextButton(
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Create'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_PickerTile(
|
||||||
|
label: 'Vehicle',
|
||||||
|
value: _vehicleName,
|
||||||
|
icon: Icons.directions_car_outlined,
|
||||||
|
onTap: _pickVehicle,
|
||||||
|
placeholder: 'Select available vehicle',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PickerTile(
|
||||||
|
label: 'Customer',
|
||||||
|
value: _customerName,
|
||||||
|
icon: Icons.person_outlined,
|
||||||
|
onTap: _pickCustomer,
|
||||||
|
placeholder: 'Select customer',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PickerTile(
|
||||||
|
label: 'Rental Period',
|
||||||
|
value: _startDate != null && _endDate != null
|
||||||
|
? '${fmt.format(_startDate!)} → ${fmt.format(_endDate!)}'
|
||||||
|
: null,
|
||||||
|
icon: Icons.date_range_outlined,
|
||||||
|
onTap: _pickDateRange,
|
||||||
|
placeholder: 'Select start & end dates',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_textField('Pickup Location', _pickup),
|
||||||
|
_textField('Return Location', _returnLoc),
|
||||||
|
_textField('Deposit (\$)', _deposit,
|
||||||
|
inputType: TextInputType.number),
|
||||||
|
_textField('Notes', _notes, maxLines: 3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: const Text('Create Reservation'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _textField(String label, TextEditingController ctrl,
|
||||||
|
{TextInputType? inputType, int maxLines = 1}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: inputType,
|
||||||
|
maxLines: maxLines,
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PickerTile extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String? value;
|
||||||
|
final String placeholder;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _PickerTile({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.placeholder,
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: const Color(0xFF6B7280)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11, color: Color(0xFF9CA3AF))),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
value ?? placeholder,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight:
|
||||||
|
value != null ? FontWeight.w500 : FontWeight.normal,
|
||||||
|
color: value != null
|
||||||
|
? const Color(0xFF111928)
|
||||||
|
: const Color(0xFF9CA3AF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right, color: Color(0xFF9CA3AF)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../widgets/error_view.dart';
|
||||||
|
|
||||||
|
class NotificationsScreen extends ConsumerWidget {
|
||||||
|
const NotificationsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final async = ref.watch(notificationsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Notifications'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.done_all),
|
||||||
|
tooltip: 'Mark all read',
|
||||||
|
onPressed: () async {
|
||||||
|
await ref.read(notificationServiceProvider).markAllRead();
|
||||||
|
ref.invalidate(notificationsProvider);
|
||||||
|
ref.invalidate(unreadCountProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: 'Failed to load notifications',
|
||||||
|
onRetry: () => ref.invalidate(notificationsProvider),
|
||||||
|
),
|
||||||
|
data: (notifications) {
|
||||||
|
if (notifications.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.notifications_none,
|
||||||
|
size: 64, color: Color(0xFFD1D5DB)),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('No notifications',
|
||||||
|
style: TextStyle(color: Color(0xFF9CA3AF))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(notificationsProvider),
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: notifications.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1, indent: 72),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final n = notifications[i];
|
||||||
|
return _NotificationTile(
|
||||||
|
notification: n,
|
||||||
|
onRead: () async {
|
||||||
|
if (!n.isRead) {
|
||||||
|
await ref
|
||||||
|
.read(notificationServiceProvider)
|
||||||
|
.markRead(n.id);
|
||||||
|
ref.invalidate(notificationsProvider);
|
||||||
|
ref.invalidate(unreadCountProvider);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationTile extends StatelessWidget {
|
||||||
|
final dynamic notification;
|
||||||
|
final VoidCallback onRead;
|
||||||
|
|
||||||
|
const _NotificationTile(
|
||||||
|
{required this.notification, required this.onRead});
|
||||||
|
|
||||||
|
IconData _icon(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'RESERVATION_CREATED':
|
||||||
|
case 'RESERVATION_CONFIRMED':
|
||||||
|
return Icons.event_available;
|
||||||
|
case 'RESERVATION_CANCELLED':
|
||||||
|
return Icons.event_busy;
|
||||||
|
case 'PAYMENT_RECEIVED':
|
||||||
|
return Icons.payments_outlined;
|
||||||
|
case 'LICENSE_UPLOADED':
|
||||||
|
return Icons.badge_outlined;
|
||||||
|
default:
|
||||||
|
return Icons.notifications_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('MMM d, h:mm a');
|
||||||
|
final isRead = notification.isRead as bool;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onRead,
|
||||||
|
child: Container(
|
||||||
|
color: isRead ? null : const Color(0xFFF0F7FF),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isRead
|
||||||
|
? const Color(0xFFF3F4F6)
|
||||||
|
: const Color(0xFFE1EFFE),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_icon(notification.type as String),
|
||||||
|
size: 20,
|
||||||
|
color: isRead
|
||||||
|
? const Color(0xFF6B7280)
|
||||||
|
: const Color(0xFF1A56DB),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
notification.title as String,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight:
|
||||||
|
isRead ? FontWeight.normal : FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
notification.body as String,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
fmt.format(notification.createdAt as DateTime),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isRead)
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(top: 6),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF1A56DB),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../core/models/offer.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../widgets/error_view.dart';
|
||||||
|
|
||||||
|
class OffersScreen extends ConsumerWidget {
|
||||||
|
const OffersScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final async = ref.watch(offersProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Offers & Promotions')),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () async {
|
||||||
|
final created = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (_) => const _OfferFormSheet(),
|
||||||
|
);
|
||||||
|
if (created == true) ref.invalidate(offersProvider);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('New Offer'),
|
||||||
|
),
|
||||||
|
body: async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: 'Failed to load offers',
|
||||||
|
onRetry: () => ref.invalidate(offersProvider),
|
||||||
|
),
|
||||||
|
data: (offers) {
|
||||||
|
if (offers.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.local_offer_outlined,
|
||||||
|
size: 64, color: Color(0xFFD1D5DB)),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('No offers yet',
|
||||||
|
style: TextStyle(color: Color(0xFF9CA3AF))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(offersProvider),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
itemCount: offers.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (_, i) => _OfferCard(
|
||||||
|
offer: offers[i],
|
||||||
|
onToggle: () async {
|
||||||
|
final svc = ref.read(offerServiceProvider);
|
||||||
|
if (offers[i].isActive) {
|
||||||
|
await svc.deactivateOffer(offers[i].id);
|
||||||
|
} else {
|
||||||
|
await svc.activateOffer(offers[i].id);
|
||||||
|
}
|
||||||
|
ref.invalidate(offersProvider);
|
||||||
|
},
|
||||||
|
onDelete: () async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Delete Offer'),
|
||||||
|
content: Text(
|
||||||
|
'Delete "${offers[i].title}"? This cannot be undone.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFE02424)),
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
await ref.read(offerServiceProvider).deleteOffer(offers[i].id);
|
||||||
|
ref.invalidate(offersProvider);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfferCard extends StatelessWidget {
|
||||||
|
final CompanyOffer offer;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const _OfferCard({
|
||||||
|
required this.offer,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('MMM d, yyyy');
|
||||||
|
final isExpired = offer.validUntil.isBefore(DateTime.now());
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
offer.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, fontSize: 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_StatusChip(
|
||||||
|
active: offer.isActive && !isExpired,
|
||||||
|
expired: isExpired),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) {
|
||||||
|
if (v == 'toggle') onToggle();
|
||||||
|
if (v == 'delete') onDelete();
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'toggle',
|
||||||
|
child: Text(offer.isActive ? 'Deactivate' : 'Activate'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Text('Delete',
|
||||||
|
style: TextStyle(color: Color(0xFFE02424))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0F7FF),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
offer.discountLabel,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF1A56DB),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (offer.promoCode != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF3F4F6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
offer.promoCode!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${fmt.format(offer.validFrom)} → ${fmt.format(offer.validUntil)}',
|
||||||
|
style:
|
||||||
|
const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
if (offer.maxRedemptions != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${offer.redemptionCount} / ${offer.maxRedemptions} uses',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusChip extends StatelessWidget {
|
||||||
|
final bool active;
|
||||||
|
final bool expired;
|
||||||
|
|
||||||
|
const _StatusChip({required this.active, required this.expired});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Color bg;
|
||||||
|
Color fg;
|
||||||
|
String label;
|
||||||
|
if (expired) {
|
||||||
|
bg = const Color(0xFFF3F4F6);
|
||||||
|
fg = const Color(0xFF6B7280);
|
||||||
|
label = 'Expired';
|
||||||
|
} else if (active) {
|
||||||
|
bg = const Color(0xFFDEF7EC);
|
||||||
|
fg = const Color(0xFF03543F);
|
||||||
|
label = 'Active';
|
||||||
|
} else {
|
||||||
|
bg = const Color(0xFFFDE8E8);
|
||||||
|
fg = const Color(0xFF9B1C1C);
|
||||||
|
label = 'Inactive';
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration:
|
||||||
|
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Text(label,
|
||||||
|
style: TextStyle(fontSize: 11, color: fg, fontWeight: FontWeight.w600)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfferFormSheet extends ConsumerStatefulWidget {
|
||||||
|
const _OfferFormSheet();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_OfferFormSheet> createState() => _OfferFormSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfferFormSheetState extends ConsumerState<_OfferFormSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
final _title = TextEditingController();
|
||||||
|
final _description = TextEditingController();
|
||||||
|
final _promoCode = TextEditingController();
|
||||||
|
final _discountValue = TextEditingController(text: '10');
|
||||||
|
final _maxRedemptions = TextEditingController();
|
||||||
|
|
||||||
|
String _type = 'PERCENTAGE';
|
||||||
|
bool _isPublic = false;
|
||||||
|
DateTime _validFrom = DateTime.now();
|
||||||
|
DateTime _validUntil = DateTime.now().add(const Duration(days: 30));
|
||||||
|
|
||||||
|
static const _types = ['PERCENTAGE', 'FIXED_AMOUNT', 'FREE_DAY'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [
|
||||||
|
_title, _description, _promoCode, _discountValue, _maxRedemptions
|
||||||
|
]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDateRange() async {
|
||||||
|
final range = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
|
||||||
|
initialDateRange: DateTimeRange(start: _validFrom, end: _validUntil),
|
||||||
|
);
|
||||||
|
if (range != null) {
|
||||||
|
setState(() {
|
||||||
|
_validFrom = range.start;
|
||||||
|
_validUntil = range.end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
int discVal = int.tryParse(_discountValue.text) ?? 0;
|
||||||
|
if (_type == 'FIXED_AMOUNT') discVal = discVal * 100;
|
||||||
|
|
||||||
|
await ref.read(offerServiceProvider).createOffer({
|
||||||
|
'title': _title.text.trim(),
|
||||||
|
if (_description.text.isNotEmpty)
|
||||||
|
'description': _description.text.trim(),
|
||||||
|
'type': _type,
|
||||||
|
'discountValue': discVal,
|
||||||
|
if (_promoCode.text.isNotEmpty) 'promoCode': _promoCode.text.trim(),
|
||||||
|
'isPublic': _isPublic,
|
||||||
|
'isActive': true,
|
||||||
|
'isFeatured': false,
|
||||||
|
'validFrom':
|
||||||
|
'${_validFrom.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||||
|
'validUntil':
|
||||||
|
'${_validUntil.toIso8601String().substring(0, 10)}T23:59:59.000Z',
|
||||||
|
if (_maxRedemptions.text.isNotEmpty)
|
||||||
|
'maxRedemptions': int.parse(_maxRedemptions.text),
|
||||||
|
'categories': <String>[],
|
||||||
|
});
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('MMM d, yyyy');
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text('New Offer',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const Spacer(),
|
||||||
|
if (_loading)
|
||||||
|
const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
else
|
||||||
|
TextButton(
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Create')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _title,
|
||||||
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _description,
|
||||||
|
decoration: const InputDecoration(labelText: 'Description'),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InputDecorator(
|
||||||
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _type,
|
||||||
|
isExpanded: true,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: _types
|
||||||
|
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => _type = v!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _discountValue,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _type == 'PERCENTAGE'
|
||||||
|
? 'Discount (%)'
|
||||||
|
: _type == 'FIXED_AMOUNT'
|
||||||
|
? 'Amount (\$)'
|
||||||
|
: 'Free Days',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _promoCode,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Promo Code (optional)'),
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _maxRedemptions,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Max Redemptions (optional)'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InkWell(
|
||||||
|
onTap: _pickDateRange,
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(labelText: 'Valid Period'),
|
||||||
|
child: Text(
|
||||||
|
'${fmt.format(_validFrom)} → ${fmt.format(_validUntil)}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Public offer'),
|
||||||
|
subtitle: const Text('Visible to customers on marketplace'),
|
||||||
|
value: _isPublic,
|
||||||
|
onChanged: (v) => setState(() => _isPublic = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: const Text('Create Offer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../core/models/reservation.dart';
|
||||||
|
import '../../../widgets/error_view.dart';
|
||||||
|
import '../../../widgets/status_badge.dart';
|
||||||
|
|
||||||
|
const _params = {
|
||||||
|
'status': 'DRAFT',
|
||||||
|
'source': 'MARKETPLACE',
|
||||||
|
'page': 1,
|
||||||
|
'pageSize': 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
class OnlineReservationsScreen extends ConsumerWidget {
|
||||||
|
const OnlineReservationsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final async = ref.watch(reservationsProvider(_params));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Online Reservations'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () => ref.invalidate(reservationsProvider(_params)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: 'Could not load online reservations',
|
||||||
|
onRetry: () => ref.invalidate(reservationsProvider(_params)),
|
||||||
|
),
|
||||||
|
data: (result) {
|
||||||
|
final items = result.data;
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle_outline,
|
||||||
|
size: 64, color: Color(0xFF6EE7B7)),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'All caught up!',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF111928)),
|
||||||
|
),
|
||||||
|
SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'No pending marketplace requests.',
|
||||||
|
style: TextStyle(color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async =>
|
||||||
|
ref.invalidate(reservationsProvider(_params)),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (_, i) => _ReservationCard(
|
||||||
|
reservation: items[i],
|
||||||
|
onAction: () => ref.invalidate(reservationsProvider(_params)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReservationCard extends ConsumerWidget {
|
||||||
|
final Reservation reservation;
|
||||||
|
final VoidCallback onAction;
|
||||||
|
|
||||||
|
const _ReservationCard({
|
||||||
|
required this.reservation,
|
||||||
|
required this.onAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final r = reservation;
|
||||||
|
final fmt = DateFormat('MMM d, yyyy');
|
||||||
|
final fmtMoney =
|
||||||
|
NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header: vehicle + status
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (r.vehicle != null)
|
||||||
|
Text(
|
||||||
|
r.vehicle!.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFF111928),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Text('Vehicle unavailable',
|
||||||
|
style:
|
||||||
|
TextStyle(color: Color(0xFF9CA3AF))),
|
||||||
|
if (r.vehicle != null)
|
||||||
|
Text(
|
||||||
|
r.vehicle!.licensePlate,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StatusBadge(status: r.status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Customer
|
||||||
|
_InfoRow(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
label: r.customer != null
|
||||||
|
? '${r.customer!.firstName} ${r.customer!.lastName}'
|
||||||
|
: 'Unknown customer',
|
||||||
|
sub: r.customer?.email,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Dates
|
||||||
|
_InfoRow(
|
||||||
|
icon: Icons.calendar_today_outlined,
|
||||||
|
label:
|
||||||
|
'${fmt.format(r.startDate)} → ${fmt.format(r.endDate)}',
|
||||||
|
sub: '${r.days} day${r.days == 1 ? '' : 's'}',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Amount
|
||||||
|
_InfoRow(
|
||||||
|
icon: Icons.attach_money,
|
||||||
|
label: fmtMoney.format(r.totalAmount / 100),
|
||||||
|
sub: 'Total',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
// Actions
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
label: const Text('Decline'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFE02424),
|
||||||
|
side: const BorderSide(color: Color(0xFFE02424)),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
_showDeclineDialog(context, ref, r.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.check, size: 16),
|
||||||
|
label: const Text('Confirm'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF057A55),
|
||||||
|
),
|
||||||
|
onPressed: () => _confirm(context, ref, r.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirm(
|
||||||
|
BuildContext context, WidgetRef ref, String id) async {
|
||||||
|
try {
|
||||||
|
await ref.read(reservationServiceProvider).confirmReservation(id);
|
||||||
|
onAction();
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Reservation confirmed'),
|
||||||
|
backgroundColor: Color(0xFF057A55),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to confirm: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDeclineDialog(
|
||||||
|
BuildContext context, WidgetRef ref, String id) {
|
||||||
|
final reasonCtrl = TextEditingController();
|
||||||
|
bool saving = false;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setState) => AlertDialog(
|
||||||
|
title: const Text('Decline Reservation'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Optionally provide a reason — it will be recorded on the reservation.',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 13, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: reasonCtrl,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Reason (optional)',
|
||||||
|
hintText:
|
||||||
|
'e.g. Vehicle not available for these dates',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: saving ? null : () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFE02424)),
|
||||||
|
onPressed: saving
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
setState(() => saving = true);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(reservationServiceProvider)
|
||||||
|
.cancelReservation(
|
||||||
|
id,
|
||||||
|
reasonCtrl.text.trim().isEmpty
|
||||||
|
? 'Declined by company'
|
||||||
|
: reasonCtrl.text.trim());
|
||||||
|
onAction();
|
||||||
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Reservation declined')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => saving = false);
|
||||||
|
if (ctx.mounted) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content:
|
||||||
|
Text('Failed to decline: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Colors.white)),
|
||||||
|
)
|
||||||
|
: const Text('Decline'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoRow extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String? sub;
|
||||||
|
|
||||||
|
const _InfoRow({required this.icon, required this.label, this.sub});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: const Color(0xFF9CA3AF)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF374151))),
|
||||||
|
if (sub != null)
|
||||||
|
Text(sub!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11, color: Color(0xFF9CA3AF))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../widgets/error_view.dart';
|
||||||
|
|
||||||
|
class ReportsScreen extends ConsumerStatefulWidget {
|
||||||
|
const ReportsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||||
|
DateTime _start = DateTime.now().subtract(const Duration(days: 30));
|
||||||
|
DateTime _end = DateTime.now();
|
||||||
|
String? _statusFilter;
|
||||||
|
|
||||||
|
Map<String, String> get _params => {
|
||||||
|
'startDate':
|
||||||
|
'${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||||
|
'endDate': '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
|
||||||
|
'status': ?_statusFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> _pickRange() async {
|
||||||
|
final range = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
initialDateRange: DateTimeRange(start: _start, end: _end),
|
||||||
|
);
|
||||||
|
if (range != null) {
|
||||||
|
setState(() {
|
||||||
|
_start = range.start;
|
||||||
|
_end = range.end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('MMM d, yyyy');
|
||||||
|
final fmtMoney = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||||
|
final reportAsync = ref.watch(reportProvider(_params));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Reports'),
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<String?>(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.filter_list,
|
||||||
|
color: _statusFilter != null ? const Color(0xFF1A56DB) : null,
|
||||||
|
),
|
||||||
|
onSelected: (v) => setState(() => _statusFilter = v),
|
||||||
|
itemBuilder: (_) => const [
|
||||||
|
PopupMenuItem(value: null, child: Text('All statuses')),
|
||||||
|
PopupMenuItem(value: 'CONFIRMED', child: Text('Confirmed')),
|
||||||
|
PopupMenuItem(value: 'ACTIVE', child: Text('Active')),
|
||||||
|
PopupMenuItem(value: 'CLOSED', child: Text('Closed')),
|
||||||
|
PopupMenuItem(value: 'CANCELLED', child: Text('Cancelled')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
_DateRangeBar(
|
||||||
|
start: _start,
|
||||||
|
end: _end,
|
||||||
|
fmt: fmt,
|
||||||
|
onTap: _pickRange,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: reportAsync.when(
|
||||||
|
loading: () =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: 'Failed to load report',
|
||||||
|
onRetry: () => ref.invalidate(reportProvider(_params)),
|
||||||
|
),
|
||||||
|
data: (report) => CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MetricCard(
|
||||||
|
label: 'Reservations',
|
||||||
|
value:
|
||||||
|
report.totalReservations.toString(),
|
||||||
|
icon: Icons.event_note,
|
||||||
|
color: const Color(0xFF1A56DB),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _MetricCard(
|
||||||
|
label: 'Revenue',
|
||||||
|
value: fmtMoney
|
||||||
|
.format(report.rentalRevenue / 100),
|
||||||
|
icon: Icons.attach_money,
|
||||||
|
color: const Color(0xFF0E9F6E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MetricCard(
|
||||||
|
label: 'Collected',
|
||||||
|
value: fmtMoney
|
||||||
|
.format(report.totalPaid / 100),
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
color: const Color(0xFF0E9F6E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _MetricCard(
|
||||||
|
label: 'Outstanding',
|
||||||
|
value: fmtMoney
|
||||||
|
.format(report.totalOutstanding / 100),
|
||||||
|
icon: Icons.pending_outlined,
|
||||||
|
color: const Color(0xFFE02424),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (report.items.isNotEmpty) ...[
|
||||||
|
const SliverPadding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
'Reservations',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverList.separated(
|
||||||
|
itemCount: report.items.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final item = report.items[i];
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(
|
||||||
|
item.customerName ?? 'Walk-in',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${item.vehicleName ?? 'Vehicle'} • ${fmt.format(item.startDate)} – ${fmt.format(item.endDate)}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
fmtMoney.format(item.totalAmount / 100),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
item.paymentStatus,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: item.paymentStatus == 'PAID'
|
||||||
|
? const Color(0xFF0E9F6E)
|
||||||
|
: const Color(0xFF9CA3AF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateRangeBar extends StatelessWidget {
|
||||||
|
final DateTime start;
|
||||||
|
final DateTime end;
|
||||||
|
final DateFormat fmt;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _DateRangeBar({
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
required this.fmt,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFF9FAFB),
|
||||||
|
border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.date_range_outlined,
|
||||||
|
size: 18, color: Color(0xFF6B7280)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${fmt.format(start)} → ${fmt.format(end)}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
const Text('Change',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF1A56DB), fontSize: 13)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetricCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _MetricCard({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: color),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../providers/dashboard_providers.dart';
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../core/services/reservation_service.dart';
|
||||||
import '../../../widgets/status_badge.dart';
|
import '../../../widgets/status_badge.dart';
|
||||||
import '../../../widgets/error_view.dart';
|
import '../../../widgets/error_view.dart';
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ class ReservationDetailScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final async = ref.watch(reservationDetailProvider(id));
|
final async = ref.watch(reservationDetailProvider(id));
|
||||||
|
final paymentsAsync = ref.watch(reservationPaymentsProvider(id));
|
||||||
|
final inspectionsAsync = ref.watch(inspectionsProvider(id));
|
||||||
|
|
||||||
return async.when(
|
return async.when(
|
||||||
loading: () =>
|
loading: () =>
|
||||||
@@ -31,13 +35,22 @@ class ReservationDetailScreen extends ConsumerWidget {
|
|||||||
res.status == 'DRAFT' || res.status == 'CONFIRMED';
|
res.status == 'DRAFT' || res.status == 'CONFIRMED';
|
||||||
final canCheckin = res.status == 'CONFIRMED';
|
final canCheckin = res.status == 'CONFIRMED';
|
||||||
final canCheckout = res.status == 'ACTIVE';
|
final canCheckout = res.status == 'ACTIVE';
|
||||||
|
final canClose = res.status == 'COMPLETED';
|
||||||
|
final canRecordPayment = (res.status != 'CANCELLED') &&
|
||||||
|
res.paymentStatus != 'PAID';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('Reservation #${id.substring(0, 8)}'),
|
title: Text('Reservation #${id.substring(0, 8)}'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.description_outlined),
|
||||||
|
tooltip: 'View Contract',
|
||||||
|
onPressed: () => context.push(
|
||||||
|
'/dashboard/reservations/$id/contract'),
|
||||||
|
),
|
||||||
StatusBadge(status: res.status),
|
StatusBadge(status: res.status),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
@@ -82,7 +95,7 @@ class ReservationDetailScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
label: 'Total',
|
label: 'Total',
|
||||||
value: '\$${res.totalAmount.toStringAsFixed(2)}'),
|
value: '\$${(res.totalAmount / 100).toStringAsFixed(2)}'),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('Status: ',
|
const Text('Status: ',
|
||||||
@@ -94,7 +107,105 @@ class ReservationDetailScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
paymentsAsync.when(
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
|
data: (payments) {
|
||||||
|
if (payments.isEmpty) return const SizedBox.shrink();
|
||||||
|
return _Section(
|
||||||
|
title: 'Payment History',
|
||||||
|
child: Column(
|
||||||
|
children: payments
|
||||||
|
.map((p) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
p.paymentMethod,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 13),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
DateFormat('MMM d, yyyy')
|
||||||
|
.format(p.createdAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'\$${(p.amount / 100).toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF0E9F6E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Inspections
|
||||||
|
inspectionsAsync.when(
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
|
data: (inspections) {
|
||||||
|
final checkin = inspections
|
||||||
|
.where((i) => i.type == 'CHECKIN')
|
||||||
|
.firstOrNull;
|
||||||
|
final checkout = inspections
|
||||||
|
.where((i) => i.type == 'CHECKOUT')
|
||||||
|
.firstOrNull;
|
||||||
|
final canEditCheckin = res.status == 'CONFIRMED' ||
|
||||||
|
res.status == 'ACTIVE';
|
||||||
|
final canEditCheckout = res.status == 'ACTIVE' ||
|
||||||
|
res.status == 'COMPLETED';
|
||||||
|
if (!canEditCheckin && !canEditCheckout && inspections.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return _Section(
|
||||||
|
title: 'Inspections',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_InspectionCard(
|
||||||
|
label: 'Departure',
|
||||||
|
inspection: checkin,
|
||||||
|
canEdit: canEditCheckin,
|
||||||
|
onEdit: () => _showInspectionSheet(
|
||||||
|
context, ref, 'CHECKIN', checkin),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_InspectionCard(
|
||||||
|
label: 'Return',
|
||||||
|
inspection: checkout,
|
||||||
|
canEdit: canEditCheckout,
|
||||||
|
onEdit: () => _showInspectionSheet(
|
||||||
|
context, ref, 'CHECKOUT', checkout),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (canRecordPayment)
|
||||||
|
_ActionButton(
|
||||||
|
label: 'Record Payment',
|
||||||
|
color: const Color(0xFF0E9F6E),
|
||||||
|
icon: Icons.payments_outlined,
|
||||||
|
onTap: () => _showPaymentDialog(context, ref),
|
||||||
|
),
|
||||||
if (canConfirm)
|
if (canConfirm)
|
||||||
_ActionButton(
|
_ActionButton(
|
||||||
label: 'Confirm Reservation',
|
label: 'Confirm Reservation',
|
||||||
@@ -121,6 +232,37 @@ class ReservationDetailScreen extends ConsumerWidget {
|
|||||||
icon: Icons.logout,
|
icon: Icons.logout,
|
||||||
onTap: () => _showMileageDialog(context, ref, 'checkout'),
|
onTap: () => _showMileageDialog(context, ref, 'checkout'),
|
||||||
),
|
),
|
||||||
|
if (canClose)
|
||||||
|
_ActionButton(
|
||||||
|
label: 'Close Reservation',
|
||||||
|
color: const Color(0xFF5521B5),
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onTap: () async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Close Reservation'),
|
||||||
|
content: const Text(
|
||||||
|
'Mark this reservation as closed? This finalises the rental.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true && context.mounted) {
|
||||||
|
await ref
|
||||||
|
.read(reservationServiceProvider)
|
||||||
|
.closeReservation(id);
|
||||||
|
ref.invalidate(reservationDetailProvider(id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
if (canCancel)
|
if (canCancel)
|
||||||
_ActionButton(
|
_ActionButton(
|
||||||
label: 'Cancel Reservation',
|
label: 'Cancel Reservation',
|
||||||
@@ -135,6 +277,91 @@ class ReservationDetailScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showPaymentDialog(BuildContext context, WidgetRef ref) {
|
||||||
|
final amountCtrl = TextEditingController();
|
||||||
|
String method = 'CASH';
|
||||||
|
String type = 'CHARGE';
|
||||||
|
const methods = ['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD'];
|
||||||
|
const types = ['CHARGE', 'DEPOSIT'];
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => StatefulBuilder(
|
||||||
|
builder: (ctx, setState) => AlertDialog(
|
||||||
|
title: const Text('Record Payment'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: amountCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Amount (\$)'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InputDecorator(
|
||||||
|
decoration: const InputDecoration(labelText: 'Method'),
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: method,
|
||||||
|
isExpanded: true,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: methods
|
||||||
|
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => method = v!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InputDecorator(
|
||||||
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: type,
|
||||||
|
isExpanded: true,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: types
|
||||||
|
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => type = v!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0E9F6E)),
|
||||||
|
onPressed: () async {
|
||||||
|
final dollars = double.tryParse(amountCtrl.text);
|
||||||
|
if (dollars == null || dollars <= 0) return;
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
try {
|
||||||
|
await ref.read(paymentServiceProvider).recordManualPayment(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
'amount': (dollars * 100).round(),
|
||||||
|
'currency': 'USD',
|
||||||
|
'type': type,
|
||||||
|
'paymentMethod': method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ref.invalidate(reservationDetailProvider(id));
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Payment failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Record'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showMileageDialog(
|
void _showMileageDialog(
|
||||||
BuildContext context, WidgetRef ref, String type) {
|
BuildContext context, WidgetRef ref, String type) {
|
||||||
final ctrl = TextEditingController();
|
final ctrl = TextEditingController();
|
||||||
@@ -178,6 +405,24 @@ class ReservationDetailScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showInspectionSheet(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String type,
|
||||||
|
ReservationInspection? existing,
|
||||||
|
) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => _InspectionSheet(
|
||||||
|
reservationId: id,
|
||||||
|
type: type,
|
||||||
|
existing: existing,
|
||||||
|
onSaved: () => ref.invalidate(inspectionsProvider(id)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showCancelDialog(BuildContext context, WidgetRef ref) {
|
void _showCancelDialog(BuildContext context, WidgetRef ref) {
|
||||||
final ctrl = TextEditingController();
|
final ctrl = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -293,3 +538,364 @@ class _ActionButton extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fuel level helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _fuelLevels = [
|
||||||
|
'FULL',
|
||||||
|
'SEVEN_EIGHTHS',
|
||||||
|
'THREE_QUARTERS',
|
||||||
|
'FIVE_EIGHTHS',
|
||||||
|
'HALF',
|
||||||
|
'THREE_EIGHTHS',
|
||||||
|
'QUARTER',
|
||||||
|
'ONE_EIGHTH',
|
||||||
|
'EMPTY',
|
||||||
|
];
|
||||||
|
|
||||||
|
const _fuelLabels = {
|
||||||
|
'FULL': 'Full',
|
||||||
|
'SEVEN_EIGHTHS': '7/8',
|
||||||
|
'THREE_QUARTERS': '3/4',
|
||||||
|
'FIVE_EIGHTHS': '5/8',
|
||||||
|
'HALF': '1/2',
|
||||||
|
'THREE_EIGHTHS': '3/8',
|
||||||
|
'QUARTER': '1/4',
|
||||||
|
'ONE_EIGHTH': '1/8',
|
||||||
|
'EMPTY': 'Empty',
|
||||||
|
};
|
||||||
|
|
||||||
|
double _fuelFraction(String level) {
|
||||||
|
const fractions = {
|
||||||
|
'FULL': 1.0,
|
||||||
|
'SEVEN_EIGHTHS': 0.875,
|
||||||
|
'THREE_QUARTERS': 0.75,
|
||||||
|
'FIVE_EIGHTHS': 0.625,
|
||||||
|
'HALF': 0.5,
|
||||||
|
'THREE_EIGHTHS': 0.375,
|
||||||
|
'QUARTER': 0.25,
|
||||||
|
'ONE_EIGHTH': 0.125,
|
||||||
|
'EMPTY': 0.0,
|
||||||
|
};
|
||||||
|
return fractions[level] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── InspectionCard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _InspectionCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final ReservationInspection? inspection;
|
||||||
|
final bool canEdit;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
|
||||||
|
const _InspectionCard({
|
||||||
|
required this.label,
|
||||||
|
required this.inspection,
|
||||||
|
required this.canEdit,
|
||||||
|
required this.onEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final i = inspection;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF9FAFB),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFF374151))),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
if (i == null)
|
||||||
|
const Text('Not recorded',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF9CA3AF)))
|
||||||
|
else ...[
|
||||||
|
if (i.fuelLevel != null)
|
||||||
|
_FuelBar(level: i.fuelLevel!),
|
||||||
|
if (i.mileage != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${i.mileage} km',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF6B7280))),
|
||||||
|
],
|
||||||
|
if (i.generalCondition != null &&
|
||||||
|
i.generalCondition!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(i.generalCondition!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF6B7280))),
|
||||||
|
],
|
||||||
|
if (i.employeeNotes != null &&
|
||||||
|
i.employeeNotes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(i.employeeNotes!,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF9CA3AF),
|
||||||
|
fontStyle: FontStyle.italic)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canEdit)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
i == null ? Icons.add_circle_outline : Icons.edit_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: const Color(0xFF1A56DB),
|
||||||
|
),
|
||||||
|
onPressed: onEdit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FuelBar extends StatelessWidget {
|
||||||
|
final String level;
|
||||||
|
|
||||||
|
const _FuelBar({required this.level});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fraction = _fuelFraction(level);
|
||||||
|
final label = _fuelLabels[level] ?? level;
|
||||||
|
final color = fraction > 0.5
|
||||||
|
? const Color(0xFF057A55)
|
||||||
|
: fraction > 0.25
|
||||||
|
? const Color(0xFFFF6B00)
|
||||||
|
: const Color(0xFFE02424);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: fraction,
|
||||||
|
minHeight: 8,
|
||||||
|
backgroundColor: const Color(0xFFE5E7EB),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── InspectionSheet ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _InspectionSheet extends ConsumerStatefulWidget {
|
||||||
|
final String reservationId;
|
||||||
|
final String type; // CHECKIN | CHECKOUT
|
||||||
|
final ReservationInspection? existing;
|
||||||
|
final VoidCallback onSaved;
|
||||||
|
|
||||||
|
const _InspectionSheet({
|
||||||
|
required this.reservationId,
|
||||||
|
required this.type,
|
||||||
|
this.existing,
|
||||||
|
required this.onSaved,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_InspectionSheet> createState() => _InspectionSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InspectionSheetState extends ConsumerState<_InspectionSheet> {
|
||||||
|
late String _fuelLevel;
|
||||||
|
final _mileageCtrl = TextEditingController();
|
||||||
|
final _conditionCtrl = TextEditingController();
|
||||||
|
final _notesCtrl = TextEditingController();
|
||||||
|
bool _saving = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final e = widget.existing;
|
||||||
|
_fuelLevel = e?.fuelLevel ?? 'FULL';
|
||||||
|
if (e?.mileage != null) _mileageCtrl.text = '${e!.mileage}';
|
||||||
|
_conditionCtrl.text = e?.generalCondition ?? '';
|
||||||
|
_notesCtrl.text = e?.employeeNotes ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_mileageCtrl.dispose();
|
||||||
|
_conditionCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
setState(() {
|
||||||
|
_saving = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await ref.read(reservationServiceProvider).upsertInspection(
|
||||||
|
widget.reservationId,
|
||||||
|
widget.type,
|
||||||
|
{
|
||||||
|
'fuelLevel': _fuelLevel,
|
||||||
|
if (_mileageCtrl.text.isNotEmpty)
|
||||||
|
'mileage': int.parse(_mileageCtrl.text),
|
||||||
|
if (_conditionCtrl.text.trim().isNotEmpty)
|
||||||
|
'generalCondition': _conditionCtrl.text.trim(),
|
||||||
|
if (_notesCtrl.text.trim().isNotEmpty)
|
||||||
|
'employeeNotes': _notesCtrl.text.trim(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
widget.onSaved();
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_saving = false;
|
||||||
|
_error = 'Failed to save. Please try again.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final title = widget.type == 'CHECKIN'
|
||||||
|
? 'Departure Inspection'
|
||||||
|
: 'Return Inspection';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding:
|
||||||
|
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Fuel level
|
||||||
|
const Text('Fuel Level',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF374151))),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_FuelBar(level: _fuelLevel),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _fuelLevels.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: 6),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final level = _fuelLevels[i];
|
||||||
|
final selected = level == _fuelLevel;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _fuelLevel = level),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFF1A56DB)
|
||||||
|
: const Color(0xFFF3F4F6),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_fuelLabels[level] ?? level,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: selected
|
||||||
|
? Colors.white
|
||||||
|
: const Color(0xFF374151),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
// Mileage
|
||||||
|
TextField(
|
||||||
|
controller: _mileageCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(labelText: 'Mileage (km)'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// General condition
|
||||||
|
TextField(
|
||||||
|
controller: _conditionCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'General Condition (optional)'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Notes
|
||||||
|
TextField(
|
||||||
|
controller: _notesCtrl,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes (optional)',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(_error!,
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Color(0xFF9B1C1C))),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48)),
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Colors.white)),
|
||||||
|
)
|
||||||
|
: Text(widget.existing == null
|
||||||
|
? 'Record Inspection'
|
||||||
|
: 'Update Inspection'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart';
|
|||||||
import '../../../widgets/reservation_card.dart';
|
import '../../../widgets/reservation_card.dart';
|
||||||
import '../../../widgets/loading_list.dart';
|
import '../../../widgets/loading_list.dart';
|
||||||
import '../../../widgets/error_view.dart';
|
import '../../../widgets/error_view.dart';
|
||||||
|
import 'dashboard_shell.dart';
|
||||||
|
import 'new_reservation_screen.dart';
|
||||||
|
|
||||||
class ReservationsScreen extends ConsumerStatefulWidget {
|
class ReservationsScreen extends ConsumerStatefulWidget {
|
||||||
const ReservationsScreen({super.key});
|
const ReservationsScreen({super.key});
|
||||||
@@ -16,8 +18,24 @@ class ReservationsScreen extends ConsumerStatefulWidget {
|
|||||||
class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final TabController _tabs;
|
late final TabController _tabs;
|
||||||
final _statuses = [null, 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
|
final _statuses = [
|
||||||
final _labels = ['All', 'Confirmed', 'Active', 'Completed', 'Cancelled'];
|
null,
|
||||||
|
'DRAFT',
|
||||||
|
'CONFIRMED',
|
||||||
|
'ACTIVE',
|
||||||
|
'COMPLETED',
|
||||||
|
'CLOSED',
|
||||||
|
'CANCELLED',
|
||||||
|
];
|
||||||
|
final _labels = [
|
||||||
|
'All',
|
||||||
|
'Pending',
|
||||||
|
'Confirmed',
|
||||||
|
'Active',
|
||||||
|
'Completed',
|
||||||
|
'Closed',
|
||||||
|
'Cancelled',
|
||||||
|
];
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
String _search = '';
|
String _search = '';
|
||||||
|
|
||||||
@@ -35,15 +53,27 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _params(int tabIndex) => {
|
Map<String, dynamic> _params(int tabIndex) => {
|
||||||
'page': 1,
|
'page': 1,
|
||||||
'pageSize': 50,
|
'pageSize': 50,
|
||||||
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
|
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
|
||||||
if (_search.isNotEmpty) 'search': _search,
|
if (_search.isNotEmpty) 'search': _search,
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return DashboardShell(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(builder: (_) => const NewReservationScreen()),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
ref.invalidate(reservationsProvider);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Reservations'),
|
title: const Text('Reservations'),
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
@@ -74,8 +104,7 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
|||||||
_statuses.length,
|
_statuses.length,
|
||||||
(i) => _ReservationTab(
|
(i) => _ReservationTab(
|
||||||
params: _params(i),
|
params: _params(i),
|
||||||
onTap: (id) =>
|
onTap: (id) => context.push('/dashboard/reservations/$id'),
|
||||||
context.push('/dashboard/reservations/$id'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -103,8 +132,11 @@ class _ReservationTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
data: (res) => res.data.isEmpty
|
data: (res) => res.data.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text('No reservations found',
|
child: Text(
|
||||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
'No reservations found',
|
||||||
|
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async =>
|
onRefresh: () async =>
|
||||||
ref.invalidate(reservationsProvider(params)),
|
ref.invalidate(reservationsProvider(params)),
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../widgets/error_view.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends ConsumerWidget {
|
||||||
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final async = ref.watch(brandSettingsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Brand Settings')),
|
||||||
|
body: async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: 'Failed to load settings',
|
||||||
|
onRetry: () => ref.invalidate(brandSettingsProvider),
|
||||||
|
),
|
||||||
|
data: (settings) => _SettingsForm(settings: settings),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsForm extends ConsumerStatefulWidget {
|
||||||
|
final dynamic settings;
|
||||||
|
|
||||||
|
const _SettingsForm({required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_SettingsForm> createState() => _SettingsFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsFormState extends ConsumerState<_SettingsForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
late final _displayName =
|
||||||
|
TextEditingController(text: widget.settings.displayName as String);
|
||||||
|
late final _tagline =
|
||||||
|
TextEditingController(text: widget.settings.tagline as String?);
|
||||||
|
late final _email =
|
||||||
|
TextEditingController(text: widget.settings.publicEmail as String?);
|
||||||
|
late final _phone =
|
||||||
|
TextEditingController(text: widget.settings.publicPhone as String?);
|
||||||
|
late final _city =
|
||||||
|
TextEditingController(text: widget.settings.publicCity as String?);
|
||||||
|
late final _country =
|
||||||
|
TextEditingController(text: widget.settings.publicCountry as String?);
|
||||||
|
late final _website =
|
||||||
|
TextEditingController(text: widget.settings.websiteUrl as String?);
|
||||||
|
late final _whatsapp =
|
||||||
|
TextEditingController(text: widget.settings.whatsappNumber as String?);
|
||||||
|
late bool _listed = widget.settings.isListedOnMarketplace as bool;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [
|
||||||
|
_displayName, _tagline, _email, _phone, _city, _country, _website, _whatsapp
|
||||||
|
]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
await ref.read(settingsServiceProvider).updateBrand({
|
||||||
|
'displayName': _displayName.text.trim(),
|
||||||
|
if (_tagline.text.isNotEmpty) 'tagline': _tagline.text.trim(),
|
||||||
|
if (_email.text.isNotEmpty) 'publicEmail': _email.text.trim(),
|
||||||
|
if (_phone.text.isNotEmpty) 'publicPhone': _phone.text.trim(),
|
||||||
|
if (_city.text.isNotEmpty) 'publicCity': _city.text.trim(),
|
||||||
|
if (_country.text.isNotEmpty) 'publicCountry': _country.text.trim(),
|
||||||
|
if (_website.text.isNotEmpty) 'websiteUrl': _website.text.trim(),
|
||||||
|
if (_whatsapp.text.isNotEmpty) 'whatsappNumber': _whatsapp.text.trim(),
|
||||||
|
'isListedOnMarketplace': _listed,
|
||||||
|
});
|
||||||
|
ref.invalidate(brandSettingsProvider);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Settings saved')));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_section('Brand', [
|
||||||
|
_field('Display Name', _displayName, required: true),
|
||||||
|
_field('Tagline', _tagline),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_section('Contact', [
|
||||||
|
_field('Public Email', _email,
|
||||||
|
inputType: TextInputType.emailAddress),
|
||||||
|
_field('Public Phone', _phone, inputType: TextInputType.phone),
|
||||||
|
_field('WhatsApp Number', _whatsapp,
|
||||||
|
inputType: TextInputType.phone),
|
||||||
|
_field('Website URL', _website, inputType: TextInputType.url),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_section('Location', [
|
||||||
|
_field('City', _city),
|
||||||
|
_field('Country', _country),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: const Text('Listed on Marketplace'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Show your fleet on the public marketplace',
|
||||||
|
),
|
||||||
|
value: _listed,
|
||||||
|
onChanged: (v) => setState(() => _listed = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('Save Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _section(String title, List<Widget> children) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF6B7280))),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Column(children: children),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field(String label, TextEditingController ctrl,
|
||||||
|
{bool required = false, TextInputType? inputType}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: inputType,
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
validator: required
|
||||||
|
? (v) => v == null || v.isEmpty ? 'Required' : null
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
import '../../../widgets/error_view.dart';
|
||||||
|
import '../../../core/providers/auth_provider.dart';
|
||||||
|
|
||||||
|
class TeamScreen extends ConsumerWidget {
|
||||||
|
const TeamScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final async = ref.watch(teamProvider);
|
||||||
|
final myRole = ref.watch(authProvider).employee?.role ?? '';
|
||||||
|
final isOwner = myRole == 'OWNER';
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Team')),
|
||||||
|
floatingActionButton: isOwner
|
||||||
|
? FloatingActionButton.extended(
|
||||||
|
onPressed: () async {
|
||||||
|
final invited = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (_) => const _InviteSheet(),
|
||||||
|
);
|
||||||
|
if (invited == true) ref.invalidate(teamProvider);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.person_add_outlined),
|
||||||
|
label: const Text('Invite'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
body: async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: 'Failed to load team',
|
||||||
|
onRetry: () => ref.invalidate(teamProvider),
|
||||||
|
),
|
||||||
|
data: (members) {
|
||||||
|
if (members.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('No team members',
|
||||||
|
style: TextStyle(color: Color(0xFF9CA3AF))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(teamProvider),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
itemCount: members.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (_, i) => _MemberCard(
|
||||||
|
member: members[i],
|
||||||
|
isOwner: isOwner,
|
||||||
|
onDeactivate: () async {
|
||||||
|
await ref
|
||||||
|
.read(teamServiceProvider)
|
||||||
|
.deactivateMember(members[i].id);
|
||||||
|
ref.invalidate(teamProvider);
|
||||||
|
},
|
||||||
|
onReactivate: () async {
|
||||||
|
await ref
|
||||||
|
.read(teamServiceProvider)
|
||||||
|
.reactivateMember(members[i].id);
|
||||||
|
ref.invalidate(teamProvider);
|
||||||
|
},
|
||||||
|
onRoleChange: (role) async {
|
||||||
|
await ref
|
||||||
|
.read(teamServiceProvider)
|
||||||
|
.updateRole(members[i].id, role);
|
||||||
|
ref.invalidate(teamProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MemberCard extends StatelessWidget {
|
||||||
|
final dynamic member;
|
||||||
|
final bool isOwner;
|
||||||
|
final VoidCallback onDeactivate;
|
||||||
|
final VoidCallback onReactivate;
|
||||||
|
final ValueChanged<String> onRoleChange;
|
||||||
|
|
||||||
|
const _MemberCard({
|
||||||
|
required this.member,
|
||||||
|
required this.isOwner,
|
||||||
|
required this.onDeactivate,
|
||||||
|
required this.onReactivate,
|
||||||
|
required this.onRoleChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isActive = member.isActive as bool;
|
||||||
|
final role = member.role as String;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: isActive
|
||||||
|
? const Color(0xFFE1EFFE)
|
||||||
|
: const Color(0xFFF3F4F6),
|
||||||
|
radius: 22,
|
||||||
|
child: Text(
|
||||||
|
(member.firstName as String)
|
||||||
|
.substring(0, 1)
|
||||||
|
.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isActive
|
||||||
|
? const Color(0xFF1A56DB)
|
||||||
|
: const Color(0xFF9CA3AF),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
member.fullName as String,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isActive ? null : const Color(0xFF9CA3AF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_RoleBadge(role: role),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
member.email as String,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Color(0xFF6B7280)),
|
||||||
|
),
|
||||||
|
if (!isActive)
|
||||||
|
const Text(
|
||||||
|
'Inactive',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isOwner && role != 'OWNER')
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) {
|
||||||
|
if (v == 'deactivate') onDeactivate();
|
||||||
|
if (v == 'reactivate') onReactivate();
|
||||||
|
if (v == 'MANAGER' || v == 'AGENT') onRoleChange(v);
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
if (role != 'MANAGER')
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'MANAGER', child: Text('Make Manager')),
|
||||||
|
if (role != 'AGENT')
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'AGENT', child: Text('Make Agent')),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
if (isActive)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'deactivate',
|
||||||
|
child: Text('Deactivate',
|
||||||
|
style: TextStyle(color: Color(0xFFE02424))),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'reactivate',
|
||||||
|
child: Text('Reactivate',
|
||||||
|
style: TextStyle(color: Color(0xFF0E9F6E))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoleBadge extends StatelessWidget {
|
||||||
|
final String role;
|
||||||
|
|
||||||
|
const _RoleBadge({required this.role});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = {
|
||||||
|
'OWNER': (const Color(0xFFFFF3CD), const Color(0xFF92400E)),
|
||||||
|
'MANAGER': (const Color(0xFFE1EFFE), const Color(0xFF1E40AF)),
|
||||||
|
'AGENT': (const Color(0xFFF3F4F6), const Color(0xFF374151)),
|
||||||
|
};
|
||||||
|
final (bg, fg) = colors[role] ?? (const Color(0xFFF3F4F6), const Color(0xFF374151));
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||||
|
decoration:
|
||||||
|
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
|
||||||
|
child: Text(role,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10, color: fg, fontWeight: FontWeight.w600)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InviteSheet extends ConsumerStatefulWidget {
|
||||||
|
const _InviteSheet();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_InviteSheet> createState() => _InviteSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InviteSheetState extends ConsumerState<_InviteSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
final _firstName = TextEditingController();
|
||||||
|
final _lastName = TextEditingController();
|
||||||
|
final _email = TextEditingController();
|
||||||
|
String _role = 'AGENT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_firstName, _lastName, _email]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
await ref.read(teamServiceProvider).inviteMember({
|
||||||
|
'firstName': _firstName.text.trim(),
|
||||||
|
'lastName': _lastName.text.trim(),
|
||||||
|
'email': _email.text.trim(),
|
||||||
|
'role': _role,
|
||||||
|
});
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text('Invite Team Member',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const Spacer(),
|
||||||
|
if (_loading)
|
||||||
|
const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
else
|
||||||
|
TextButton(
|
||||||
|
onPressed: _submit, child: const Text('Invite')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _firstName,
|
||||||
|
decoration: const InputDecoration(labelText: 'First Name'),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _lastName,
|
||||||
|
decoration: const InputDecoration(labelText: 'Last Name'),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _email,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(labelText: 'Email'),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InputDecorator(
|
||||||
|
decoration: const InputDecoration(labelText: 'Role'),
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _role,
|
||||||
|
isExpanded: true,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'MANAGER', child: Text('Manager')),
|
||||||
|
DropdownMenuItem(value: 'AGENT', child: Text('Agent')),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _role = v!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48)),
|
||||||
|
child: const Text('Send Invitation'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../../../core/models/vehicle.dart';
|
||||||
|
import '../providers/dashboard_providers.dart';
|
||||||
|
|
||||||
|
class VehicleFormScreen extends ConsumerStatefulWidget {
|
||||||
|
final Vehicle? vehicle;
|
||||||
|
|
||||||
|
const VehicleFormScreen({super.key, this.vehicle});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<VehicleFormScreen> createState() => _VehicleFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VehicleFormScreenState extends ConsumerState<VehicleFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
late final _make = TextEditingController(text: widget.vehicle?.make);
|
||||||
|
late final _model = TextEditingController(text: widget.vehicle?.model);
|
||||||
|
late final _year = TextEditingController(
|
||||||
|
text: widget.vehicle?.year.toString() ?? DateTime.now().year.toString());
|
||||||
|
late final _plate =
|
||||||
|
TextEditingController(text: widget.vehicle?.licensePlate);
|
||||||
|
late final _rate = TextEditingController(
|
||||||
|
text: widget.vehicle != null
|
||||||
|
? (widget.vehicle!.dailyRate / 100).toStringAsFixed(2)
|
||||||
|
: '');
|
||||||
|
late final _mileage = TextEditingController(
|
||||||
|
text: widget.vehicle?.mileage?.toString());
|
||||||
|
late final _seats = TextEditingController(
|
||||||
|
text: widget.vehicle?.seats?.toString() ?? '5');
|
||||||
|
late final _color = TextEditingController(text: widget.vehicle?.color ?? '');
|
||||||
|
|
||||||
|
String _category = 'ECONOMY';
|
||||||
|
String _transmission = 'AUTOMATIC';
|
||||||
|
String _fuelType = 'GASOLINE';
|
||||||
|
|
||||||
|
static const _categories = [
|
||||||
|
'ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK',
|
||||||
|
];
|
||||||
|
static const _transmissions = ['AUTOMATIC', 'MANUAL'];
|
||||||
|
static const _fuels = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.vehicle != null) {
|
||||||
|
_category = widget.vehicle!.category;
|
||||||
|
_transmission = widget.vehicle!.transmission ?? 'AUTOMATIC';
|
||||||
|
_fuelType = widget.vehicle!.fuelType ?? 'GASOLINE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_make, _model, _year, _plate, _rate, _mileage, _seats, _color]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final data = {
|
||||||
|
'make': _make.text.trim(),
|
||||||
|
'model': _model.text.trim(),
|
||||||
|
'year': int.parse(_year.text),
|
||||||
|
'licensePlate': _plate.text.trim(),
|
||||||
|
'dailyRate': (double.parse(_rate.text) * 100).round(),
|
||||||
|
'category': _category,
|
||||||
|
'transmission': _transmission,
|
||||||
|
'fuelType': _fuelType,
|
||||||
|
'seats': int.tryParse(_seats.text) ?? 5,
|
||||||
|
if (_mileage.text.isNotEmpty) 'mileage': int.parse(_mileage.text),
|
||||||
|
if (_color.text.isNotEmpty) 'color': _color.text.trim(),
|
||||||
|
};
|
||||||
|
final svc = ref.read(vehicleServiceProvider);
|
||||||
|
if (widget.vehicle == null) {
|
||||||
|
await svc.createVehicle(data);
|
||||||
|
} else {
|
||||||
|
await svc.updateVehicle(widget.vehicle!.id, data);
|
||||||
|
}
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isEdit = widget.vehicle != null;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(isEdit ? 'Edit Vehicle' : 'New Vehicle'),
|
||||||
|
actions: [
|
||||||
|
if (_loading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextButton(
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_section('Basic Info', [
|
||||||
|
_field('Make', _make, required: true),
|
||||||
|
_field('Model', _model, required: true),
|
||||||
|
_field('Year', _year, required: true,
|
||||||
|
inputType: TextInputType.number),
|
||||||
|
_field('License Plate', _plate, required: true),
|
||||||
|
_field('Color', _color),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_section('Specs', [
|
||||||
|
_dropdown('Category', _category, _categories,
|
||||||
|
(v) => setState(() => _category = v!)),
|
||||||
|
_dropdown('Transmission', _transmission, _transmissions,
|
||||||
|
(v) => setState(() => _transmission = v!)),
|
||||||
|
_dropdown('Fuel Type', _fuelType, _fuels,
|
||||||
|
(v) => setState(() => _fuelType = v!)),
|
||||||
|
_field('Seats', _seats, inputType: TextInputType.number),
|
||||||
|
_field('Mileage (km)', _mileage,
|
||||||
|
inputType: TextInputType.number),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_section('Pricing', [
|
||||||
|
_field('Daily Rate (\$)', _rate,
|
||||||
|
required: true, inputType: TextInputType.number),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
child: Text(isEdit ? 'Save Changes' : 'Create Vehicle'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _section(String title, List<Widget> children) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF6B7280))),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Column(children: children),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field(String label, TextEditingController ctrl,
|
||||||
|
{bool required = false, TextInputType? inputType}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: inputType,
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
validator: required
|
||||||
|
? (v) => v == null || v.isEmpty ? 'Required' : null
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dropdown(String label, String value, List<String> items,
|
||||||
|
ValueChanged<String?> onChanged) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: InputDecoration(labelText: label),
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: value,
|
||||||
|
isExpanded: true,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: items
|
||||||
|
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VehiclePhotosSheet extends ConsumerStatefulWidget {
|
||||||
|
final String vehicleId;
|
||||||
|
final List<String> currentPhotos;
|
||||||
|
|
||||||
|
const VehiclePhotosSheet({
|
||||||
|
super.key,
|
||||||
|
required this.vehicleId,
|
||||||
|
required this.currentPhotos,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<VehiclePhotosSheet> createState() => _VehiclePhotosSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VehiclePhotosSheetState extends ConsumerState<VehiclePhotosSheet> {
|
||||||
|
bool _uploading = false;
|
||||||
|
|
||||||
|
Future<void> _pickAndUpload() async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final files = await picker.pickMultiImage(imageQuality: 80);
|
||||||
|
if (files.isEmpty) return;
|
||||||
|
setState(() => _uploading = true);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(vehicleServiceProvider)
|
||||||
|
.uploadPhotos(widget.vehicleId, files.map((f) => f.path).toList());
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Upload failed: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _uploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deletePhoto(int idx) async {
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(vehicleServiceProvider)
|
||||||
|
.deletePhoto(widget.vehicleId, idx);
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Delete failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text('Photos',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const Spacer(),
|
||||||
|
if (_uploading)
|
||||||
|
const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_photo_alternate_outlined),
|
||||||
|
onPressed: _pickAndUpload,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (widget.currentPhotos.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text('No photos yet',
|
||||||
|
style: TextStyle(color: Color(0xFF9CA3AF))),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
height: 100,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: widget.currentPhotos.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (_, i) => Stack(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.network(
|
||||||
|
widget.currentPhotos[i],
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _deletePhoto(i),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFE02424),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.close,
|
||||||
|
size: 14, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart';
|
|||||||
import '../../../widgets/vehicle_card.dart';
|
import '../../../widgets/vehicle_card.dart';
|
||||||
import '../../../widgets/loading_list.dart';
|
import '../../../widgets/loading_list.dart';
|
||||||
import '../../../widgets/error_view.dart';
|
import '../../../widgets/error_view.dart';
|
||||||
|
import 'dashboard_shell.dart';
|
||||||
|
import 'vehicle_form_screen.dart';
|
||||||
|
|
||||||
class VehiclesScreen extends ConsumerStatefulWidget {
|
class VehiclesScreen extends ConsumerStatefulWidget {
|
||||||
const VehiclesScreen({super.key});
|
const VehiclesScreen({super.key});
|
||||||
@@ -25,17 +27,26 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> get _params => {
|
Map<String, dynamic> get _params => {
|
||||||
'page': 1,
|
'page': 1,
|
||||||
'pageSize': 50,
|
'pageSize': 50,
|
||||||
if (_statusFilter != null) 'status': _statusFilter,
|
if (_statusFilter != null) 'status': _statusFilter,
|
||||||
if (_search.isNotEmpty) 'search': _search,
|
if (_search.isNotEmpty) 'search': _search,
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final vehiclesAsync = ref.watch(vehiclesProvider(_params));
|
final vehiclesAsync = ref.watch(vehiclesProvider(_params));
|
||||||
|
|
||||||
return Scaffold(
|
return DashboardShell(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(builder: (_) => const VehicleFormScreen()),
|
||||||
|
);
|
||||||
|
if (result == true) ref.invalidate(vehiclesProvider(_params));
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Vehicles'),
|
title: const Text('Vehicles'),
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
@@ -57,9 +68,7 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
|||||||
PopupMenuButton<String?>(
|
PopupMenuButton<String?>(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.filter_list,
|
Icons.filter_list,
|
||||||
color: _statusFilter != null
|
color: _statusFilter != null ? const Color(0xFF1A56DB) : null,
|
||||||
? const Color(0xFF1A56DB)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
onSelected: (v) => setState(() => _statusFilter = v),
|
onSelected: (v) => setState(() => _statusFilter = v),
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
@@ -67,7 +76,9 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
|||||||
const PopupMenuItem(value: 'AVAILABLE', child: Text('Available')),
|
const PopupMenuItem(value: 'AVAILABLE', child: Text('Available')),
|
||||||
const PopupMenuItem(value: 'RENTED', child: Text('Rented')),
|
const PopupMenuItem(value: 'RENTED', child: Text('Rented')),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'MAINTENANCE', child: Text('Maintenance')),
|
value: 'MAINTENANCE',
|
||||||
|
child: Text('Maintenance'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -80,15 +91,17 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
|||||||
),
|
),
|
||||||
data: (res) => res.data.isEmpty
|
data: (res) => res.data.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text('No vehicles found',
|
child: Text(
|
||||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
'No vehicles found',
|
||||||
|
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async =>
|
onRefresh: () async =>
|
||||||
ref.invalidate(vehiclesProvider(_params)),
|
ref.invalidate(vehiclesProvider(_params)),
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
gridDelegate:
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 1,
|
crossAxisCount: 1,
|
||||||
mainAxisExtent: 240,
|
mainAxisExtent: 240,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
@@ -96,8 +109,8 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
|||||||
itemCount: res.data.length,
|
itemCount: res.data.length,
|
||||||
itemBuilder: (_, i) => VehicleCard(
|
itemBuilder: (_, i) => VehicleCard(
|
||||||
vehicle: res.data[i],
|
vehicle: res.data[i],
|
||||||
onTap: () => context
|
onTap: () =>
|
||||||
.push('/dashboard/vehicles/${res.data[i].id}'),
|
context.push('/dashboard/vehicles/${res.data[i].id}'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"appName": "RentalDriveGo",
|
"appName": "RentalDriveGo",
|
||||||
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
|
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
|
||||||
"findYourCar": "ابحث عن سيارتك",
|
"findYourCar": "ابحث عن سيارتك",
|
||||||
"companySignIn": "تسجيل دخول لوحة تحكم الشركة",
|
"companySignIn": "وكالة السيارات",
|
||||||
"sameDropoff": "إرجاع نفس المكان",
|
"sameDropoff": "إرجاع نفس المكان",
|
||||||
"differentDropoff": "إرجاع مكان آخر",
|
"differentDropoff": "إرجاع مكان آخر",
|
||||||
"findCarToRent": "ابحث عن سيارة للإيجار",
|
"findCarToRent": "ابحث عن سيارة للإيجار",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"appName": "RentalDriveGo",
|
"appName": "RentalDriveGo",
|
||||||
"tagline": "The easiest way to find\nand reserve your perfect car",
|
"tagline": "The easiest way to find\nand reserve your perfect car",
|
||||||
"findYourCar": "Find your car",
|
"findYourCar": "Find your car",
|
||||||
"companySignIn": "Company dashboard sign in",
|
"companySignIn": "Space Agency",
|
||||||
"sameDropoff": "Same drop-off",
|
"sameDropoff": "Same drop-off",
|
||||||
"differentDropoff": "Different drop-off",
|
"differentDropoff": "Different drop-off",
|
||||||
"findCarToRent": "Find a car to rent",
|
"findCarToRent": "Find a car to rent",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"appName": "RentalDriveGo",
|
"appName": "RentalDriveGo",
|
||||||
"tagline": "La façon la plus simple de trouver\net réserver la voiture parfaite",
|
"tagline": "La façon la plus simple de trouver\net réserver la voiture parfaite",
|
||||||
"findYourCar": "Trouver ma voiture",
|
"findYourCar": "Trouver ma voiture",
|
||||||
"companySignIn": "Connexion tableau de bord entreprise",
|
"companySignIn": "Espace Agence",
|
||||||
"sameDropoff": "Retour même lieu",
|
"sameDropoff": "Retour même lieu",
|
||||||
"differentDropoff": "Retour autre lieu",
|
"differentDropoff": "Retour autre lieu",
|
||||||
"findCarToRent": "Trouver une voiture à louer",
|
"findCarToRent": "Trouver une voiture à louer",
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @companySignIn.
|
/// No description provided for @companySignIn.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Company dashboard sign in'**
|
/// **'Space Agency'**
|
||||||
String get companySignIn;
|
String get companySignIn;
|
||||||
|
|
||||||
/// No description provided for @sameDropoff.
|
/// No description provided for @sameDropoff.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
String get findYourCar => 'ابحث عن سيارتك';
|
String get findYourCar => 'ابحث عن سيارتك';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get companySignIn => 'تسجيل دخول لوحة تحكم الشركة';
|
String get companySignIn => 'وكالة السيارات';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sameDropoff => 'إرجاع نفس المكان';
|
String get sameDropoff => 'إرجاع نفس المكان';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get findYourCar => 'Find your car';
|
String get findYourCar => 'Find your car';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get companySignIn => 'Company dashboard sign in';
|
String get companySignIn => 'Space Agency';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sameDropoff => 'Same drop-off';
|
String get sameDropoff => 'Same drop-off';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get findYourCar => 'Trouver ma voiture';
|
String get findYourCar => 'Trouver ma voiture';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get companySignIn => 'Connexion tableau de bord entreprise';
|
String get companySignIn => 'Espace Agence';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sameDropoff => 'Retour même lieu';
|
String get sameDropoff => 'Retour même lieu';
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import '../core/providers/theme_provider.dart';
|
|||||||
|
|
||||||
const _orange = Color(0xFFFF6B00);
|
const _orange = Color(0xFFFF6B00);
|
||||||
|
|
||||||
/// Compact row of language pills + theme toggle.
|
const _langs = [
|
||||||
|
('EN', Locale('en')),
|
||||||
|
('FR', Locale('fr')),
|
||||||
|
('AR', Locale('ar')),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Compact language dropdown + theme toggle.
|
||||||
/// [onDark] = true when rendered on a dark background (landing screen).
|
/// [onDark] = true when rendered on a dark background (landing screen).
|
||||||
class PreferencesBar extends ConsumerWidget {
|
class PreferencesBar extends ConsumerWidget {
|
||||||
final bool onDark;
|
final bool onDark;
|
||||||
@@ -17,69 +23,100 @@ class PreferencesBar extends ConsumerWidget {
|
|||||||
final currentLocale = ref.watch(localeProvider);
|
final currentLocale = ref.watch(localeProvider);
|
||||||
final themeMode = ref.watch(themeProvider);
|
final themeMode = ref.watch(themeProvider);
|
||||||
final isDark = themeMode == ThemeMode.dark;
|
final isDark = themeMode == ThemeMode.dark;
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
final inactiveText = onDark
|
final chipBg = onDark
|
||||||
? Colors.white.withValues(alpha: 0.55)
|
|
||||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
|
||||||
final activeText = Colors.white;
|
|
||||||
final inactiveBg = onDark
|
|
||||||
? Colors.white.withValues(alpha: 0.12)
|
? Colors.white.withValues(alpha: 0.12)
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
: cs.surfaceContainerHighest;
|
||||||
|
final iconColor = onDark
|
||||||
const langs = [
|
? Colors.white.withValues(alpha: 0.8)
|
||||||
('EN', Locale('en')),
|
: cs.onSurfaceVariant;
|
||||||
('FR', Locale('fr')),
|
final langCode = currentLocale.languageCode.toUpperCase();
|
||||||
('AR', Locale('ar')),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Language pills
|
// ── Language dropdown ──────────────────────────────────────────
|
||||||
...langs.map((item) {
|
PopupMenuButton<Locale>(
|
||||||
final (code, locale) = item;
|
onSelected: (locale) =>
|
||||||
final selected =
|
ref.read(localeProvider.notifier).setLocale(locale),
|
||||||
currentLocale.languageCode == locale.languageCode;
|
color: cs.surfaceContainerHigh,
|
||||||
return GestureDetector(
|
shape: RoundedRectangleBorder(
|
||||||
onTap: () => ref.read(localeProvider.notifier).setLocale(locale),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
side: BorderSide(color: cs.outlineVariant),
|
||||||
margin: const EdgeInsets.only(right: 6),
|
),
|
||||||
padding:
|
offset: const Offset(0, 36),
|
||||||
const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
|
itemBuilder: (_) => _langs.map((item) {
|
||||||
decoration: BoxDecoration(
|
final (code, locale) = item;
|
||||||
color: selected ? _orange : inactiveBg,
|
final selected =
|
||||||
borderRadius: BorderRadius.circular(20),
|
currentLocale.languageCode == locale.languageCode;
|
||||||
),
|
return PopupMenuItem<Locale>(
|
||||||
child: Text(
|
value: locale,
|
||||||
code,
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
style: TextStyle(
|
child: Row(
|
||||||
color: selected ? activeText : inactiveText,
|
children: [
|
||||||
fontSize: 12,
|
Expanded(
|
||||||
fontWeight:
|
child: Text(
|
||||||
selected ? FontWeight.bold : FontWeight.normal,
|
code,
|
||||||
),
|
style: TextStyle(
|
||||||
|
color: selected ? _orange : cs.onSurface,
|
||||||
|
fontWeight: selected
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (selected)
|
||||||
|
const Icon(Icons.check, size: 16, color: _orange),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: chipBg,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
);
|
child: Row(
|
||||||
}),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
langCode,
|
||||||
|
style: TextStyle(
|
||||||
|
color: onDark ? Colors.white : cs.onSurface,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Icon(
|
||||||
|
Icons.expand_more,
|
||||||
|
size: 14,
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(width: 2),
|
const SizedBox(width: 6),
|
||||||
|
|
||||||
// Theme toggle icon
|
// ── Theme toggle ───────────────────────────────────────────────
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => ref.read(themeProvider.notifier).toggle(),
|
onTap: () => ref.read(themeProvider.notifier).toggle(),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(7),
|
padding: const EdgeInsets.all(7),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: inactiveBg,
|
color: chipBg,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||||||
size: 15,
|
size: 15,
|
||||||
color: onDark
|
color: iconColor,
|
||||||
? Colors.white.withValues(alpha: 0.8)
|
|
||||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class ReservationCard extends StatelessWidget {
|
|||||||
size: 14, color: Color(0xFF9CA3AF)),
|
size: 14, color: Color(0xFF9CA3AF)),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'\$${reservation.totalAmount.toStringAsFixed(2)}',
|
'\$${(reservation.totalAmount / 100).toStringAsFixed(2)}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF111928),
|
color: Color(0xFF111928),
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class StatusBadge extends StatelessWidget {
|
|||||||
return (const Color(0xFF1A56DB), const Color(0xFFE1EFFE), 'Active');
|
return (const Color(0xFF1A56DB), const Color(0xFFE1EFFE), 'Active');
|
||||||
case 'COMPLETED':
|
case 'COMPLETED':
|
||||||
return (const Color(0xFF5521B5), const Color(0xFFEDEBFE), 'Completed');
|
return (const Color(0xFF5521B5), const Color(0xFFEDEBFE), 'Completed');
|
||||||
|
case 'CLOSED':
|
||||||
|
return (const Color(0xFF374151), const Color(0xFFF3F4F6), 'Closed');
|
||||||
case 'CANCELLED':
|
case 'CANCELLED':
|
||||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Cancelled');
|
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Cancelled');
|
||||||
case 'DRAFT':
|
case 'DRAFT':
|
||||||
@@ -49,6 +51,10 @@ class StatusBadge extends StatelessWidget {
|
|||||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'No Show');
|
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'No Show');
|
||||||
case 'PENDING':
|
case 'PENDING':
|
||||||
return (const Color(0xFFB45309), const Color(0xFFFDF6B2), 'Pending');
|
return (const Color(0xFFB45309), const Color(0xFFFDF6B2), 'Pending');
|
||||||
|
case 'PARTIAL':
|
||||||
|
return (const Color(0xFFB45309), const Color(0xFFFDF6B2), 'Partial');
|
||||||
|
case 'PAID':
|
||||||
|
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Paid');
|
||||||
case 'SUCCEEDED':
|
case 'SUCCEEDED':
|
||||||
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Paid');
|
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Paid');
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class VehicleCard extends StatelessWidget {
|
|||||||
const Icon(Icons.attach_money,
|
const Icon(Icons.attach_money,
|
||||||
size: 16, color: Color(0xFF1A56DB)),
|
size: 16, color: Color(0xFF1A56DB)),
|
||||||
Text(
|
Text(
|
||||||
'${vehicle.dailyRate.toStringAsFixed(0)}/day',
|
'\$${(vehicle.dailyRate / 100).toStringAsFixed(0)}/day',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF1A56DB),
|
color: Color(0xFF1A56DB),
|
||||||
|
|||||||
+112
@@ -105,6 +105,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -169,6 +177,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+5"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -219,6 +259,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.34"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -325,6 +373,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.8.0"
|
||||||
|
image_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
image_picker_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_android
|
||||||
|
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+17"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
image_picker_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_ios
|
||||||
|
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+6"
|
||||||
|
image_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_linux
|
||||||
|
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
image_picker_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_macos
|
||||||
|
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2+1"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.1"
|
||||||
|
image_picker_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_windows
|
||||||
|
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ dependencies:
|
|||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
shimmer: ^3.0.0
|
shimmer: ^3.0.0
|
||||||
fl_chart: ^0.69.0
|
fl_chart: ^0.69.0
|
||||||
|
image_picker: ^1.1.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user