diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..76cbffc --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index df5c145..5c58c1b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + /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", diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index eb4e2f2..0455d99 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -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'; diff --git a/lib/core/models/marketplace.dart b/lib/core/models/marketplace.dart index d253cbb..35e1041 100644 --- a/lib/core/models/marketplace.dart +++ b/lib/core/models/marketplace.dart @@ -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': diff --git a/lib/core/models/notification.dart b/lib/core/models/notification.dart new file mode 100644 index 0000000..388a562 --- /dev/null +++ b/lib/core/models/notification.dart @@ -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? 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 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?, + ); +} diff --git a/lib/core/models/offer.dart b/lib/core/models/offer.dart new file mode 100644 index 0000000..1e1245a --- /dev/null +++ b/lib/core/models/offer.dart @@ -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 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 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?) + ?.map((e) => e as String) + .toList() ?? + [], + ); +} diff --git a/lib/core/models/report.dart b/lib/core/models/report.dart new file mode 100644 index 0000000..389b929 --- /dev/null +++ b/lib/core/models/report.dart @@ -0,0 +1,62 @@ +class ReportSummary { + final int totalReservations; + final double rentalRevenue; + final double totalPaid; + final double totalOutstanding; + final List items; + + const ReportSummary({ + required this.totalReservations, + required this.rentalRevenue, + required this.totalPaid, + required this.totalOutstanding, + required this.items, + }); + + factory ReportSummary.fromJson(Map 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? ?? []) + .map((e) => ReportItem.fromJson(e as Map)) + .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 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?, + ); +} diff --git a/lib/core/models/reservation.dart b/lib/core/models/reservation.dart index a0b0744..3581380 100644 --- a/lib/core/models/reservation.dart +++ b/lib/core/models/reservation.dart @@ -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?, ); } diff --git a/lib/core/models/settings.dart b/lib/core/models/settings.dart new file mode 100644 index 0000000..2917bbf --- /dev/null +++ b/lib/core/models/settings.dart @@ -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 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 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, + }; +} diff --git a/lib/core/models/vehicle.dart b/lib/core/models/vehicle.dart index 339f728..35056c8 100644 --- a/lib/core/models/vehicle.dart +++ b/lib/core/models/vehicle.dart @@ -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, ); } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index fb5305a..93a8956 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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((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((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((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((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(authProvider, (_, _) => router.refresh()); return router; diff --git a/lib/core/services/analytics_service.dart b/lib/core/services/analytics_service.dart index 4ff17d9..ea0c24f 100644 --- a/lib/core/services/analytics_service.dart +++ b/lib/core/services/analytics_service.dart @@ -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); } + + Future 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); + } } diff --git a/lib/core/services/api_client.dart b/lib/core/services/api_client.dart index d8297fa..6037de2 100644 --- a/lib/core/services/api_client.dart +++ b/lib/core/services/api_client.dart @@ -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 patch(String path, {dynamic data}) => _dio.patch(path, data: data); + Future put(String path, {dynamic data}) => + _dio.put(path, data: data); + Future delete(String path) => _dio.delete(path); } diff --git a/lib/core/services/auth_service.dart b/lib/core/services/auth_service.dart index 3f36f6b..36c286d 100644 --- a/lib/core/services/auth_service.dart +++ b/lib/core/services/auth_service.dart @@ -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; diff --git a/lib/core/services/contract_service.dart b/lib/core/services/contract_service.dart new file mode 100644 index 0000000..f673f63 --- /dev/null +++ b/lib/core/services/contract_service.dart @@ -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 j) { + final parts = []; + 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 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 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 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 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 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 lineItems; + final int subtotal; + final int taxTotal; + final int total; + final int amountPaid; + final int balanceDue; + final String currency; + final List 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 j) { + final period = j['rentalPeriod'] as Map; + final invoice = j['invoice'] as Map; + final inspections = + j['inspections'] as Map? ?? {}; + final termsData = j['terms'] as Map? ?? {}; + + 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), + driver: + ContractDriver.fromJson(j['driver'] as Map), + vehicle: + ContractVehicle.fromJson(j['vehicle'] as Map), + 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? ?? []) + .map((e) => ContractLineItem.fromJson(e as Map)) + .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? ?? []) + .map((e) => ContractPayment.fromJson(e as Map)) + .toList(), + checkIn: inspections['checkIn'] != null + ? ContractInspection.fromJson( + inspections['checkIn'] as Map) + : null, + checkOut: inspections['checkOut'] != null + ? ContractInspection.fromJson( + inspections['checkOut'] as Map) + : null, + terms: termsData['terms'] as String?, + fuelPolicy: termsData['fuelPolicy'] as String?, + depositPolicy: termsData['depositPolicy'] as String?, + ); + } +} + +class ContractService { + final _client = ApiClient.instance; + + Future getContract(String reservationId) async { + final res = await _client.get('/reservations/$reservationId/contract'); + return ContractPayload.fromJson(res.data as Map); + } +} diff --git a/lib/core/services/customer_service.dart b/lib/core/services/customer_service.dart index 5421ed6..ec22a39 100644 --- a/lib/core/services/customer_service.dart +++ b/lib/core/services/customer_service.dart @@ -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); } + + Future updateCustomer(String id, Map data) async { + final res = await _client.patch('/customers/$id', data: data); + return Customer.fromJson(res.data as Map); + } + + Future flagCustomer(String id, String reason) async { + await _client.post('/customers/$id/flag', data: {'reason': reason}); + } + + Future unflagCustomer(String id) async { + await _client.delete('/customers/$id/flag'); + } + + Future 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); + } + + Future 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 && body.containsKey('data')) + ? body['data'] + : body; + return Customer.fromJson(payload as Map); + } } diff --git a/lib/core/services/marketplace_service.dart b/lib/core/services/marketplace_service.dart index c6dd716..c1f5e5f 100644 --- a/lib/core/services/marketplace_service.dart +++ b/lib/core/services/marketplace_service.dart @@ -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; diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart new file mode 100644 index 0000000..30f7170 --- /dev/null +++ b/lib/core/services/notification_service.dart @@ -0,0 +1,29 @@ +import '../models/notification.dart'; +import 'api_client.dart'; + +class NotificationService { + final _client = ApiClient.instance; + + Future> getNotifications({bool? unreadOnly}) async { + final res = await _client.get('/notifications/company', params: { + if (unreadOnly == true) 'unread': true, + }); + final list = res.data as List; + return list + .map((e) => AppNotification.fromJson(e as Map)) + .toList(); + } + + Future getUnreadCount() async { + final res = await _client.get('/notifications/unread-count'); + return (res.data as Map)['unread'] as int? ?? 0; + } + + Future markRead(String id) async { + await _client.post('/notifications/company/$id/read'); + } + + Future markAllRead() async { + await _client.post('/notifications/company/read-all'); + } +} diff --git a/lib/core/services/offer_service.dart b/lib/core/services/offer_service.dart new file mode 100644 index 0000000..aa9c53d --- /dev/null +++ b/lib/core/services/offer_service.dart @@ -0,0 +1,41 @@ +import '../models/offer.dart'; +import 'api_client.dart'; + +class OfferService { + final _client = ApiClient.instance; + + Future> getOffers() async { + final res = await _client.get('/offers'); + final list = res.data as List; + return list + .map((e) => CompanyOffer.fromJson(e as Map)) + .toList(); + } + + Future getOffer(String id) async { + final res = await _client.get('/offers/$id'); + return CompanyOffer.fromJson(res.data as Map); + } + + Future createOffer(Map data) async { + final res = await _client.post('/offers', data: data); + return CompanyOffer.fromJson(res.data as Map); + } + + Future updateOffer(String id, Map data) async { + final res = await _client.patch('/offers/$id', data: data); + return CompanyOffer.fromJson(res.data as Map); + } + + Future deleteOffer(String id) async { + await _client.delete('/offers/$id'); + } + + Future activateOffer(String id) async { + await _client.post('/offers/$id/activate'); + } + + Future deactivateOffer(String id) async { + await _client.post('/offers/$id/deactivate'); + } +} diff --git a/lib/core/services/payment_service.dart b/lib/core/services/payment_service.dart new file mode 100644 index 0000000..93781ae --- /dev/null +++ b/lib/core/services/payment_service.dart @@ -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 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> getPayments(String reservationId) async { + final res = await _client.get('/payments/reservations/$reservationId'); + final list = res.data as List; + return list + .map((e) => Payment.fromJson(e as Map)) + .toList(); + } + + Future recordManualPayment( + String reservationId, Map data) async { + final res = await _client.post( + '/payments/reservations/$reservationId/manual', + data: data); + return Payment.fromJson(res.data as Map); + } +} diff --git a/lib/core/services/reservation_service.dart b/lib/core/services/reservation_service.dart index d5bb887..d6a2802 100644 --- a/lib/core/services/reservation_service.dart +++ b/lib/core/services/reservation_service.dart @@ -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 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); } @@ -44,4 +80,26 @@ class ReservationService { Future checkout(String id, int mileage) async { await _client.post('/reservations/$id/checkout', data: {'mileage': mileage}); } + + Future closeReservation(String id) async { + final res = await _client.post('/reservations/$id/close'); + return Reservation.fromJson(res.data as Map); + } + + Future> getInspections(String id) async { + final res = await _client.get('/reservations/$id/inspections'); + final list = res.data as List; + return list + .map((e) => + ReservationInspection.fromJson(e as Map)) + .toList(); + } + + Future upsertInspection( + String id, String type, Map data) async { + final res = await _client.put( + '/reservations/$id/inspections/$type', + data: data); + return ReservationInspection.fromJson(res.data as Map); + } } diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart new file mode 100644 index 0000000..bf03f6f --- /dev/null +++ b/lib/core/services/settings_service.dart @@ -0,0 +1,16 @@ +import '../models/settings.dart'; +import 'api_client.dart'; + +class SettingsService { + final _client = ApiClient.instance; + + Future getBrand() async { + final res = await _client.get('/companies/me/brand'); + return BrandSettings.fromJson(res.data as Map); + } + + Future updateBrand(Map data) async { + final res = await _client.patch('/companies/me/brand', data: data); + return BrandSettings.fromJson(res.data as Map); + } +} diff --git a/lib/core/services/team_service.dart b/lib/core/services/team_service.dart new file mode 100644 index 0000000..236ed19 --- /dev/null +++ b/lib/core/services/team_service.dart @@ -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 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> getMembers() async { + final res = await _client.get('/team'); + final list = res.data as List; + return list + .map((e) => TeamMember.fromJson(e as Map)) + .toList(); + } + + Future inviteMember(Map data) async { + await _client.post('/team/invite', data: data); + } + + Future updateRole(String id, String role) async { + await _client.patch('/team/$id/role', data: {'role': role}); + } + + Future deactivateMember(String id) async { + await _client.post('/team/$id/deactivate'); + } + + Future reactivateMember(String id) async { + await _client.post('/team/$id/reactivate'); + } +} diff --git a/lib/core/services/vehicle_service.dart b/lib/core/services/vehicle_service.dart index 2504a64..a8301c7 100644 --- a/lib/core/services/vehicle_service.dart +++ b/lib/core/services/vehicle_service.dart @@ -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 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 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); @@ -34,7 +97,70 @@ class VehicleService { return Vehicle.fromJson(res.data as Map); } + Future deleteVehicle(String id) async { + await _client.delete('/vehicles/$id'); + } + Future togglePublish(String id, bool publish) async { await _client.patch('/vehicles/$id/publish', data: {'isPublished': publish}); } + + Future uploadPhotos(String id, List 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 && body.containsKey('data')) + ? body['data'] + : body; + return Vehicle.fromJson(payload as Map); + } + + Future deletePhoto(String id, int idx) async { + final res = await _client.delete('/vehicles/$id/photos/$idx'); + return Vehicle.fromJson(res.data as Map); + } + + Future> getMaintenance(String id) async { + final res = await _client.get('/vehicles/$id/maintenance'); + final list = res.data as List; + return list + .map((e) => MaintenanceLog.fromJson(e as Map)) + .toList(); + } + + Future addMaintenance( + String id, Map data) async { + final res = await _client.post('/vehicles/$id/maintenance', data: data); + return MaintenanceLog.fromJson(res.data as Map); + } + + Future> 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; + return list + .map((e) => CalendarEvent.fromJson(e as Map)) + .toList(); + } + + Future createCalendarBlock( + String id, Map data) async { + final res = + await _client.post('/vehicles/$id/calendar/blocks', data: data); + return CalendarEvent.fromJson(res.data as Map); + } + + Future deleteCalendarBlock(String id, String blockId) async { + await _client.delete('/vehicles/$id/calendar/blocks/$blockId'); + } } diff --git a/lib/features/auth/screens/employee_login_screen.dart b/lib/features/auth/screens/employee_login_screen.dart index 33cfe97..3a231ca 100644 --- a/lib/features/auth/screens/employee_login_screen.dart +++ b/lib/features/auth/screens/employee_login_screen.dart @@ -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 { _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 { 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 { controller: _passwordController, obscureText: _obscurePassword, textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.password], onFieldSubmitted: (_) => _login(), decoration: InputDecoration( labelText: 'Password', diff --git a/lib/features/client/screens/client_home_screen.dart b/lib/features/client/screens/client_home_screen.dart index 352a803..ca5604c 100644 --- a/lib/features/client/screens/client_home_screen.dart +++ b/lib/features/client/screens/client_home_screen.dart @@ -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 { 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 { ? 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 { } List _applyClientFilters( - List all, SearchParams params) { + List all, + SearchParams params, + ) { var result = all; final cats = params.categories; if (cats != null && cats.length > 1) { @@ -199,14 +210,14 @@ class _ClientHomeScreenState extends ConsumerState { 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 { 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 { // 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 { ) : 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 { 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 { 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 { ), ), + // ── 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 { 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, + ), + ], + ), + ), + ); } diff --git a/lib/features/client/screens/client_shell.dart b/lib/features/client/screens/client_shell.dart index 1bdba26..08dbe49 100644 --- a/lib/features/client/screens/client_shell.dart +++ b/lib/features/client/screens/client_shell.dart @@ -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) { diff --git a/lib/features/client/screens/my_bookings_screen.dart b/lib/features/client/screens/my_bookings_screen.dart index d750277..bf0b45c 100644 --- a/lib/features/client/screens/my_bookings_screen.dart +++ b/lib/features/client/screens/my_bookings_screen.dart @@ -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)), ), ], ), diff --git a/lib/features/dashboard/providers/dashboard_providers.dart b/lib/features/dashboard/providers/dashboard_providers.dart index e029bcc..544a9c2 100644 --- a/lib/features/dashboard/providers/dashboard_providers.dart +++ b/lib/features/dashboard/providers/dashboard_providers.dart @@ -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((ref) async { return ref.read(analyticsServiceProvider).getDashboard(); }); +final reportProvider = + FutureProvider.family>((ref, params) async { + return ref.read(analyticsServiceProvider).getReport( + startDate: params['startDate']!, + endDate: params['endDate']!, + status: params['status'], + ); +}); + +// ─── Vehicles ───────────────────────────────────────────────── + final vehiclesProvider = FutureProvider.family>( (ref, params) async { return ref.read(vehicleServiceProvider).getVehicles( @@ -32,6 +60,21 @@ final vehicleDetailProvider = FutureProvider.family((ref, id) { return ref.read(vehicleServiceProvider).getVehicle(id); }); +final maintenanceProvider = + FutureProvider.family, String>((ref, vehicleId) { + return ref.read(vehicleServiceProvider).getMaintenance(vehicleId); +}); + +typedef CalendarKey = ({String vehicleId, int year, int month}); + +final calendarProvider = + FutureProvider.family, CalendarKey>((ref, key) { + return ref.read(vehicleServiceProvider) + .getCalendar(key.vehicleId, key.year, key.month); +}); + +// ─── Reservations ───────────────────────────────────────────── + final reservationsProvider = FutureProvider.family>( (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, String>((ref, id) { + return ref.read(reservationServiceProvider).getInspections(id); +}); + +// ─── Customers ──────────────────────────────────────────────── + final customersProvider = FutureProvider.family>( (ref, params) async { @@ -64,3 +116,57 @@ final customerDetailProvider = FutureProvider.family((ref, id) { return ref.read(customerServiceProvider).getCustomer(id); }); + +// ─── Notifications ──────────────────────────────────────────── + +final notificationsProvider = + FutureProvider>((ref) async { + return ref.read(notificationServiceProvider).getNotifications(); +}); + +final unreadCountProvider = FutureProvider((ref) async { + return ref.read(notificationServiceProvider).getUnreadCount(); +}); + +// ─── Offers ─────────────────────────────────────────────────── + +final offersProvider = FutureProvider>((ref) async { + return ref.read(offerServiceProvider).getOffers(); +}); + +final offerDetailProvider = + FutureProvider.family((ref, id) { + return ref.read(offerServiceProvider).getOffer(id); +}); + +// ─── Settings ───────────────────────────────────────────────── + +final brandSettingsProvider = FutureProvider((ref) async { + return ref.read(settingsServiceProvider).getBrand(); +}); + +// ─── Payments ───────────────────────────────────────────────── + +final paymentServiceProvider = Provider((_) => PaymentService()); + +final reservationPaymentsProvider = + FutureProvider.family, String>((ref, reservationId) { + return ref.read(paymentServiceProvider).getPayments(reservationId); +}); + +// ─── Team ───────────────────────────────────────────────────── + +final teamServiceProvider = Provider((_) => TeamService()); + +final teamProvider = FutureProvider>((ref) { + return ref.read(teamServiceProvider).getMembers(); +}); + +// ─── Contracts ──────────────────────────────────────────────── + +final contractServiceProvider = Provider((_) => ContractService()); + +final contractProvider = + FutureProvider.family((ref, reservationId) { + return ref.read(contractServiceProvider).getContract(reservationId); +}); diff --git a/lib/features/dashboard/screens/contract_screen.dart b/lib/features/dashboard/screens/contract_screen.dart new file mode 100644 index 0000000..e79180a --- /dev/null +++ b/lib/features/dashboard/screens/contract_screen.dart @@ -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(); + } +} diff --git a/lib/features/dashboard/screens/contracts_screen.dart b/lib/features/dashboard/screens/contracts_screen.dart new file mode 100644 index 0000000..f6b2ced --- /dev/null +++ b/lib/features/dashboard/screens/contracts_screen.dart @@ -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 createState() => _ContractsScreenState(); +} + +class _ContractsScreenState extends ConsumerState { + 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, + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/customer_detail_screen.dart b/lib/features/dashboard/screens/customer_detail_screen.dart index aecadf8..bf2b52c 100644 --- a/lib/features/dashboard/screens/customer_detail_screen.dart +++ b/lib/features/dashboard/screens/customer_detail_screen.dart @@ -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( + MaterialPageRoute( + builder: (_) => + CustomerFormScreen(customer: customer)), + ); + if (result == true) { + ref.invalidate(customerDetailProvider(id)); + } + }, + ), + PopupMenuButton( + 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 _showFlagDialog( + BuildContext context, WidgetRef ref, bool isFlagged) async { + if (isFlagged) { + final confirmed = await showDialog( + 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( + 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 _showApproveLicenseDialog( + BuildContext context, WidgetRef ref) async { + final noteCtrl = TextEditingController(); + final confirmed = await showDialog( + 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 _showRejectLicenseDialog( + BuildContext context, WidgetRef ref) async { + final noteCtrl = TextEditingController(); + final confirmed = await showDialog( + 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, + ), + ); + } +} diff --git a/lib/features/dashboard/screens/customer_form_screen.dart b/lib/features/dashboard/screens/customer_form_screen.dart new file mode 100644 index 0000000..3248411 --- /dev/null +++ b/lib/features/dashboard/screens/customer_form_screen.dart @@ -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 createState() => _CustomerFormScreenState(); +} + +class _CustomerFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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 _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, + ), + ); + } +} diff --git a/lib/features/dashboard/screens/customers_screen.dart b/lib/features/dashboard/screens/customers_screen.dart index 4498c9a..45562f8 100644 --- a/lib/features/dashboard/screens/customers_screen.dart +++ b/lib/features/dashboard/screens/customers_screen.dart @@ -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 { } Map 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( + 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 { ), 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 { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ @@ -111,9 +124,11 @@ class _CustomersScreenState extends ConsumerState { ), 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), + ), ], ], ), diff --git a/lib/features/dashboard/screens/dashboard_shell.dart b/lib/features/dashboard/screens/dashboard_shell.dart index 3ee8b81..911941e 100644 --- a/lib/features/dashboard/screens/dashboard_shell.dart +++ b/lib/features/dashboard/screens/dashboard_shell.dart @@ -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, + ); + } +} diff --git a/lib/features/dashboard/screens/home_screen.dart b/lib/features/dashboard/screens/home_screen.dart index 8dc8bb1..f966d46 100644 --- a/lib/features/dashboard/screens/home_screen.dart +++ b/lib/features/dashboard/screens/home_screen.dart @@ -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(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( + onSelected: (value) async { + if (value == 'logout') { + final confirm = await showDialog( + 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( + 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( + 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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/new_reservation_screen.dart b/lib/features/dashboard/screens/new_reservation_screen.dart new file mode 100644 index 0000000..3c9bee7 --- /dev/null +++ b/lib/features/dashboard/screens/new_reservation_screen.dart @@ -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 createState() => + _NewReservationScreenState(); +} + +class _NewReservationScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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 _pickVehicle() async { + final vehicles = await ref + .read(vehicleServiceProvider) + .getVehicles(status: 'AVAILABLE', pageSize: 100); + if (!mounted) return; + final result = await showModalBottomSheet>( + 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 _pickCustomer() async { + final customers = + await ref.read(customerServiceProvider).getCustomers(pageSize: 100); + if (!mounted) return; + final result = await showModalBottomSheet>( + 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 _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)), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/notifications_screen.dart b/lib/features/dashboard/screens/notifications_screen.dart new file mode 100644 index 0000000..ef26766 --- /dev/null +++ b/lib/features/dashboard/screens/notifications_screen.dart @@ -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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/offers_screen.dart b/lib/features/dashboard/screens/offers_screen.dart new file mode 100644 index 0000000..e821726 --- /dev/null +++ b/lib/features/dashboard/screens/offers_screen.dart @@ -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( + 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( + 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( + 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(); + 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 _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 _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': [], + }); + 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( + 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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/online_reservations_screen.dart b/lib/features/dashboard/screens/online_reservations_screen.dart new file mode 100644 index 0000000..85eb29b --- /dev/null +++ b/lib/features/dashboard/screens/online_reservations_screen.dart @@ -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 _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( + 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))), + ], + ), + ), + ], + ); +} diff --git a/lib/features/dashboard/screens/reports_screen.dart b/lib/features/dashboard/screens/reports_screen.dart new file mode 100644 index 0000000..9f91cfe --- /dev/null +++ b/lib/features/dashboard/screens/reports_screen.dart @@ -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 createState() => _ReportsScreenState(); +} + +class _ReportsScreenState extends ConsumerState { + DateTime _start = DateTime.now().subtract(const Duration(days: 30)); + DateTime _end = DateTime.now(); + String? _statusFilter; + + Map 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 _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( + 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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/reservation_detail_screen.dart b/lib/features/dashboard/screens/reservation_detail_screen.dart index be48a53..53ab8c9 100644 --- a/lib/features/dashboard/screens/reservation_detail_screen.dart +++ b/lib/features/dashboard/screens/reservation_detail_screen.dart @@ -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( + 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( + 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( + 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), + ), + ), + ), + 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 _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( + Colors.white)), + ) + : Text(widget.existing == null + ? 'Record Inspection' + : 'Update Inspection'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/reservations_screen.dart b/lib/features/dashboard/screens/reservations_screen.dart index a55e719..4a5ad26 100644 --- a/lib/features/dashboard/screens/reservations_screen.dart +++ b/lib/features/dashboard/screens/reservations_screen.dart @@ -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 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 } Map _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( + 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 _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)), diff --git a/lib/features/dashboard/screens/settings_screen.dart b/lib/features/dashboard/screens/settings_screen.dart new file mode 100644 index 0000000..00af9ba --- /dev/null +++ b/lib/features/dashboard/screens/settings_screen.dart @@ -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(); + 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 _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 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, + ), + ); + } +} diff --git a/lib/features/dashboard/screens/team_screen.dart b/lib/features/dashboard/screens/team_screen.dart new file mode 100644 index 0000000..db965dd --- /dev/null +++ b/lib/features/dashboard/screens/team_screen.dart @@ -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( + 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 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( + 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(); + 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 _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( + 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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/vehicle_detail_screen.dart b/lib/features/dashboard/screens/vehicle_detail_screen.dart index c9ad330..48c2e11 100644 --- a/lib/features/dashboard/screens/vehicle_detail_screen.dart +++ b/lib/features/dashboard/screens/vehicle_detail_screen.dart @@ -1,16 +1,30 @@ +import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; import '../providers/dashboard_providers.dart'; import '../../../core/constants/app_constants.dart'; +import '../../../core/services/vehicle_service.dart'; import '../../../widgets/status_badge.dart'; import '../../../widgets/error_view.dart'; +import 'vehicle_form_screen.dart'; class VehicleDetailScreen extends ConsumerWidget { final String id; const VehicleDetailScreen({super.key, required this.id}); + String _resolveUrl(String photo) { + if (!photo.startsWith('http')) { + return '${AppConstants.baseUrl.replaceAll('/api/v1', '')}$photo'; + } + if (Platform.isAndroid) { + return photo.replaceFirst('localhost', '10.0.2.2'); + } + return photo; + } + @override Widget build(BuildContext context, WidgetRef ref) { final vehicleAsync = ref.watch(vehicleDetailProvider(id)); @@ -27,128 +41,414 @@ class VehicleDetailScreen extends ConsumerWidget { ), ), data: (vehicle) { - final baseUrl = - AppConstants.baseUrl.replaceAll('/api/v1', ''); - return Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: 240, - pinned: true, - flexibleSpace: FlexibleSpaceBar( - background: vehicle.primaryPhoto != null - ? CachedNetworkImage( - imageUrl: vehicle.primaryPhoto!.startsWith('http') - ? vehicle.primaryPhoto! - : '$baseUrl${vehicle.primaryPhoto!}', - fit: BoxFit.cover, - errorWidget: (_, _, _) => Container( - color: const Color(0xFFF3F4F6), - child: const Icon(Icons.directions_car, - size: 72, color: Color(0xFFD1D5DB)), + return DefaultTabController( + length: 3, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverAppBar( + expandedHeight: 240, + pinned: true, + actions: [ + IconButton( + icon: const Icon(Icons.add_photo_alternate_outlined), + onPressed: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => VehiclePhotosSheet( + vehicleId: id, + currentPhotos: vehicle.photos + .map((p) => _resolveUrl(p)) + .toList(), ), - ) - : Container( - color: const Color(0xFFF3F4F6), - child: const Icon(Icons.directions_car, - size: 72, color: Color(0xFFD1D5DB)), - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.all(20), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Row( - children: [ - Expanded( - child: Text( - vehicle.displayName, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Color(0xFF111928), - ), - ), - ), - StatusBadge(status: vehicle.status), - ], + ); + if (result == true) { + ref.invalidate(vehicleDetailProvider(id)); + } + }, ), - const SizedBox(height: 8), - Text( - vehicle.licensePlate, - style: const TextStyle( - fontSize: 15, color: Color(0xFF6B7280)), + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + VehicleFormScreen(vehicle: vehicle)), + ); + if (result == true) { + ref.invalidate(vehicleDetailProvider(id)); + } + }, ), - const SizedBox(height: 20), - _InfoGrid(vehicle: vehicle), - const SizedBox(height: 20), - const Text( - 'Pricing', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF111928), - ), - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.attach_money, - color: Color(0xFF1A56DB)), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '\$${vehicle.dailyRate.toStringAsFixed(2)}/day', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Color(0xFF1A56DB), - ), - ), - const Text( - 'Daily rate', - style: TextStyle( - fontSize: 12, - color: Color(0xFF6B7280)), + PopupMenuButton( + onSelected: (v) async { + if (v == 'delete') { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete Vehicle'), + content: Text( + 'Delete ${vehicle.displayName}? 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'), ), ], ), - const Spacer(), - Switch( - value: vehicle.isPublished, - onChanged: (v) async { - try { - await ref - .read(vehicleServiceProvider) - .togglePublish(id, v); - ref.invalidate(vehicleDetailProvider(id)); - } catch (_) {} - }, - ), - const Text( - 'Published', - style: TextStyle( - fontSize: 12, color: Color(0xFF6B7280)), - ), - ], + ); + if (confirmed == true && context.mounted) { + await ref + .read(vehicleServiceProvider) + .deleteVehicle(id); + if (context.mounted) Navigator.of(context).pop(); + } + } + }, + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'delete', + child: Text('Delete', + style: TextStyle(color: Color(0xFFE02424))), ), - ), + ], ), - ]), + ], + flexibleSpace: FlexibleSpaceBar( + background: vehicle.primaryPhoto != null + ? CachedNetworkImage( + imageUrl: _resolveUrl(vehicle.primaryPhoto!), + fit: BoxFit.cover, + errorWidget: (context, url, _) => _placeholder(), + ) + : _placeholder(), + ), ), + SliverPersistentHeader( + pinned: true, + delegate: _TabBarDelegate( + TabBar( + tabs: const [ + Tab(text: 'Details'), + Tab(text: 'Calendar'), + Tab(text: 'Maintenance'), + ], + ), + ), + ), + ], + body: TabBarView( + children: [ + _DetailsTab( + vehicle: vehicle, + onPublishChanged: (v) async { + await ref + .read(vehicleServiceProvider) + .togglePublish(id, v); + ref.invalidate(vehicleDetailProvider(id)); + }, + ), + _CalendarTab(vehicleId: id), + _MaintenanceTab(vehicleId: id), + ], ), - ], + ), ), ); }, ); } + + Widget _placeholder() => Container( + color: const Color(0xFFF3F4F6), + child: const Icon(Icons.directions_car, + size: 72, color: Color(0xFFD1D5DB)), + ); +} + +class _TabBarDelegate extends SliverPersistentHeaderDelegate { + final TabBar tabBar; + + _TabBarDelegate(this.tabBar); + + @override + double get minExtent => tabBar.preferredSize.height; + @override + double get maxExtent => tabBar.preferredSize.height; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) => + Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: tabBar, + ); + + @override + bool shouldRebuild(_TabBarDelegate old) => false; +} + +class _DetailsTab extends StatelessWidget { + final dynamic vehicle; + final ValueChanged onPublishChanged; + + const _DetailsTab({ + required this.vehicle, + required this.onPublishChanged, + }); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(20), + children: [ + Row( + children: [ + Expanded( + child: Text( + vehicle.displayName, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF111928), + ), + ), + ), + StatusBadge(status: vehicle.status), + ], + ), + const SizedBox(height: 8), + Text( + vehicle.licensePlate, + style: + const TextStyle(fontSize: 15, color: Color(0xFF6B7280)), + ), + const SizedBox(height: 20), + _InfoGrid(vehicle: vehicle), + const SizedBox(height: 20), + const Text( + 'Pricing', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF111928), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.attach_money, color: Color(0xFF1A56DB)), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\$${(vehicle.dailyRate / 100).toStringAsFixed(2)}/day', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF1A56DB), + ), + ), + const Text( + 'Daily rate', + style: TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ), + const Spacer(), + Switch( + value: vehicle.isPublished, + onChanged: onPublishChanged, + ), + const Text( + 'Published', + style: TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _MaintenanceTab extends ConsumerWidget { + final String vehicleId; + + const _MaintenanceTab({required this.vehicleId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(maintenanceProvider(vehicleId)); + + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Could not load maintenance logs', + onRetry: () => ref.invalidate(maintenanceProvider(vehicleId)), + ), + data: (logs) => Stack( + children: [ + logs.isEmpty + ? const Center( + child: Text('No maintenance records', + style: TextStyle(color: Color(0xFF9CA3AF))), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: logs.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) => _MaintenanceCard(log: logs[i]), + ), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.small( + onPressed: () => + _showAddMaintenance(context, ref, vehicleId), + child: const Icon(Icons.add), + ), + ), + ], + ), + ); + } + + void _showAddMaintenance( + BuildContext context, WidgetRef ref, String vehicleId) { + final typeCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final costCtrl = TextEditingController(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => 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: [ + const Text('Add Maintenance Log', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + TextField( + controller: typeCtrl, + decoration: + const InputDecoration(labelText: 'Type (e.g. Oil Change)')), + const SizedBox(height: 12), + TextField( + controller: descCtrl, + decoration: + const InputDecoration(labelText: 'Description'), + maxLines: 2), + const SizedBox(height: 12), + TextField( + controller: costCtrl, + decoration: + const InputDecoration(labelText: 'Cost (\$)'), + keyboardType: TextInputType.number), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () async { + if (typeCtrl.text.isEmpty) return; + await ref.read(vehicleServiceProvider).addMaintenance( + vehicleId, + { + 'type': typeCtrl.text.trim(), + if (descCtrl.text.isNotEmpty) + 'description': descCtrl.text.trim(), + if (costCtrl.text.isNotEmpty) + 'cost': ((double.tryParse(costCtrl.text) ?? 0) * + 100) + .round(), + 'performedAt': DateTime.now().toIso8601String(), + }, + ); + ref.invalidate(maintenanceProvider(vehicleId)); + if (context.mounted) Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48)), + child: const Text('Add Log'), + ), + ], + ), + ), + ), + ); + } +} + +class _MaintenanceCard extends StatelessWidget { + final dynamic log; + + const _MaintenanceCard({required this.log}); + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + log.type as String, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + if (log.cost != null) + Text( + '\$${((log.cost as int) / 100).toStringAsFixed(2)}', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF1A56DB)), + ), + ], + ), + if (log.description != null) ...[ + const SizedBox(height: 4), + Text( + log.description as String, + style: const TextStyle( + fontSize: 13, color: Color(0xFF6B7280)), + ), + ], + const SizedBox(height: 6), + Text( + fmt.format(log.performedAt as DateTime), + style: + const TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + ); + } } class _InfoGrid extends StatelessWidget { @@ -161,10 +461,10 @@ class _InfoGrid extends StatelessWidget { final items = [ ('Category', vehicle.category), if (vehicle.seats != null) ('Seats', '${vehicle.seats}'), - if (vehicle.transmission != null) ('Transmission', vehicle.transmission!), + if (vehicle.transmission != null) + ('Transmission', vehicle.transmission!), if (vehicle.fuelType != null) ('Fuel', vehicle.fuelType!), - if (vehicle.mileage != null) - ('Mileage', '${vehicle.mileage} km'), + if (vehicle.mileage != null) ('Mileage', '${vehicle.mileage} km'), ]; return Wrap( @@ -201,3 +501,563 @@ class _InfoGrid extends StatelessWidget { ); } } + +// ─── Calendar Tab ───────────────────────────────────────────────────────────── + +class _CalendarTab extends ConsumerStatefulWidget { + final String vehicleId; + + const _CalendarTab({required this.vehicleId}); + + @override + ConsumerState<_CalendarTab> createState() => _CalendarTabState(); +} + +class _CalendarTabState extends ConsumerState<_CalendarTab> + with AutomaticKeepAliveClientMixin { + late int _year; + late int _month; + DateTime? _selectedDay; + + static const _eventColors = { + 'RESERVATION': Color(0xFF1A56DB), + 'MAINTENANCE': Color(0xFFFF6B00), + 'BLOCK': Color(0xFFE02424), + }; + + static const _eventBg = { + 'RESERVATION': Color(0xFFEBF5FF), + 'MAINTENANCE': Color(0xFFFFF3E0), + 'BLOCK': Color(0xFFFDE8E8), + }; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _year = now.year; + _month = now.month; + } + + void _prevMonth() => setState(() { + if (_month == 1) { + _month = 12; + _year--; + } else { + _month--; + } + _selectedDay = null; + }); + + void _nextMonth() => setState(() { + if (_month == 12) { + _month = 1; + _year++; + } else { + _month++; + } + _selectedDay = null; + }); + + bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + List _eventsForDay( + DateTime day, List events) { + final d = DateTime(day.year, day.month, day.day); + return events.where((e) { + final s = DateTime(e.startDate.year, e.startDate.month, e.startDate.day); + final en = DateTime(e.endDate.year, e.endDate.month, e.endDate.day); + return !d.isBefore(s) && !d.isAfter(en); + }).toList(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final key = (vehicleId: widget.vehicleId, year: _year, month: _month); + final eventsAsync = ref.watch(calendarProvider(key)); + + return Stack( + children: [ + eventsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Could not load calendar', + onRetry: () => ref.invalidate(calendarProvider(key)), + ), + data: (events) => ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 80), + children: [ + // Month navigation + Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _prevMonth, + ), + Expanded( + child: Text( + DateFormat('MMMM yyyy').format(DateTime(_year, _month)), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF111928), + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _nextMonth, + ), + ], + ), + const SizedBox(height: 4), + // Day-of-week headers + Row( + children: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] + .map((d) => Expanded( + child: Center( + child: Text(d, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF9CA3AF), + )), + ), + )) + .toList(), + ), + const SizedBox(height: 4), + // Calendar grid + _buildGrid(events), + const SizedBox(height: 12), + // Legend + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: _eventColors.entries.expand((entry) => [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: entry.value, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + entry.key[0] + entry.key.substring(1).toLowerCase(), + style: const TextStyle( + fontSize: 11, color: Color(0xFF6B7280)), + ), + const SizedBox(width: 14), + ]).toList(), + ), + const SizedBox(height: 16), + // Selected-day events + if (_selectedDay != null) ...[ + Text( + DateFormat('EEEE, MMMM d').format(_selectedDay!), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 10), + ..._eventsForDay(_selectedDay!, events).map((e) => _EventTile( + event: e, + color: _eventColors[e.type] ?? Colors.grey, + bg: _eventBg[e.type] ?? const Color(0xFFF3F4F6), + onDelete: e.type == 'BLOCK' + ? () async { + await ref + .read(vehicleServiceProvider) + .deleteCalendarBlock( + widget.vehicleId, e.id); + ref.invalidate(calendarProvider(key)); + } + : null, + )), + if (_eventsForDay(_selectedDay!, events).isEmpty) + const Text( + 'No events on this day', + style: TextStyle(color: Color(0xFF9CA3AF)), + ), + ], + ], + ), + ), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.small( + heroTag: 'cal_add', + tooltip: 'Block dates', + onPressed: () => _showAddBlock(context), + child: const Icon(Icons.block), + ), + ), + ], + ); + } + + Widget _buildGrid(List events) { + final daysInMonth = DateTime(_year, _month + 1, 0).day; + final firstDay = DateTime(_year, _month, 1); + final startOffset = (firstDay.weekday - 1) % 7; + + final cells = [ + for (int i = 0; i < startOffset; i++) const SizedBox(), + for (int day = 1; day <= daysInMonth; day++) + _buildDayCell(day, events), + ]; + + while (cells.length % 7 != 0) { + cells.add(const SizedBox()); + } + + return GridView.count( + crossAxisCount: 7, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: cells, + ); + } + + Widget _buildDayCell(int day, List events) { + final date = DateTime(_year, _month, day); + final dayEvents = _eventsForDay(date, events); + final isSelected = + _selectedDay != null && _isSameDay(_selectedDay!, date); + final isToday = _isSameDay(DateTime.now(), date); + + return GestureDetector( + onTap: () => setState( + () => _selectedDay = isSelected ? null : date), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF1A56DB) + : isToday + ? const Color(0xFFEBF5FF) + : null, + borderRadius: BorderRadius.circular(6), + border: isToday && !isSelected + ? Border.all(color: const Color(0xFF1A56DB)) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$day', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected + ? Colors.white + : isToday + ? const Color(0xFF1A56DB) + : const Color(0xFF374151), + ), + ), + if (dayEvents.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: dayEvents + .take(3) + .map((e) => Container( + width: 4, + height: 4, + margin: + const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: isSelected + ? Colors.white70 + : (_eventColors[e.type] ?? + Colors.grey), + shape: BoxShape.circle, + ), + )) + .toList(), + ), + ), + ], + ), + ), + ); + } + + void _showAddBlock(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _AddBlockSheet( + vehicleId: widget.vehicleId, + initialStart: _selectedDay ?? DateTime.now(), + onSaved: () { + final key = + (vehicleId: widget.vehicleId, year: _year, month: _month); + ref.invalidate(calendarProvider(key)); + }, + ), + ); + } +} + +class _EventTile extends StatelessWidget { + final CalendarEvent event; + final Color color; + final Color bg; + final VoidCallback? onDelete; + + const _EventTile({ + required this.event, + required this.color, + required this.bg, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d'); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Container( + width: 4, + height: 36, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.label, + style: TextStyle( + fontWeight: FontWeight.w600, + color: color, + fontSize: 13, + ), + ), + Text( + '${fmt.format(event.startDate)} → ${fmt.format(event.endDate)}', + style: const TextStyle( + fontSize: 11, color: Color(0xFF6B7280)), + ), + if (event.status != null) + Text( + event.status!, + style: const TextStyle( + fontSize: 11, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + if (onDelete != null) + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + color: const Color(0xFFE02424), + onPressed: onDelete, + ), + ], + ), + ); + } +} + +class _AddBlockSheet extends ConsumerStatefulWidget { + final String vehicleId; + final DateTime initialStart; + final VoidCallback onSaved; + + const _AddBlockSheet({ + required this.vehicleId, + required this.initialStart, + required this.onSaved, + }); + + @override + ConsumerState<_AddBlockSheet> createState() => _AddBlockSheetState(); +} + +class _AddBlockSheetState extends ConsumerState<_AddBlockSheet> { + late DateTime _start; + late DateTime _end; + final _reasonCtrl = TextEditingController(); + String _blockType = 'MANUAL'; + bool _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + _start = widget.initialStart; + _end = widget.initialStart.add(const Duration(days: 1)); + } + + @override + void dispose() { + _reasonCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + try { + await ref.read(vehicleServiceProvider).createCalendarBlock( + widget.vehicleId, + { + 'startDate': + '${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z', + 'endDate': + '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z', + 'type': _blockType, + if (_reasonCtrl.text.trim().isNotEmpty) + 'reason': _reasonCtrl.text.trim(), + }, + ); + widget.onSaved(); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _saving = false; + _error = 'Failed to create block. Please try again.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + final far = DateTime.now().add(const Duration(days: 365 * 2)); + final near = DateTime.now().subtract(const Duration(days: 365)); + + 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: [ + const Text('Block Dates', + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 14), + label: Text(fmt.format(_start)), + onPressed: () async { + final d = await showDatePicker( + context: context, + initialDate: _start, + firstDate: near, + lastDate: far, + ); + if (d != null) { + setState(() { + _start = d; + if (_end.isBefore(_start)) _end = _start; + }); + } + }, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('→'), + ), + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 14), + label: Text(fmt.format(_end)), + onPressed: () async { + final d = await showDatePicker( + context: context, + initialDate: _end, + firstDate: _start, + lastDate: far, + ); + if (d != null) setState(() => _end = d); + }, + ), + ), + ], + ), + const SizedBox(height: 12), + InputDecorator( + decoration: const InputDecoration(labelText: 'Block Type'), + child: DropdownButton( + value: _blockType, + isExpanded: true, + underline: const SizedBox(), + items: const [ + DropdownMenuItem( + value: 'MANUAL', child: Text('Manual Block')), + DropdownMenuItem( + value: 'MAINTENANCE', child: Text('Maintenance')), + ], + onChanged: (v) => setState(() => _blockType = v!), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _reasonCtrl, + decoration: + const InputDecoration(labelText: 'Reason (optional)'), + ), + 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(Colors.white)), + ) + : const Text('Block Dates'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/vehicle_form_screen.dart b/lib/features/dashboard/screens/vehicle_form_screen.dart new file mode 100644 index 0000000..79f4b2a --- /dev/null +++ b/lib/features/dashboard/screens/vehicle_form_screen.dart @@ -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 createState() => _VehicleFormScreenState(); +} + +class _VehicleFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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 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 items, + ValueChanged onChanged) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: InputDecorator( + decoration: InputDecoration(labelText: label), + child: DropdownButton( + 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 currentPhotos; + + const VehiclePhotosSheet({ + super.key, + required this.vehicleId, + required this.currentPhotos, + }); + + @override + ConsumerState createState() => _VehiclePhotosSheetState(); +} + +class _VehiclePhotosSheetState extends ConsumerState { + bool _uploading = false; + + Future _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 _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), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/vehicles_screen.dart b/lib/features/dashboard/screens/vehicles_screen.dart index 6233bff..1bd9969 100644 --- a/lib/features/dashboard/screens/vehicles_screen.dart +++ b/lib/features/dashboard/screens/vehicles_screen.dart @@ -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 { } Map 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( + 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 { PopupMenuButton( 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 { 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 { ), 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 { 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}'), ), ), ), diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 671acaa..0ef463d 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -3,7 +3,7 @@ "appName": "RentalDriveGo", "tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية", "findYourCar": "ابحث عن سيارتك", - "companySignIn": "تسجيل دخول لوحة تحكم الشركة", + "companySignIn": "وكالة السيارات", "sameDropoff": "إرجاع نفس المكان", "differentDropoff": "إرجاع مكان آخر", "findCarToRent": "ابحث عن سيارة للإيجار", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1a912dc..f8189cd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 41efe30..cbf495a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index de5ed8a..d48db3c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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. diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 3eb23ac..a2f6a45 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -18,7 +18,7 @@ class AppLocalizationsAr extends AppLocalizations { String get findYourCar => 'ابحث عن سيارتك'; @override - String get companySignIn => 'تسجيل دخول لوحة تحكم الشركة'; + String get companySignIn => 'وكالة السيارات'; @override String get sameDropoff => 'إرجاع نفس المكان'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 02658f5..c4b0a38 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 6fd86d8..7df8f66 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/widgets/preferences_bar.dart b/lib/widgets/preferences_bar.dart index 906cb60..161e9ee 100644 --- a/lib/widgets/preferences_bar.dart +++ b/lib/widgets/preferences_bar.dart @@ -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( + 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( + 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, ), ), ), diff --git a/lib/widgets/reservation_card.dart b/lib/widgets/reservation_card.dart index 5b8dce6..ba9d806 100644 --- a/lib/widgets/reservation_card.dart +++ b/lib/widgets/reservation_card.dart @@ -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), diff --git a/lib/widgets/status_badge.dart b/lib/widgets/status_badge.dart index a45e467..f4b9470 100644 --- a/lib/widgets/status_badge.dart +++ b/lib/widgets/status_badge.dart @@ -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': diff --git a/lib/widgets/vehicle_card.dart b/lib/widgets/vehicle_card.dart index 4dc67ee..d79ed49 100644 --- a/lib/widgets/vehicle_card.dart +++ b/lib/widgets/vehicle_card.dart @@ -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), diff --git a/pubspec.lock b/pubspec.lock index 61e18c9..0bfdd2d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 98592f8..468b9ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: