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">
|
||||
<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
|
||||
android:label="car_rental_app"
|
||||
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.
|
||||
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 */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -41,14 +43,18 @@
|
||||
/* End PBXCopyFilesBuildPhase 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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -57,6 +63,11 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -64,6 +75,15 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
995A594C2412F61895CB2016 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EC4F91958B1958510DF60AFF /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F42061B85542064B2CAE079F /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -83,6 +103,7 @@
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
79F48B122F8D6BC8002A1A11 /* Profile.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
@@ -96,6 +117,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
D394CDA7A7BD6BDE41D8494A /* Pods */,
|
||||
ACB44088637E3B4C0CAEDA11 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -124,6 +147,29 @@
|
||||
path = Runner;
|
||||
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 */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -131,8 +177,10 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
F3650807002DE913D3C084C2 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
EC4F91958B1958510DF60AFF /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -148,12 +196,14 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
8ADD842875B4146703974D33 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
0A3C24793BEDEF7D086CCEB8 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -225,6 +275,23 @@
|
||||
/* End PBXResourcesBuildPhase 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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -241,6 +308,28 @@
|
||||
shellPath = /bin/sh;
|
||||
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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -256,6 +345,28 @@
|
||||
shellPath = /bin/sh;
|
||||
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 */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -361,13 +472,14 @@
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
baseConfigurationReference = 79F48B122F8D6BC8002A1A11 /* Profile.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -382,6 +494,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = A7206B50C42F65222935848D /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -399,6 +512,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = AA12F1DB701472A907C0C6B9 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -414,6 +528,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = B893C3A8BB7D1BA341320415 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -547,6 +662,7 @@
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -569,6 +685,7 @@
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
+3
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import 'dart:io';
|
||||
|
||||
class AppConstants {
|
||||
// Android emulator routes localhost as 10.0.2.2; iOS simulator uses localhost fine
|
||||
static String get baseUrl => Platform.isAndroid
|
||||
? 'http://10.0.2.2:4000/api/v1'
|
||||
: 'http://localhost:4000/api/v1';
|
||||
static const String apiBaseUrlOverride =
|
||||
String.fromEnvironment('API_BASE_URL');
|
||||
static const String developmentAndroidApiUrl = 'http://10.0.2.2: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 userTypeKey = 'user_type';
|
||||
|
||||
@@ -193,7 +193,7 @@ class MarketplaceOffer {
|
||||
String get discountLabel {
|
||||
switch (type) {
|
||||
case 'PERCENTAGE':
|
||||
return '${discountValue}% off';
|
||||
return '$discountValue% off';
|
||||
case 'FIXED_AMOUNT':
|
||||
return '\$${(discountValue / 100).toStringAsFixed(0)} off';
|
||||
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 DateTime createdAt;
|
||||
final String? source;
|
||||
final String? contractNumber;
|
||||
final String? invoiceNumber;
|
||||
|
||||
const Reservation({
|
||||
required this.id,
|
||||
@@ -25,6 +27,8 @@ class Reservation {
|
||||
this.customer,
|
||||
required this.createdAt,
|
||||
this.source,
|
||||
this.contractNumber,
|
||||
this.invoiceNumber,
|
||||
});
|
||||
|
||||
int get days => endDate.difference(startDate).inDays;
|
||||
@@ -49,6 +53,8 @@ class Reservation {
|
||||
createdAt: DateTime.parse(
|
||||
json['createdAt'] as String? ?? DateTime.now().toIso8601String()),
|
||||
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 String? transmission;
|
||||
final String? fuelType;
|
||||
final String? color;
|
||||
final bool isPublished;
|
||||
|
||||
const Vehicle({
|
||||
@@ -28,6 +29,7 @@ class Vehicle {
|
||||
this.seats,
|
||||
this.transmission,
|
||||
this.fuelType,
|
||||
this.color,
|
||||
required this.isPublished,
|
||||
});
|
||||
|
||||
@@ -52,6 +54,7 @@ class Vehicle {
|
||||
seats: json['seats'] as int?,
|
||||
transmission: json['transmission'] as String?,
|
||||
fuelType: json['fuelType'] as String?,
|
||||
color: json['color'] as String?,
|
||||
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/role_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/vehicles_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/customers_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_vehicle_detail_screen.dart';
|
||||
import '../../features/client/screens/booking_screen.dart';
|
||||
@@ -25,7 +31,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/splash',
|
||||
redirect: (context, state) {
|
||||
// Read (not watch) so redirect is evaluated on router.refresh()
|
||||
final auth = ref.read(authProvider);
|
||||
final status = auth.status;
|
||||
final loc = state.matchedLocation;
|
||||
@@ -33,12 +38,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
final isLanding = loc == '/landing';
|
||||
final isLogin = loc.startsWith('/login');
|
||||
|
||||
// Still initialising — stay on splash
|
||||
if (status == AuthStatus.unknown) {
|
||||
return isSplash ? null : '/splash';
|
||||
}
|
||||
|
||||
// Auth resolved — always leave splash
|
||||
if (isSplash) {
|
||||
if (status == AuthStatus.authenticated) {
|
||||
return auth.userType == AppConstants.userTypeEmployee
|
||||
@@ -49,7 +52,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
}
|
||||
|
||||
if (status == AuthStatus.unauthenticated) {
|
||||
// Allow landing, login routes, and the entire client section without auth
|
||||
if (isLanding || isLogin || loc.startsWith('/client')) return null;
|
||||
return '/landing';
|
||||
}
|
||||
@@ -64,95 +66,99 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, _) => const SplashScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/landing',
|
||||
builder: (context, _) => const LandingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/role',
|
||||
builder: (context, _) => const RoleScreen(),
|
||||
),
|
||||
GoRoute(path: '/splash', builder: (context, _) => const SplashScreen()),
|
||||
GoRoute(path: '/landing', builder: (context, _) => const LandingScreen()),
|
||||
GoRoute(path: '/role', builder: (context, _) => const RoleScreen()),
|
||||
GoRoute(
|
||||
path: '/login/employee',
|
||||
builder: (context, _) => const EmployeeLoginScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => DashboardShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
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']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
GoRoute(path: '/dashboard', builder: (context, _) => const HomeScreen()),
|
||||
GoRoute(
|
||||
path: '/dashboard/vehicles',
|
||||
builder: (context, _) => const VehiclesScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => ClientShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/client',
|
||||
builder: (context, _) => const ClientHomeScreen(),
|
||||
),
|
||||
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?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/client/bookings',
|
||||
builder: (context, _) => const MyBookingsScreen(),
|
||||
),
|
||||
],
|
||||
GoRoute(
|
||||
path: '/dashboard/reservations',
|
||||
builder: (context, _) => const ReservationsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard/customers',
|
||||
builder: (context, _) => const CustomersScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard/vehicles/:id',
|
||||
builder: (_, state) =>
|
||||
VehicleDetailScreen(id: state.pathParameters['id']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard/reservations/:id',
|
||||
builder: (_, state) =>
|
||||
ReservationDetailScreen(id: state.pathParameters['id']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard/reservations/:id/contract',
|
||||
builder: (_, state) =>
|
||||
ContractScreen(reservationId: state.pathParameters['id']!),
|
||||
),
|
||||
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());
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../models/analytics.dart';
|
||||
import '../models/report.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class AnalyticsService {
|
||||
@@ -8,4 +9,18 @@ class AnalyticsService {
|
||||
final res = await _client.get('/analytics/dashboard');
|
||||
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(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await TokenStorage.getToken();
|
||||
if (token != null) {
|
||||
if (token != null && !_shouldSkipAuthHeader(options.path)) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
@@ -41,6 +41,11 @@ class ApiClient {
|
||||
|
||||
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.
|
||||
// In practice this is called once and cached via the singleton.
|
||||
Dio get dio => _dio;
|
||||
@@ -54,5 +59,8 @@ class ApiClient {
|
||||
Future<Response> patch(String path, {dynamic 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);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class AuthService {
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await _client.post('/auth/employee/login', data: {
|
||||
'email': email,
|
||||
'email': email.trim().toLowerCase(),
|
||||
'password': password,
|
||||
});
|
||||
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 'api_client.dart';
|
||||
|
||||
@@ -26,4 +27,40 @@ class CustomerService {
|
||||
final res = await _client.post('/customers', data: data);
|
||||
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',
|
||||
if (endDate != null)
|
||||
'endDate': '${endDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||
if (category != null) 'category': category,
|
||||
if (transmission != null) 'transmission': transmission,
|
||||
'category': ?category,
|
||||
'transmission': ?transmission,
|
||||
if (make != null && make.isNotEmpty) 'make': make,
|
||||
if (maxPriceCents != null) 'maxPrice': maxPriceCents,
|
||||
'maxPrice': ?maxPriceCents,
|
||||
});
|
||||
|
||||
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 '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 {
|
||||
final _client = ApiClient.instance;
|
||||
|
||||
@@ -8,13 +40,17 @@ class ReservationService {
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? status,
|
||||
String? source,
|
||||
String? search,
|
||||
String? customerId,
|
||||
}) async {
|
||||
final res = await _client.get('/reservations', params: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (status != null) 'status': status,
|
||||
'status': ?status,
|
||||
'source': ?source,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
'customerId': ?customerId,
|
||||
});
|
||||
return ReservationListResponse.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
@@ -44,4 +80,26 @@ class ReservationService {
|
||||
Future<void> checkout(String id, int mileage) async {
|
||||
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 '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 {
|
||||
final _client = ApiClient.instance;
|
||||
|
||||
@@ -13,7 +76,7 @@ class VehicleService {
|
||||
final res = await _client.get('/vehicles', params: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (status != null) 'status': status,
|
||||
'status': ?status,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
});
|
||||
return VehicleListResponse.fromJson(res.data as Map<String, dynamic>);
|
||||
@@ -34,7 +97,70 @@ class VehicleService {
|
||||
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 {
|
||||
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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
|
||||
class EmployeeLoginScreen extends ConsumerStatefulWidget {
|
||||
@@ -36,15 +37,36 @@ class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
} catch (e) {
|
||||
} on DioException catch (e) {
|
||||
setState(() {
|
||||
_error = 'Invalid email or password. Please try again.';
|
||||
_error = _messageForDioError(e);
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() {
|
||||
_error = 'Sign in failed. Please try again.';
|
||||
});
|
||||
} finally {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -107,6 +129,10 @@ class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.username, AutofillHints.email],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
@@ -120,6 +146,9 @@ class _EmployeeLoginScreenState extends ConsumerState<EmployeeLoginScreen> {
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onFieldSubmitted: (_) => _login(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
|
||||
@@ -7,14 +7,22 @@ import '../providers/client_providers.dart';
|
||||
import '../../../core/models/marketplace.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../widgets/preferences_bar.dart';
|
||||
import 'client_shell.dart';
|
||||
import 'widgets/marketplace_vehicle_card.dart';
|
||||
import 'widgets/offer_banner.dart';
|
||||
|
||||
const _orange = Color(0xFFFF6B00);
|
||||
const _blue = Color(0xFF1A56DB);
|
||||
|
||||
const _allCarTypes = [
|
||||
'ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE',
|
||||
'SUV', 'LUXURY', 'VAN', 'TRUCK',
|
||||
'ECONOMY',
|
||||
'COMPACT',
|
||||
'MIDSIZE',
|
||||
'FULLSIZE',
|
||||
'SUV',
|
||||
'LUXURY',
|
||||
'VAN',
|
||||
'TRUCK',
|
||||
];
|
||||
|
||||
String _capitalize(String s) =>
|
||||
@@ -62,8 +70,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
endDate: _endDate,
|
||||
categories: _selectedTypes.isEmpty ? null : Set.from(_selectedTypes),
|
||||
maxPriceCents: _priceFilterActive ? _maxPriceCents : null,
|
||||
minPriceCents:
|
||||
_priceFilterActive && _minPriceCents > 0 ? _minPriceCents : null,
|
||||
minPriceCents: _priceFilterActive && _minPriceCents > 0
|
||||
? _minPriceCents
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,9 +87,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
? DateTimeRange(start: _startDate!, end: _endDate!)
|
||||
: DateTimeRange(start: now, end: now.add(const Duration(days: 1))),
|
||||
builder: (context, child) => Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: cs.copyWith(primary: _orange),
|
||||
),
|
||||
data: Theme.of(
|
||||
context,
|
||||
).copyWith(colorScheme: cs.copyWith(primary: _orange)),
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
@@ -174,7 +183,9 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
}
|
||||
|
||||
List<MarketplaceVehicle> _applyClientFilters(
|
||||
List<MarketplaceVehicle> all, SearchParams params) {
|
||||
List<MarketplaceVehicle> all,
|
||||
SearchParams params,
|
||||
) {
|
||||
var result = all;
|
||||
final cats = params.categories;
|
||||
if (cats != null && cats.length > 1) {
|
||||
@@ -199,14 +210,14 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
final typeLabel = _selectedTypes.isEmpty
|
||||
? l10n.allCarTypes
|
||||
: _selectedTypes.length == 1
|
||||
? _capitalize(_selectedTypes.first)
|
||||
: '${_selectedTypes.length} types';
|
||||
? _capitalize(_selectedTypes.first)
|
||||
: '${_selectedTypes.length} types';
|
||||
|
||||
final priceLabel = _priceFilterActive
|
||||
? '\$${(_minPriceCents / 100).toStringAsFixed(0)} – \$${(_maxPriceCents / 100).toStringAsFixed(0)}/day'
|
||||
: l10n.anyPrice;
|
||||
|
||||
return Scaffold(
|
||||
return ClientShell(
|
||||
backgroundColor: cs.surface,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -268,12 +279,14 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
children: [
|
||||
// Pickup
|
||||
_SearchInputBox(
|
||||
onTap: () => citiesAsync
|
||||
.whenData((c) => _showCitySheet(c, l10n)),
|
||||
onTap: () => citiesAsync.whenData(
|
||||
(c) => _showCitySheet(c, l10n),
|
||||
),
|
||||
child: _LocationRow(
|
||||
icon: Icons.radio_button_checked,
|
||||
iconColor: _orange,
|
||||
text: _city ??
|
||||
text:
|
||||
_city ??
|
||||
(_sameDropoff
|
||||
? l10n.findCarToRent
|
||||
: l10n.pickupLocation),
|
||||
@@ -307,17 +320,20 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
// Drop-off box
|
||||
_SearchInputBox(
|
||||
onTap: () => citiesAsync.whenData(
|
||||
(c) => _showDropoffCitySheet(c, l10n)),
|
||||
(c) => _showDropoffCitySheet(c, l10n),
|
||||
),
|
||||
child: _LocationRow(
|
||||
icon: Icons.location_on,
|
||||
iconColor: _blue,
|
||||
text: _dropoffCity ??
|
||||
text:
|
||||
_dropoffCity ??
|
||||
l10n.dropoffLocation,
|
||||
placeholder: _dropoffCity == null,
|
||||
cs: cs,
|
||||
onClear: _dropoffCity != null
|
||||
? () => setState(
|
||||
() => _dropoffCity = null)
|
||||
() => _dropoffCity = null,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
@@ -343,13 +359,18 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Icon(Icons.calendar_today_outlined,
|
||||
size: 18, color: cs.onSurfaceVariant),
|
||||
Icon(
|
||||
Icons.calendar_today_outlined,
|
||||
size: 18,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
l10n.pickDates,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant, fontSize: 15),
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -382,10 +403,10 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
onTap: () => _showPriceSheet(l10n),
|
||||
onClear: _priceFilterActive
|
||||
? () => setState(() {
|
||||
_priceFilterActive = false;
|
||||
_minPriceCents = 0;
|
||||
_maxPriceCents = 50000;
|
||||
})
|
||||
_priceFilterActive = false;
|
||||
_minPriceCents = 0;
|
||||
_maxPriceCents = 50000;
|
||||
})
|
||||
: null,
|
||||
),
|
||||
),
|
||||
@@ -426,28 +447,38 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
|
||||
child: searchAsync.when(
|
||||
loading: () => Row(children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: cs.onSurfaceVariant,
|
||||
loading: () => Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.searching,
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.searching,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
]),
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (r) {
|
||||
final count =
|
||||
_applyClientFilters(r.data, params).length;
|
||||
final count = _applyClientFilters(
|
||||
r.data,
|
||||
params,
|
||||
).length;
|
||||
return Text(
|
||||
l10n.vehiclesFound(count),
|
||||
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 ──────────────────────────────────────────────────────
|
||||
searchAsync.when(
|
||||
loading: () => const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: _orange, strokeWidth: 2.5),
|
||||
color: _orange,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (_, _) => SliverFillRemaining(
|
||||
@@ -477,7 +556,8 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
|
||||
final vehicles = _applyClientFilters(result.data, params);
|
||||
if (vehicles.isEmpty) {
|
||||
return SliverFillRemaining(
|
||||
child: _EmptyBody(l10n: l10n, cs: cs));
|
||||
child: _EmptyBody(l10n: l10n, cs: cs),
|
||||
);
|
||||
}
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||
@@ -511,8 +591,11 @@ class _DropoffTab extends StatelessWidget {
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DropoffTab(
|
||||
{required this.label, required this.active, required this.onTap});
|
||||
const _DropoffTab({
|
||||
required this.label,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -579,26 +662,25 @@ class _LocationRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: iconColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: placeholder ? cs.onSurfaceVariant : cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Icon(icon, size: 18, color: iconColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: placeholder ? cs.onSurfaceVariant : cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
if (onClear != null)
|
||||
GestureDetector(
|
||||
onTap: onClear,
|
||||
child:
|
||||
Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
),
|
||||
if (onClear != null)
|
||||
GestureDetector(
|
||||
onTap: onClear,
|
||||
child: Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Date range display ────────────────────────────────────────────────────────
|
||||
@@ -629,8 +711,11 @@ class _DateRangeRow extends StatelessWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Icon(Icons.arrow_forward,
|
||||
size: 14, color: cs.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 14,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
_DateChunk(
|
||||
date: dayFmt.format(end),
|
||||
@@ -647,23 +732,23 @@ class _DateChunk extends StatelessWidget {
|
||||
final String sub;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _DateChunk(
|
||||
{required this.date, required this.sub, required this.cs});
|
||||
const _DateChunk({required this.date, required this.sub, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Text(date,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 6),
|
||||
Text(sub,
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
],
|
||||
);
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(sub, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Option tile (car type / price) ────────────────────────────────────────────
|
||||
@@ -696,15 +781,11 @@ class _OptionTile extends StatelessWidget {
|
||||
? _blue.withValues(alpha: 0.12)
|
||||
: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: active ? _blue : cs.outlineVariant,
|
||||
),
|
||||
border: Border.all(color: active ? _blue : cs.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon,
|
||||
size: 16,
|
||||
color: active ? _blue : cs.onSurfaceVariant),
|
||||
Icon(icon, size: 16, color: active ? _blue : cs.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -712,8 +793,7 @@ class _OptionTile extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
active ? FontWeight.w600 : FontWeight.normal,
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
@@ -722,9 +802,11 @@ class _OptionTile extends StatelessWidget {
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: onClear,
|
||||
child: Icon(Icons.close,
|
||||
size: 14,
|
||||
color: active ? _blue : cs.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: active ? _blue : cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -843,7 +925,11 @@ class _CarTypeSheetState extends State<_CarTypeSheet> {
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, 10, 16, 16 + MediaQuery.of(context).padding.bottom),
|
||||
16,
|
||||
10,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
@@ -853,12 +939,17 @@ class _CarTypeSheetState extends State<_CarTypeSheet> {
|
||||
backgroundColor: _orange,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: Text(l10n.apply,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
child: Text(
|
||||
l10n.apply,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -903,8 +994,9 @@ class _PriceSheetState extends State<_PriceSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(double v) =>
|
||||
v >= _PriceSheet._cap ? '\$${v.toStringAsFixed(0)}+' : '\$${v.toStringAsFixed(0)}';
|
||||
String _fmt(double v) => v >= _PriceSheet._cap
|
||||
? '\$${v.toStringAsFixed(0)}+'
|
||||
: '\$${v.toStringAsFixed(0)}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -912,8 +1004,9 @@ class _PriceSheetState extends State<_PriceSheet> {
|
||||
final l10n = widget.l10n;
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -941,8 +1034,10 @@ class _PriceSheetState extends State<_PriceSheet> {
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(l10n.clearAll,
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
child: Text(
|
||||
l10n.clearAll,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -955,8 +1050,7 @@ class _PriceSheetState extends State<_PriceSheet> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_PriceTag(label: _fmt(_range.start), cs: cs),
|
||||
Icon(Icons.arrow_forward,
|
||||
size: 14, color: cs.onSurfaceVariant),
|
||||
Icon(Icons.arrow_forward, size: 14, color: cs.onSurfaceVariant),
|
||||
_PriceTag(label: '${_fmt(_range.end)}/day', cs: cs),
|
||||
],
|
||||
),
|
||||
@@ -987,7 +1081,11 @@ class _PriceSheetState extends State<_PriceSheet> {
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, 4, 16, 16 + MediaQuery.of(context).padding.bottom),
|
||||
16,
|
||||
4,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
@@ -1001,12 +1099,17 @@ class _PriceSheetState extends State<_PriceSheet> {
|
||||
backgroundColor: _orange,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: Text(l10n.apply,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
child: Text(
|
||||
l10n.apply,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1024,21 +1127,21 @@ class _PriceTag extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15),
|
||||
),
|
||||
);
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── City Sheet ────────────────────────────────────────────────────────────────
|
||||
@@ -1089,9 +1192,10 @@ class _CitySheetState extends State<_CitySheet> {
|
||||
Text(
|
||||
widget.titleOverride ?? widget.l10n.selectCity,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: cs.onSurface),
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
@@ -1099,10 +1203,12 @@ class _CitySheetState extends State<_CitySheet> {
|
||||
style: TextStyle(color: cs.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.l10n.searchCities,
|
||||
prefixIcon: Icon(Icons.search,
|
||||
size: 20, color: cs.onSurfaceVariant),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 8),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
size: 20,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
),
|
||||
@@ -1114,10 +1220,14 @@ class _CitySheetState extends State<_CitySheet> {
|
||||
controller: controller,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.public_outlined,
|
||||
color: cs.onSurfaceVariant),
|
||||
title: Text(widget.l10n.allCities,
|
||||
style: TextStyle(color: cs.onSurface)),
|
||||
leading: Icon(
|
||||
Icons.public_outlined,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
widget.l10n.allCities,
|
||||
style: TextStyle(color: cs.onSurface),
|
||||
),
|
||||
selected: widget.selected == null,
|
||||
selectedColor: _orange,
|
||||
trailing: widget.selected == null
|
||||
@@ -1125,19 +1235,21 @@ class _CitySheetState extends State<_CitySheet> {
|
||||
: null,
|
||||
onTap: () => widget.onSelect(null),
|
||||
),
|
||||
...filtered.map((city) => ListTile(
|
||||
leading: Icon(Icons.location_city_outlined,
|
||||
color: cs.onSurfaceVariant),
|
||||
title: Text(city,
|
||||
style: TextStyle(color: cs.onSurface)),
|
||||
selected: widget.selected == city,
|
||||
selectedColor: _orange,
|
||||
trailing: widget.selected == city
|
||||
? const Icon(Icons.check,
|
||||
color: _orange, size: 18)
|
||||
: null,
|
||||
onTap: () => widget.onSelect(city),
|
||||
)),
|
||||
...filtered.map(
|
||||
(city) => ListTile(
|
||||
leading: Icon(
|
||||
Icons.location_city_outlined,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
title: Text(city, style: TextStyle(color: cs.onSurface)),
|
||||
selected: widget.selected == city,
|
||||
selectedColor: _orange,
|
||||
trailing: widget.selected == city
|
||||
? const Icon(Icons.check, color: _orange, size: 18)
|
||||
: null,
|
||||
onTap: () => widget.onSelect(city),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1155,13 +1267,13 @@ class _SheetHandle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error / Empty states ─────────────────────────────────────────────────────
|
||||
@@ -1171,46 +1283,48 @@ class _ErrorBody extends StatelessWidget {
|
||||
final AppLocalizations l10n;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _ErrorBody(
|
||||
{required this.onRetry, required this.l10n, required this.cs});
|
||||
const _ErrorBody({
|
||||
required this.onRetry,
|
||||
required this.l10n,
|
||||
required this.cs,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.wifi_off_outlined,
|
||||
size: 48, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.couldNotLoadVehicles,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
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),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.wifi_off_outlined, size: 48, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.couldNotLoadVehicles,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _EmptyBody extends StatelessWidget {
|
||||
@@ -1221,29 +1335,29 @@ class _EmptyBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 56, color: cs.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.noVehiclesFound,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.tryDifferentSearch,
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 56, color: cs.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.noVehiclesFound,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
class ClientShell extends ConsumerWidget {
|
||||
final Widget child;
|
||||
class ClientShell extends StatelessWidget {
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
@@ -17,7 +25,10 @@ class ClientShell extends ConsumerWidget {
|
||||
if (location == '/client/bookings') currentIndex = 1;
|
||||
|
||||
return Scaffold(
|
||||
body: child,
|
||||
appBar: appBar,
|
||||
backgroundColor: backgroundColor,
|
||||
body: body,
|
||||
floatingActionButton: floatingActionButton,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (i) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import 'client_shell.dart';
|
||||
|
||||
class MyBookingsScreen extends ConsumerWidget {
|
||||
const MyBookingsScreen({super.key});
|
||||
@@ -11,7 +12,7 @@ class MyBookingsScreen extends ConsumerWidget {
|
||||
final auth = ref.watch(authProvider);
|
||||
|
||||
if (auth.status != AuthStatus.authenticated) {
|
||||
return Scaffold(
|
||||
return ClientShell(
|
||||
appBar: AppBar(title: const Text('My Bookings')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
@@ -19,8 +20,11 @@ class MyBookingsScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.bookmark_outline,
|
||||
size: 64, color: Color(0xFFD1D5DB)),
|
||||
const Icon(
|
||||
Icons.bookmark_outline,
|
||||
size: 64,
|
||||
color: Color(0xFFD1D5DB),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'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')),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
@@ -58,10 +62,7 @@ class MyBookingsScreen extends ConsumerWidget {
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No bookings yet',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
style: TextStyle(fontSize: 16, color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,20 +3,48 @@ import '../../../core/models/analytics.dart';
|
||||
import '../../../core/models/vehicle.dart';
|
||||
import '../../../core/models/reservation.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/vehicle_service.dart';
|
||||
import '../../../core/services/reservation_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 vehicleServiceProvider = Provider((_) => VehicleService());
|
||||
final reservationServiceProvider = Provider((_) => ReservationService());
|
||||
final customerServiceProvider = Provider((_) => CustomerService());
|
||||
final notificationServiceProvider = Provider((_) => NotificationService());
|
||||
final offerServiceProvider = Provider((_) => OfferService());
|
||||
final settingsServiceProvider = Provider((_) => SettingsService());
|
||||
|
||||
// ─── Analytics ────────────────────────────────────────────────
|
||||
|
||||
final dashboardMetricsProvider = FutureProvider<DashboardMetrics>((ref) async {
|
||||
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>>(
|
||||
(ref, params) async {
|
||||
return ref.read(vehicleServiceProvider).getVehicles(
|
||||
@@ -32,6 +60,21 @@ final vehicleDetailProvider = FutureProvider.family<Vehicle, String>((ref, 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 =
|
||||
FutureProvider.family<ReservationListResponse, Map<String, dynamic>>(
|
||||
(ref, params) async {
|
||||
@@ -39,7 +82,9 @@ final reservationsProvider =
|
||||
page: params['page'] as int? ?? 1,
|
||||
pageSize: params['pageSize'] as int? ?? 20,
|
||||
status: params['status'] as String?,
|
||||
source: params['source'] as String?,
|
||||
search: params['search'] as String?,
|
||||
customerId: params['customerId'] as String?,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -49,6 +94,13 @@ final reservationDetailProvider =
|
||||
return ref.read(reservationServiceProvider).getReservation(id);
|
||||
});
|
||||
|
||||
final inspectionsProvider =
|
||||
FutureProvider.family<List<ReservationInspection>, String>((ref, id) {
|
||||
return ref.read(reservationServiceProvider).getInspections(id);
|
||||
});
|
||||
|
||||
// ─── Customers ────────────────────────────────────────────────
|
||||
|
||||
final customersProvider =
|
||||
FutureProvider.family<CustomerListResponse, Map<String, dynamic>>(
|
||||
(ref, params) async {
|
||||
@@ -64,3 +116,57 @@ final customerDetailProvider =
|
||||
FutureProvider.family<Customer, String>((ref, 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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/status_badge.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
import '../../../widgets/reservation_card.dart';
|
||||
import 'customer_form_screen.dart';
|
||||
|
||||
class CustomerDetailScreen extends ConsumerWidget {
|
||||
final String id;
|
||||
@@ -13,6 +16,9 @@ class CustomerDetailScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(customerDetailProvider(id));
|
||||
final reservationsAsync = ref.watch(
|
||||
reservationsProvider({'customerId': id, 'page': 1, 'pageSize': 10}),
|
||||
);
|
||||
|
||||
return async.when(
|
||||
loading: () =>
|
||||
@@ -27,7 +33,62 @@ class CustomerDetailScreen extends ConsumerWidget {
|
||||
data: (customer) {
|
||||
final fmt = DateFormat('MMM d, yyyy');
|
||||
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(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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 {
|
||||
@@ -151,11 +413,40 @@ class _InfoRow extends StatelessWidget {
|
||||
Icon(icon, size: 18, color: const Color(0xFF6B7280)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(value,
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
child: Text(value, 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/error_view.dart';
|
||||
import '../../../widgets/status_badge.dart';
|
||||
import 'dashboard_shell.dart';
|
||||
import 'customer_form_screen.dart';
|
||||
|
||||
class CustomersScreen extends ConsumerStatefulWidget {
|
||||
const CustomersScreen({super.key});
|
||||
@@ -24,16 +26,25 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _params => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
title: const Text('Customers'),
|
||||
bottom: PreferredSize(
|
||||
@@ -60,8 +71,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
||||
),
|
||||
data: (res) => res.data.isEmpty
|
||||
? const Center(
|
||||
child: Text('No customers found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
||||
child: Text(
|
||||
'No customers found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.invalidate(customersProvider(_params)),
|
||||
@@ -98,8 +112,7 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
@@ -111,9 +124,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
|
||||
),
|
||||
if (c.isFlagged) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.flag,
|
||||
size: 14,
|
||||
color: Color(0xFFE02424)),
|
||||
const Icon(
|
||||
Icons.flag,
|
||||
size: 14,
|
||||
color: Color(0xFFE02424),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,11 +2,21 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -19,8 +29,11 @@ class DashboardShell extends ConsumerWidget {
|
||||
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
|
||||
|
||||
return Scaffold(
|
||||
drawer: _Drawer(employee: employee, ref: ref),
|
||||
body: child,
|
||||
appBar: appBar,
|
||||
backgroundColor: backgroundColor,
|
||||
drawer: _Drawer(employee: employee),
|
||||
body: body,
|
||||
floatingActionButton: floatingActionButton,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (i) {
|
||||
@@ -62,14 +75,16 @@ class DashboardShell extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Drawer extends StatelessWidget {
|
||||
class _Drawer extends ConsumerWidget {
|
||||
final dynamic employee;
|
||||
final WidgetRef ref;
|
||||
|
||||
const _Drawer({required this.employee, required this.ref});
|
||||
const _Drawer({required this.employee});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unreadAsync = ref.watch(unreadCountProvider);
|
||||
final unread = unreadAsync.valueOrNull ?? 0;
|
||||
|
||||
return Drawer(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
@@ -84,7 +99,9 @@ class _Drawer extends StatelessWidget {
|
||||
child: Text(
|
||||
employee?.firstName.substring(0, 1).toUpperCase() ?? 'U',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -99,7 +116,9 @@ class _Drawer extends StatelessWidget {
|
||||
Text(
|
||||
employee?.role ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFF6B7280)),
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -108,14 +127,115 @@ class _Drawer extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
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(
|
||||
leading: const Icon(Icons.logout, color: Color(0xFFE02424)),
|
||||
title: const Text('Sign Out',
|
||||
style: TextStyle(color: Color(0xFFE02424))),
|
||||
title: const Text(
|
||||
'Sign Out',
|
||||
style: TextStyle(color: Color(0xFFE02424)),
|
||||
),
|
||||
onTap: () async {
|
||||
final router = GoRouter.of(context);
|
||||
Navigator.of(context).pop();
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
router.go('/landing');
|
||||
},
|
||||
),
|
||||
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 '../../../core/providers/auth_provider.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import 'dashboard_shell.dart';
|
||||
import '../../../widgets/stat_card.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
import '../../../widgets/reservation_card.dart';
|
||||
@@ -18,8 +19,18 @@ class HomeScreen extends ConsumerWidget {
|
||||
final recentAsync = ref.watch(
|
||||
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(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -30,18 +41,21 @@ class HomeScreen extends ConsumerWidget {
|
||||
),
|
||||
Text(
|
||||
DateFormat('EEEE, MMMM d').format(DateTime.now()),
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||
),
|
||||
IconButton(
|
||||
icon: unreadCount > 0
|
||||
? Badge(
|
||||
label: Text('$unreadCount'),
|
||||
child: const Icon(Icons.notifications_outlined),
|
||||
)
|
||||
: const Icon(Icons.notifications_outlined),
|
||||
onPressed: () => context.push('/dashboard/notifications'),
|
||||
),
|
||||
_AvatarMenu(employee: employee),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
@@ -92,7 +106,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
child: StatCard(
|
||||
title: 'Revenue',
|
||||
value:
|
||||
'\$${NumberFormat.compact().format(metrics.totalRevenue)}',
|
||||
'\$${NumberFormat.compact().format(metrics.totalRevenue / 100)}',
|
||||
icon: Icons.attach_money,
|
||||
color: const Color(0xFF5521B5),
|
||||
),
|
||||
@@ -127,7 +141,8 @@ class HomeScreen extends ConsumerWidget {
|
||||
value: metrics.occupancyRate / 100,
|
||||
backgroundColor: const Color(0xFFE5E7EB),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFF1A56DB)),
|
||||
Color(0xFF1A56DB),
|
||||
),
|
||||
minHeight: 8,
|
||||
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),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -168,7 +221,8 @@ class HomeScreen extends ConsumerWidget {
|
||||
recentAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => const ErrorView(
|
||||
message: 'Could not load recent reservations'),
|
||||
message: 'Could not load recent reservations',
|
||||
),
|
||||
data: (res) => res.data.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
@@ -181,15 +235,17 @@ class HomeScreen extends ConsumerWidget {
|
||||
)
|
||||
: Column(
|
||||
children: res.data
|
||||
.map((r) => Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 10),
|
||||
child: ReservationCard(
|
||||
reservation: r,
|
||||
onTap: () => context.push(
|
||||
'/dashboard/reservations/${r.id}'),
|
||||
.map(
|
||||
(r) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: ReservationCard(
|
||||
reservation: r,
|
||||
onTap: () => context.push(
|
||||
'/dashboard/reservations/${r.id}',
|
||||
),
|
||||
))
|
||||
),
|
||||
),
|
||||
)
|
||||
.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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../core/services/reservation_service.dart';
|
||||
import '../../../widgets/status_badge.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
|
||||
@@ -13,6 +15,8 @@ class ReservationDetailScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(reservationDetailProvider(id));
|
||||
final paymentsAsync = ref.watch(reservationPaymentsProvider(id));
|
||||
final inspectionsAsync = ref.watch(inspectionsProvider(id));
|
||||
|
||||
return async.when(
|
||||
loading: () =>
|
||||
@@ -31,13 +35,22 @@ class ReservationDetailScreen extends ConsumerWidget {
|
||||
res.status == 'DRAFT' || res.status == 'CONFIRMED';
|
||||
final canCheckin = res.status == 'CONFIRMED';
|
||||
final canCheckout = res.status == 'ACTIVE';
|
||||
final canClose = res.status == 'COMPLETED';
|
||||
final canRecordPayment = (res.status != 'CANCELLED') &&
|
||||
res.paymentStatus != 'PAID';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Reservation #${id.substring(0, 8)}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.description_outlined),
|
||||
tooltip: 'View Contract',
|
||||
onPressed: () => context.push(
|
||||
'/dashboard/reservations/$id/contract'),
|
||||
),
|
||||
StatusBadge(status: res.status),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
@@ -82,7 +95,7 @@ class ReservationDetailScreen extends ConsumerWidget {
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Total',
|
||||
value: '\$${res.totalAmount.toStringAsFixed(2)}'),
|
||||
value: '\$${(res.totalAmount / 100).toStringAsFixed(2)}'),
|
||||
Row(
|
||||
children: [
|
||||
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),
|
||||
if (canRecordPayment)
|
||||
_ActionButton(
|
||||
label: 'Record Payment',
|
||||
color: const Color(0xFF0E9F6E),
|
||||
icon: Icons.payments_outlined,
|
||||
onTap: () => _showPaymentDialog(context, ref),
|
||||
),
|
||||
if (canConfirm)
|
||||
_ActionButton(
|
||||
label: 'Confirm Reservation',
|
||||
@@ -121,6 +232,37 @@ class ReservationDetailScreen extends ConsumerWidget {
|
||||
icon: Icons.logout,
|
||||
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)
|
||||
_ActionButton(
|
||||
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(
|
||||
BuildContext context, WidgetRef ref, String type) {
|
||||
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) {
|
||||
final ctrl = TextEditingController();
|
||||
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/loading_list.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
import 'dashboard_shell.dart';
|
||||
import 'new_reservation_screen.dart';
|
||||
|
||||
class ReservationsScreen extends ConsumerStatefulWidget {
|
||||
const ReservationsScreen({super.key});
|
||||
@@ -16,8 +18,24 @@ class ReservationsScreen extends ConsumerStatefulWidget {
|
||||
class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabs;
|
||||
final _statuses = [null, 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
|
||||
final _labels = ['All', 'Confirmed', 'Active', 'Completed', 'Cancelled'];
|
||||
final _statuses = [
|
||||
null,
|
||||
'DRAFT',
|
||||
'CONFIRMED',
|
||||
'ACTIVE',
|
||||
'COMPLETED',
|
||||
'CLOSED',
|
||||
'CANCELLED',
|
||||
];
|
||||
final _labels = [
|
||||
'All',
|
||||
'Pending',
|
||||
'Confirmed',
|
||||
'Active',
|
||||
'Completed',
|
||||
'Closed',
|
||||
'Cancelled',
|
||||
];
|
||||
final _searchController = TextEditingController();
|
||||
String _search = '';
|
||||
|
||||
@@ -35,15 +53,27 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
||||
}
|
||||
|
||||
Map<String, dynamic> _params(int tabIndex) => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
|
||||
@override
|
||||
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(
|
||||
title: const Text('Reservations'),
|
||||
bottom: TabBar(
|
||||
@@ -74,8 +104,7 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
|
||||
_statuses.length,
|
||||
(i) => _ReservationTab(
|
||||
params: _params(i),
|
||||
onTap: (id) =>
|
||||
context.push('/dashboard/reservations/$id'),
|
||||
onTap: (id) => context.push('/dashboard/reservations/$id'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -103,8 +132,11 @@ class _ReservationTab extends ConsumerWidget {
|
||||
),
|
||||
data: (res) => res.data.isEmpty
|
||||
? const Center(
|
||||
child: Text('No reservations found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
||||
child: Text(
|
||||
'No reservations found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
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/loading_list.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
import 'dashboard_shell.dart';
|
||||
import 'vehicle_form_screen.dart';
|
||||
|
||||
class VehiclesScreen extends ConsumerStatefulWidget {
|
||||
const VehiclesScreen({super.key});
|
||||
@@ -25,17 +27,26 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _params => {
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_statusFilter != null) 'status': _statusFilter,
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
'page': 1,
|
||||
'pageSize': 50,
|
||||
if (_statusFilter != null) 'status': _statusFilter,
|
||||
if (_search.isNotEmpty) 'search': _search,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
title: const Text('Vehicles'),
|
||||
bottom: PreferredSize(
|
||||
@@ -57,9 +68,7 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
||||
PopupMenuButton<String?>(
|
||||
icon: Icon(
|
||||
Icons.filter_list,
|
||||
color: _statusFilter != null
|
||||
? const Color(0xFF1A56DB)
|
||||
: null,
|
||||
color: _statusFilter != null ? const Color(0xFF1A56DB) : null,
|
||||
),
|
||||
onSelected: (v) => setState(() => _statusFilter = v),
|
||||
itemBuilder: (_) => [
|
||||
@@ -67,7 +76,9 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
||||
const PopupMenuItem(value: 'AVAILABLE', child: Text('Available')),
|
||||
const PopupMenuItem(value: 'RENTED', child: Text('Rented')),
|
||||
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
|
||||
? const Center(
|
||||
child: Text('No vehicles found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF))))
|
||||
child: Text(
|
||||
'No vehicles found',
|
||||
style: TextStyle(color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.invalidate(vehiclesProvider(_params)),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 1,
|
||||
mainAxisExtent: 240,
|
||||
mainAxisSpacing: 12,
|
||||
@@ -96,8 +109,8 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
|
||||
itemCount: res.data.length,
|
||||
itemBuilder: (_, i) => VehicleCard(
|
||||
vehicle: res.data[i],
|
||||
onTap: () => context
|
||||
.push('/dashboard/vehicles/${res.data[i].id}'),
|
||||
onTap: () =>
|
||||
context.push('/dashboard/vehicles/${res.data[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"appName": "RentalDriveGo",
|
||||
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
|
||||
"findYourCar": "ابحث عن سيارتك",
|
||||
"companySignIn": "تسجيل دخول لوحة تحكم الشركة",
|
||||
"companySignIn": "وكالة السيارات",
|
||||
"sameDropoff": "إرجاع نفس المكان",
|
||||
"differentDropoff": "إرجاع مكان آخر",
|
||||
"findCarToRent": "ابحث عن سيارة للإيجار",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"appName": "RentalDriveGo",
|
||||
"tagline": "The easiest way to find\nand reserve your perfect car",
|
||||
"findYourCar": "Find your car",
|
||||
"companySignIn": "Company dashboard sign in",
|
||||
"companySignIn": "Space Agency",
|
||||
"sameDropoff": "Same drop-off",
|
||||
"differentDropoff": "Different drop-off",
|
||||
"findCarToRent": "Find a car to rent",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"appName": "RentalDriveGo",
|
||||
"tagline": "La façon la plus simple de trouver\net réserver la voiture parfaite",
|
||||
"findYourCar": "Trouver ma voiture",
|
||||
"companySignIn": "Connexion tableau de bord entreprise",
|
||||
"companySignIn": "Espace Agence",
|
||||
"sameDropoff": "Retour même lieu",
|
||||
"differentDropoff": "Retour autre lieu",
|
||||
"findCarToRent": "Trouver une voiture à louer",
|
||||
|
||||
@@ -121,7 +121,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @companySignIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Company dashboard sign in'**
|
||||
/// **'Space Agency'**
|
||||
String get companySignIn;
|
||||
|
||||
/// No description provided for @sameDropoff.
|
||||
|
||||
@@ -18,7 +18,7 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
String get findYourCar => 'ابحث عن سيارتك';
|
||||
|
||||
@override
|
||||
String get companySignIn => 'تسجيل دخول لوحة تحكم الشركة';
|
||||
String get companySignIn => 'وكالة السيارات';
|
||||
|
||||
@override
|
||||
String get sameDropoff => 'إرجاع نفس المكان';
|
||||
|
||||
@@ -18,7 +18,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get findYourCar => 'Find your car';
|
||||
|
||||
@override
|
||||
String get companySignIn => 'Company dashboard sign in';
|
||||
String get companySignIn => 'Space Agency';
|
||||
|
||||
@override
|
||||
String get sameDropoff => 'Same drop-off';
|
||||
|
||||
@@ -19,7 +19,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get findYourCar => 'Trouver ma voiture';
|
||||
|
||||
@override
|
||||
String get companySignIn => 'Connexion tableau de bord entreprise';
|
||||
String get companySignIn => 'Espace Agence';
|
||||
|
||||
@override
|
||||
String get sameDropoff => 'Retour même lieu';
|
||||
|
||||
@@ -5,7 +5,13 @@ import '../core/providers/theme_provider.dart';
|
||||
|
||||
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).
|
||||
class PreferencesBar extends ConsumerWidget {
|
||||
final bool onDark;
|
||||
@@ -17,69 +23,100 @@ class PreferencesBar extends ConsumerWidget {
|
||||
final currentLocale = ref.watch(localeProvider);
|
||||
final themeMode = ref.watch(themeProvider);
|
||||
final isDark = themeMode == ThemeMode.dark;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final inactiveText = onDark
|
||||
? Colors.white.withValues(alpha: 0.55)
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final activeText = Colors.white;
|
||||
final inactiveBg = onDark
|
||||
final chipBg = onDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
|
||||
const langs = [
|
||||
('EN', Locale('en')),
|
||||
('FR', Locale('fr')),
|
||||
('AR', Locale('ar')),
|
||||
];
|
||||
: cs.surfaceContainerHighest;
|
||||
final iconColor = onDark
|
||||
? Colors.white.withValues(alpha: 0.8)
|
||||
: cs.onSurfaceVariant;
|
||||
final langCode = currentLocale.languageCode.toUpperCase();
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Language pills
|
||||
...langs.map((item) {
|
||||
final (code, locale) = item;
|
||||
final selected =
|
||||
currentLocale.languageCode == locale.languageCode;
|
||||
return GestureDetector(
|
||||
onTap: () => ref.read(localeProvider.notifier).setLocale(locale),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? _orange : inactiveBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: TextStyle(
|
||||
color: selected ? activeText : inactiveText,
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
selected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
// ── Language dropdown ──────────────────────────────────────────
|
||||
PopupMenuButton<Locale>(
|
||||
onSelected: (locale) =>
|
||||
ref.read(localeProvider.notifier).setLocale(locale),
|
||||
color: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
offset: const Offset(0, 36),
|
||||
itemBuilder: (_) => _langs.map((item) {
|
||||
final (code, locale) = item;
|
||||
final selected =
|
||||
currentLocale.languageCode == locale.languageCode;
|
||||
return PopupMenuItem<Locale>(
|
||||
value: locale,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
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(
|
||||
onTap: () => ref.read(themeProvider.notifier).toggle(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(7),
|
||||
decoration: BoxDecoration(
|
||||
color: inactiveBg,
|
||||
color: chipBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||||
size: 15,
|
||||
color: onDark
|
||||
? Colors.white.withValues(alpha: 0.8)
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -63,7 +63,7 @@ class ReservationCard extends StatelessWidget {
|
||||
size: 14, color: Color(0xFF9CA3AF)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'\$${reservation.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${(reservation.totalAmount / 100).toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF111928),
|
||||
|
||||
@@ -41,6 +41,8 @@ class StatusBadge extends StatelessWidget {
|
||||
return (const Color(0xFF1A56DB), const Color(0xFFE1EFFE), 'Active');
|
||||
case 'COMPLETED':
|
||||
return (const Color(0xFF5521B5), const Color(0xFFEDEBFE), 'Completed');
|
||||
case 'CLOSED':
|
||||
return (const Color(0xFF374151), const Color(0xFFF3F4F6), 'Closed');
|
||||
case 'CANCELLED':
|
||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Cancelled');
|
||||
case 'DRAFT':
|
||||
@@ -49,6 +51,10 @@ class StatusBadge extends StatelessWidget {
|
||||
return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'No Show');
|
||||
case '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':
|
||||
return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Paid');
|
||||
case 'FAILED':
|
||||
|
||||
@@ -55,7 +55,7 @@ class VehicleCard extends StatelessWidget {
|
||||
const Icon(Icons.attach_money,
|
||||
size: 16, color: Color(0xFF1A56DB)),
|
||||
Text(
|
||||
'${vehicle.dailyRate.toStringAsFixed(0)}/day',
|
||||
'\$${(vehicle.dailyRate / 100).toStringAsFixed(0)}/day',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1A56DB),
|
||||
|
||||
+112
@@ -105,6 +105,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -169,6 +177,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -219,6 +259,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -325,6 +373,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -20,6 +20,7 @@ dependencies:
|
||||
cached_network_image: ^3.4.1
|
||||
shimmer: ^3.0.0
|
||||
fl_chart: ^0.69.0
|
||||
image_picker: ^1.1.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user