add dashboard to phone app

This commit is contained in:
root
2026-05-25 05:10:43 -04:00
parent d04c8ce437
commit 277db06d26
67 changed files with 7872 additions and 570 deletions
+62
View File
@@ -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
+3
View File
@@ -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}"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"
#include "Generated.xcconfig"
+1 -2
View File
@@ -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'
+35
View File
@@ -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
+118 -1
View File
@@ -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
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+18 -4
View File
@@ -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';
+1 -1
View File
@@ -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':
+31
View File
@@ -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>?,
);
}
+68
View File
@@ -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() ??
[],
);
}
+62
View File
@@ -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?,
);
}
+6
View File
@@ -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?,
);
}
+63
View File
@@ -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,
};
}
+3
View File
@@ -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,
);
}
+92 -86
View File
@@ -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;
+15
View File
@@ -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>);
}
}
+9 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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>;
+293
View File
@@ -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>);
}
}
+37
View File
@@ -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>);
}
}
+3 -3
View File
@@ -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');
}
}
+41
View File
@@ -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');
}
}
+52
View File
@@ -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>);
}
}
+59 -1
View File
@@ -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>);
}
}
+16
View File
@@ -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>);
}
}
+62
View File
@@ -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');
}
}
+127 -1
View File
@@ -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,
),
],
),
),
);
}
+17 -6
View File
@@ -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,
);
}
}
+175 -19
View File
@@ -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
View File
@@ -3,7 +3,7 @@
"appName": "RentalDriveGo",
"tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية",
"findYourCar": "ابحث عن سيارتك",
"companySignIn": "تسجيل دخول لوحة تحكم الشركة",
"companySignIn": "وكالة السيارات",
"sameDropoff": "إرجاع نفس المكان",
"differentDropoff": "إرجاع مكان آخر",
"findCarToRent": "ابحث عن سيارة للإيجار",
+1 -1
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -18,7 +18,7 @@ class AppLocalizationsAr extends AppLocalizations {
String get findYourCar => 'ابحث عن سيارتك';
@override
String get companySignIn => 'تسجيل دخول لوحة تحكم الشركة';
String get companySignIn => 'وكالة السيارات';
@override
String get sameDropoff => 'إرجاع نفس المكان';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+81 -44
View File
@@ -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,
),
),
),
+1 -1
View File
@@ -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),
+6
View File
@@ -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':
+1 -1
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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: