diff --git a/.gitignore b/.gitignore index 3820a95..ed76329 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Environment configs (contain URLs — do not commit) +env/ + # Miscellaneous *.class *.log diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..d08cc9c 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,6 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/build_prod_andr.md b/build_prod_andr.md new file mode 100644 index 0000000..cc4e006 --- /dev/null +++ b/build_prod_andr.md @@ -0,0 +1,17 @@ +Run it from the project root: + + ./build_prod_apk.sh + + The APK lands at build/outputs/rentaldrivego-release.apk. + + One thing to sort out before distributing — the build.gradle.kts currently signs with the debug + key in release mode: + signingConfig = signingConfigs.getByName("debug") + That's fine for internal testing, but app stores and most MDM systems require a proper release + keystore. When you're ready to publish: + + 1. Generate a keystore once: + keytool -genkey -v -keystore android/rentaldrivego.jks \ + -alias rentaldrivego -keyalg RSA -keysize 2048 -validity 10000 + 2. Add the signing config to android/app/build.gradle.kts pointing at that keystore + 3. Add android/rentaldrivego.jks to .gitignore \ No newline at end of file diff --git a/build_prod_apk.sh b/build_prod_apk.sh new file mode 100755 index 0000000..a78c64f --- /dev/null +++ b/build_prod_apk.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── Config ──────────────────────────────────────────────────────────────────── +ENV_FILE="env/prod.json" +OUTPUT_DIR="build/outputs" +APK_SRC="build/app/outputs/flutter-apk/app-release.apk" +APK_DEST="$OUTPUT_DIR/rentaldrivego-release.apk" + +# ── Checks ──────────────────────────────────────────────────────────────────── +cd "$(dirname "$0")" + +if ! command -v flutter &>/dev/null; then + echo "❌ flutter not found in PATH" + exit 1 +fi + +if [[ ! -f "$ENV_FILE" ]]; then + echo "❌ $ENV_FILE not found — create it with production API URLs" + exit 1 +fi + +# ── Build ───────────────────────────────────────────────────────────────────── +echo "▶ Cleaning previous build..." +flutter clean + +echo "▶ Fetching dependencies..." +flutter pub get + +echo "▶ Building release APK (production)..." +flutter build apk --release --dart-define-from-file="$ENV_FILE" + +# ── Copy to output ──────────────────────────────────────────────────────────── +mkdir -p "$OUTPUT_DIR" +cp "$APK_SRC" "$APK_DEST" + +echo "" +echo "✅ Build complete!" +echo " APK → $APK_DEST" +echo " Size: $(du -sh "$APK_DEST" | cut -f1)" diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 0455d99..25dc943 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -3,21 +3,39 @@ import 'dart:io'; class AppConstants { 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'; + static const String dashboardUrlOverride = + String.fromEnvironment('DASHBOARD_URL'); + + // Production + static const String productionApiUrl = 'https://api.rentaldrivego.ma/api/v1'; + static const String productionDashboardUrl = 'https://rentaldrivego.ma/dashboard'; + + // Development + static const String devAndroidApiUrl = 'http://10.0.2.2:4000/api/v1'; + static const String devLocalApiUrl = 'http://localhost:4000/api/v1'; + static const String devAndroidDashboardUrl = 'http://10.0.2.2:3001'; + static const String devLocalDashboardUrl = 'http://localhost:3001'; - // 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 (apiBaseUrlOverride.isNotEmpty) return apiBaseUrlOverride; + if (Platform.isAndroid) return devAndroidApiUrl; + return devLocalApiUrl; + } - if (Platform.isAndroid) { - return developmentAndroidApiUrl; - } + static String get dashboardBaseUrl { + if (dashboardUrlOverride.isNotEmpty) return dashboardUrlOverride; + if (Platform.isAndroid) return devAndroidDashboardUrl; + return devLocalDashboardUrl; + } - return developmentLocalApiUrl; + static String resolveMediaUrl(String url) { + if (!url.startsWith('http')) { + return '${baseUrl.replaceAll('/api/v1', '')}$url'; + } + if (Platform.isAndroid && url.contains('localhost')) { + return url.replaceFirst('localhost', '10.0.2.2'); + } + return url; } static const String tokenKey = 'auth_token'; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 93a8956..4e024df 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -10,10 +10,13 @@ import '../../features/auth/screens/employee_login_screen.dart'; import '../../features/dashboard/screens/home_screen.dart'; import '../../features/dashboard/screens/vehicles_screen.dart'; import '../../features/dashboard/screens/vehicle_detail_screen.dart'; +import '../../features/dashboard/screens/vehicle_form_screen.dart'; import '../../features/dashboard/screens/reservations_screen.dart'; import '../../features/dashboard/screens/reservation_detail_screen.dart'; +import '../../features/dashboard/screens/new_reservation_screen.dart'; import '../../features/dashboard/screens/customers_screen.dart'; import '../../features/dashboard/screens/customer_detail_screen.dart'; +import '../../features/dashboard/screens/customer_form_screen.dart'; import '../../features/dashboard/screens/notifications_screen.dart'; import '../../features/dashboard/screens/online_reservations_screen.dart'; import '../../features/dashboard/screens/contract_screen.dart'; @@ -25,7 +28,10 @@ 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'; +import '../../features/client/screens/booking_confirmation_screen.dart'; import '../../features/client/screens/my_bookings_screen.dart'; +import '../../features/client/models/booking_result.dart'; +import '../../features/agency_webview/screens/agency_dashboard_webview_screen.dart'; final routerProvider = Provider((ref) { final router = GoRouter( @@ -52,7 +58,13 @@ final routerProvider = Provider((ref) { } if (status == AuthStatus.unauthenticated) { - if (isLanding || isLogin || loc.startsWith('/client')) return null; + if (isLanding || + isLogin || + loc.startsWith('/client') || + loc == '/role' || + loc == '/agency') { + return null; + } return '/landing'; } @@ -156,6 +168,42 @@ final routerProvider = Provider((ref) { vehicle: state.extra as MarketplaceVehicle?, ), ), + GoRoute( + path: '/booking-confirmation', + builder: (_, state) { + final result = state.extra as BookingResult?; + if (result == null) return const ClientHomeScreen(); + return BookingConfirmationScreen(result: result); + }, + ), + GoRoute( + path: '/agency', + builder: (context, _) => const AgencyDashboardWebViewScreen(), + ), + GoRoute( + path: '/dashboard/reservations/new', + builder: (context, _) => const NewReservationScreen(), + ), + GoRoute( + path: '/dashboard/vehicles/new', + builder: (context, _) => const VehicleFormScreen(), + ), + GoRoute( + path: '/dashboard/vehicles/:id/edit', + builder: (_, state) => VehicleFormScreen( + vehicle: state.extra as dynamic, + ), + ), + GoRoute( + path: '/dashboard/customers/new', + builder: (context, _) => const CustomerFormScreen(), + ), + GoRoute( + path: '/dashboard/customers/:id/edit', + builder: (_, state) => CustomerFormScreen( + customer: state.extra as dynamic, + ), + ), ], ); diff --git a/lib/core/services/api_client.dart b/lib/core/services/api_client.dart index 6037de2..a3ff994 100644 --- a/lib/core/services/api_client.dart +++ b/lib/core/services/api_client.dart @@ -34,6 +34,12 @@ class ApiClient { handler.next(response); }, onError: (e, handler) { + // Log API errors to help diagnose issues during development. + final resp = e.response; + if (resp != null) { + // ignore: avoid_print + print('[API] ${e.requestOptions.method} ${e.requestOptions.path} → ${resp.statusCode}: ${resp.data}'); + } handler.next(e); }, )); diff --git a/lib/core/services/marketplace_service.dart b/lib/core/services/marketplace_service.dart index c1f5e5f..11e449d 100644 --- a/lib/core/services/marketplace_service.dart +++ b/lib/core/services/marketplace_service.dart @@ -74,7 +74,8 @@ class MarketplaceService { required String firstName, required String lastName, required String email, - String? phone, + required String phone, + required String driverLicense, required DateTime startDate, required DateTime endDate, String? notes, @@ -86,7 +87,8 @@ class MarketplaceService { 'firstName': firstName, 'lastName': lastName, 'email': email, - if (phone != null && phone.isNotEmpty) 'phone': phone, + 'phone': phone, + 'driverLicense': driverLicense, 'startDate': '${startDate.toIso8601String().substring(0, 10)}T00:00:00.000Z', 'endDate': '${endDate.toIso8601String().substring(0, 10)}T00:00:00.000Z', if (notes != null && notes.isNotEmpty) 'notes': notes, diff --git a/lib/features/agency_webview/screens/agency_dashboard_webview_screen.dart b/lib/features/agency_webview/screens/agency_dashboard_webview_screen.dart new file mode 100644 index 0000000..744ed16 --- /dev/null +++ b/lib/features/agency_webview/screens/agency_dashboard_webview_screen.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import '../../../core/constants/app_constants.dart'; + +class AgencyDashboardWebViewScreen extends StatefulWidget { + const AgencyDashboardWebViewScreen({super.key}); + + @override + State createState() => + _AgencyDashboardWebViewScreenState(); +} + +class _AgencyDashboardWebViewScreenState + extends State { + late final WebViewController _controller; + bool _isLoading = true; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (_) => setState(() { + _isLoading = true; + _hasError = false; + }), + onPageFinished: (_) => setState(() => _isLoading = false), + onWebResourceError: (_) => setState(() { + _isLoading = false; + _hasError = true; + }), + ), + ) + ..loadRequest( + Uri.parse('${AppConstants.dashboardBaseUrl}/dashboard'), + ); + } + + void _reload() { + setState(() { + _hasError = false; + _isLoading = true; + }); + _controller.reload(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Agency Dashboard'), + leading: BackButton(onPressed: () => context.go('/landing')), + actions: [ + if (!_isLoading) + IconButton( + icon: const Icon(Icons.refresh_rounded), + onPressed: _reload, + tooltip: 'Reload', + ), + PopupMenuButton( + onSelected: (value) { + if (value == 'employee_login') { + context.push('/login/employee'); + } + }, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'employee_login', + child: Row( + children: [ + Icon(Icons.login_rounded, size: 18), + SizedBox(width: 10), + Text('Employee App Login'), + ], + ), + ), + ], + ), + ], + ), + body: Stack( + children: [ + WebViewWidget(controller: _controller), + if (_isLoading) + const LinearProgressIndicator(minHeight: 3), + if (_hasError && !_isLoading) + _ErrorOverlay(onRetry: _reload), + ], + ), + ); + } +} + +class _ErrorOverlay extends StatelessWidget { + final VoidCallback onRetry; + + const _ErrorOverlay({required this.onRetry}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ColoredBox( + color: cs.surface, + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cloud_off_rounded, + size: 56, + color: cs.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'Could not load dashboard', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'Make sure the agency dashboard server is running.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Try Again'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/screens/landing_screen.dart b/lib/features/auth/screens/landing_screen.dart index 15076e4..878fd23 100644 --- a/lib/features/auth/screens/landing_screen.dart +++ b/lib/features/auth/screens/landing_screen.dart @@ -86,7 +86,7 @@ class LandingScreen extends StatelessWidget { ), const SizedBox(height: 16), TextButton( - onPressed: () => context.push('/login/employee'), + onPressed: () => context.go('/agency'), style: TextButton.styleFrom( foregroundColor: Colors.white.withValues(alpha: 0.5), ), diff --git a/lib/features/client/models/booking_result.dart b/lib/features/client/models/booking_result.dart new file mode 100644 index 0000000..762f933 --- /dev/null +++ b/lib/features/client/models/booking_result.dart @@ -0,0 +1,21 @@ +import '../../../core/models/marketplace.dart'; + +class BookingResult { + final String reservationId; + final MarketplaceVehicle vehicle; + final DateTime startDate; + final DateTime endDate; + + const BookingResult({ + required this.reservationId, + required this.vehicle, + required this.startDate, + required this.endDate, + }); + + int get days => endDate.difference(startDate).inDays.clamp(1, 9999); + double get total => vehicle.dailyRateCents * days / 100; + String get shortRef => reservationId.length >= 8 + ? reservationId.substring(0, 8).toUpperCase() + : reservationId.toUpperCase(); +} diff --git a/lib/features/client/screens/booking_confirmation_screen.dart b/lib/features/client/screens/booking_confirmation_screen.dart new file mode 100644 index 0000000..14dcae4 --- /dev/null +++ b/lib/features/client/screens/booking_confirmation_screen.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import '../models/booking_result.dart'; + +const _orange = Color(0xFFFF6B00); +const _blue = Color(0xFF1A56DB); + +class BookingConfirmationScreen extends StatelessWidget { + final BookingResult result; + + const BookingConfirmationScreen({super.key, required this.result}); + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + final cs = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Booking Confirmed'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Success badge + Container( + padding: const EdgeInsets.all(28), + decoration: const BoxDecoration( + color: Color(0xFFDEF7EC), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle_outline_rounded, + color: Color(0xFF057A55), + size: 60, + ), + ), + const SizedBox(height: 24), + const Text( + 'Booking Confirmed!', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + result.vehicle.displayName, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + textAlign: TextAlign.center, + ), + Text( + result.vehicle.company.brand.displayName, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 32), + + // Booking summary card + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outlineVariant), + ), + child: Column( + children: [ + // Reference number + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: _blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Booking Reference', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + Text( + result.shortRef, + style: const TextStyle( + color: _blue, + fontWeight: FontWeight.bold, + fontSize: 17, + letterSpacing: 2, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _InfoRow( + label: 'Pick-up date', + value: fmt.format(result.startDate), + ), + const SizedBox(height: 10), + _InfoRow( + label: 'Drop-off date', + value: fmt.format(result.endDate), + ), + const SizedBox(height: 10), + _InfoRow( + label: 'Duration', + value: + '${result.days} day${result.days == 1 ? '' : 's'}', + ), + const Divider(height: 24), + _InfoRow( + label: 'Daily rate', + value: + '${result.vehicle.dailyRate.toStringAsFixed(2)} MAD/day', + ), + const SizedBox(height: 10), + _InfoRow( + label: 'Total', + value: '${result.total.toStringAsFixed(2)} MAD', + valueStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 17, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Email notice + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF0F9FF), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFBAE6FD)), + ), + child: const Row( + children: [ + Icon( + Icons.email_outlined, + color: Color(0xFF0369A1), + size: 18, + ), + SizedBox(width: 10), + Expanded( + child: Text( + 'A confirmation has been sent to your email.', + style: TextStyle( + color: Color(0xFF0369A1), + fontSize: 13, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => context.go('/client'), + style: ElevatedButton.styleFrom( + backgroundColor: _orange, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + child: const Text( + 'Search More Cars', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + final TextStyle? valueStyle; + + const _InfoRow({required this.label, required this.value, this.valueStyle}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + Text( + value, + style: valueStyle ?? + TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ); + } +} diff --git a/lib/features/client/screens/booking_screen.dart b/lib/features/client/screens/booking_screen.dart index 77801fb..72ccda9 100644 --- a/lib/features/client/screens/booking_screen.dart +++ b/lib/features/client/screens/booking_screen.dart @@ -3,8 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../core/models/marketplace.dart'; +import '../models/booking_result.dart'; import '../providers/client_providers.dart'; +const _orange = Color(0xFFFF6B00); +const _blue = Color(0xFF1A56DB); + class BookingScreen extends ConsumerStatefulWidget { final String vehicleId; final MarketplaceVehicle? vehicle; @@ -25,6 +29,7 @@ class _BookingScreenState extends ConsumerState { final _lastNameCtrl = TextEditingController(); final _emailCtrl = TextEditingController(); final _phoneCtrl = TextEditingController(); + final _licenseCtrl = TextEditingController(); final _notesCtrl = TextEditingController(); bool _loading = false; @@ -36,6 +41,7 @@ class _BookingScreenState extends ConsumerState { _lastNameCtrl.dispose(); _emailCtrl.dispose(); _phoneCtrl.dispose(); + _licenseCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); } @@ -45,13 +51,13 @@ class _BookingScreenState extends ConsumerState { final params = ref.read(searchParamsProvider); if (!params.hasDateRange) { - setState(() => _error = 'Please select dates before booking.'); + setState(() => _error = 'Please select rental dates before booking.'); return; } final vehicle = widget.vehicle; if (vehicle == null) { - setState(() => _error = 'Vehicle information missing.'); + setState(() => _error = 'Vehicle information missing. Please go back and try again.'); return; } @@ -61,6 +67,9 @@ class _BookingScreenState extends ConsumerState { }); try { + final locale = Localizations.localeOf(context).languageCode; + final lang = ['en', 'fr', 'ar'].contains(locale) ? locale : 'en'; + final reservationId = await ref.read(marketplaceServiceProvider).createReservation( vehicleId: vehicle.id, @@ -68,110 +77,43 @@ class _BookingScreenState extends ConsumerState { firstName: _firstNameCtrl.text.trim(), lastName: _lastNameCtrl.text.trim(), email: _emailCtrl.text.trim(), - phone: _phoneCtrl.text.trim().isEmpty - ? null - : _phoneCtrl.text.trim(), + phone: _phoneCtrl.text.trim(), + driverLicense: _licenseCtrl.text.trim(), startDate: params.startDate!, endDate: params.endDate!, notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), + language: lang, ); - if (mounted) _showSuccess(reservationId, vehicle, params); + if (mounted) { + context.pushReplacement( + '/booking-confirmation', + extra: BookingResult( + reservationId: reservationId, + vehicle: vehicle, + startDate: params.startDate!, + endDate: params.endDate!, + ), + ); + } } catch (e) { - setState(() => - _error = 'Booking failed. Please check your details and try again.'); + setState(() => _error = _parseError(e)); } finally { if (mounted) setState(() => _loading = false); } } - void _showSuccess( - String reservationId, MarketplaceVehicle v, SearchParams params) { - final fmt = DateFormat('MMM d, yyyy'); - final days = params.endDate!.difference(params.startDate!).inDays; - final total = v.dailyRateCents * days / 100; - - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Color(0xFFDEF7EC), - shape: BoxShape.circle, - ), - child: const Icon(Icons.check, - color: Color(0xFF057A55), size: 32), - ), - const SizedBox(height: 16), - const Text('Booking Confirmed!', - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text( - v.displayName, - style: const TextStyle( - color: Color(0xFF6B7280), fontSize: 14), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF9FAFB), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - children: [ - _ConfirmRow( - label: 'Dates', - value: - '${fmt.format(params.startDate!)} → ${fmt.format(params.endDate!)}'), - const SizedBox(height: 6), - _ConfirmRow( - label: 'Duration', value: '$days days'), - const SizedBox(height: 6), - _ConfirmRow( - label: 'Total', - value: '\$${total.toStringAsFixed(2)}'), - const SizedBox(height: 6), - _ConfirmRow( - label: 'Reference', - value: reservationId.substring(0, 8).toUpperCase(), - ), - ], - ), - ), - const SizedBox(height: 8), - const Text( - 'A confirmation has been sent\nto your email.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, color: Color(0xFF9CA3AF)), - ), - ], - ), - actions: [ - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - Navigator.pop(context); - context.go('/client'); - }, - child: const Text('Back to Search'), - ), - ), - ], - ), - ); + String _parseError(Object e) { + final s = e.toString().toLowerCase(); + if (s.contains('unavailable') || s.contains('409')) { + return 'This vehicle is not available for the selected dates.'; + } + if (s.contains('socket') || s.contains('connection')) { + return 'Could not reach the server. Check your connection and try again.'; + } + return 'Booking failed. Please check your details and try again.'; } @override @@ -180,7 +122,7 @@ class _BookingScreenState extends ConsumerState { final params = ref.watch(searchParamsProvider); final hasDates = params.hasDateRange; final days = hasDates - ? params.endDate!.difference(params.startDate!).inDays + ? params.endDate!.difference(params.startDate!).inDays.clamp(1, 9999) : 0; final fmt = DateFormat('MMM d, yyyy'); final total = vehicle != null && hasDates @@ -192,52 +134,55 @@ class _BookingScreenState extends ConsumerState { body: Form( key: _formKey, child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 40), children: [ - // Vehicle summary - if (vehicle != null) + // ── Vehicle summary ─────────────────────────────────────────── + if (vehicle != null) ...[ + const SizedBox(height: 16), Card( - margin: const EdgeInsets.only(bottom: 16), + margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ - const Icon(Icons.directions_car, - color: Color(0xFF1A56DB), size: 28), + const Icon(Icons.directions_car, color: _blue, size: 28), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(vehicle.displayName, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15)), - Text(vehicle.company.brand.displayName, - style: const TextStyle( - color: Color(0xFF6B7280), - fontSize: 12)), + Text( + vehicle.displayName, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 15), + ), + Text( + vehicle.company.brand.displayName, + style: const TextStyle( + color: Color(0xFF6B7280), fontSize: 12), + ), ], ), ), if (total != null) Text( - '\$${total.toStringAsFixed(0)}', + '${total.toStringAsFixed(0)} MAD', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, - color: Color(0xFF1A56DB), + color: _blue, ), ), ], ), ), ), + ], - // Date summary - if (hasDates) + // ── Date summary ────────────────────────────────────────────── + if (hasDates) ...[ + const SizedBox(height: 10), Container( - margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFEBF5FF), @@ -246,23 +191,24 @@ class _BookingScreenState extends ConsumerState { ), child: Row( children: [ - const Icon(Icons.calendar_today, - color: Color(0xFF1A56DB), size: 16), + const Icon(Icons.calendar_today, color: _blue, size: 16), const SizedBox(width: 10), - Text( - '${fmt.format(params.startDate!)} → ${fmt.format(params.endDate!)} · $days day${days == 1 ? '' : 's'}', - style: const TextStyle( - color: Color(0xFF1A56DB), - fontWeight: FontWeight.w600, - fontSize: 13, + Expanded( + child: Text( + '${fmt.format(params.startDate!)} → ${fmt.format(params.endDate!)} · $days day${days == 1 ? '' : 's'}', + style: const TextStyle( + color: _blue, + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), ), ], ), - ) - else + ), + ] else ...[ + const SizedBox(height: 10), Container( - margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFFDF6B2), @@ -273,19 +219,23 @@ class _BookingScreenState extends ConsumerState { Icon(Icons.warning_amber, color: Color(0xFFB45309), size: 16), SizedBox(width: 8), - Text( - 'No dates selected — go back and pick dates', - style: TextStyle( - color: Color(0xFF92400E), fontSize: 13), + Expanded( + child: Text( + 'No dates selected — go back and pick dates', + style: TextStyle( + color: Color(0xFF92400E), fontSize: 13), + ), ), ], ), ), + ], + // ── Error ───────────────────────────────────────────────────── if (_error != null) ...[ + const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: const Color(0xFFFDE8E8), borderRadius: BorderRadius.circular(10), @@ -295,8 +245,10 @@ class _BookingScreenState extends ConsumerState { ), ], - // Personal info + // ── Your information ────────────────────────────────────────── + const SizedBox(height: 24), const _SectionLabel('Your Information'), + const SizedBox(height: 14), Row( children: [ Expanded( @@ -306,8 +258,7 @@ class _BookingScreenState extends ConsumerState { textInputAction: TextInputAction.next, decoration: const InputDecoration(labelText: 'First Name'), - validator: (v) => - v == null || v.trim().isEmpty ? 'Required' : null, + validator: _required, ), ), const SizedBox(width: 12), @@ -318,8 +269,7 @@ class _BookingScreenState extends ConsumerState { textInputAction: TextInputAction.next, decoration: const InputDecoration(labelText: 'Last Name'), - validator: (v) => - v == null || v.trim().isEmpty ? 'Required' : null, + validator: _required, ), ), ], @@ -333,9 +283,8 @@ class _BookingScreenState extends ConsumerState { labelText: 'Email', prefixIcon: Icon(Icons.email_outlined), ), - validator: (v) => (v == null || !v.contains('@')) - ? 'Enter a valid email' - : null, + validator: (v) => + (v == null || !v.contains('@')) ? 'Enter a valid email' : null, ), const SizedBox(height: 14), TextFormField( @@ -343,14 +292,26 @@ class _BookingScreenState extends ConsumerState { keyboardType: TextInputType.phone, textInputAction: TextInputAction.next, decoration: const InputDecoration( - labelText: 'Phone (optional)', + labelText: 'Phone', prefixIcon: Icon(Icons.phone_outlined), ), + validator: _required, + ), + const SizedBox(height: 14), + TextFormField( + controller: _licenseCtrl, + textCapitalization: TextCapitalization.characters, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: "Driver's License Number", + prefixIcon: Icon(Icons.credit_card_outlined), + ), + validator: _required, ), const SizedBox(height: 14), TextFormField( controller: _notesCtrl, - maxLines: 2, + maxLines: 3, textInputAction: TextInputAction.done, decoration: const InputDecoration( labelText: 'Notes (optional)', @@ -358,29 +319,69 @@ class _BookingScreenState extends ConsumerState { alignLabelWithHint: true, ), ), + + // ── Info note ───────────────────────────────────────────────── + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF0F9FF), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFBAE6FD)), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 16, color: Color(0xFF0369A1)), + SizedBox(width: 8), + Expanded( + child: Text( + 'Full identity and license details will be collected when you pick up the vehicle.', + style: TextStyle( + color: Color(0xFF0369A1), fontSize: 12), + ), + ), + ], + ), + ), + + // ── Submit ──────────────────────────────────────────────────── const SizedBox(height: 28), ElevatedButton( onPressed: (_loading || !hasDates) ? null : _submit, style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(52)), + backgroundColor: _orange, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), child: _loading ? const SizedBox( height: 22, width: 22, child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Colors.white)), + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Colors.white), + ), ) - : const Text('Confirm Booking', - style: TextStyle(fontSize: 16)), + : const Text( + 'Confirm Booking', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.bold), + ), ), - const SizedBox(height: 24), ], ), ), ); } + + static String? _required(String? v) => + v == null || v.trim().isEmpty ? 'Required' : null; } class _SectionLabel extends StatelessWidget { @@ -388,31 +389,12 @@ class _SectionLabel extends StatelessWidget { const _SectionLabel(this.text); @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(text, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: Color(0xFF374151))), - ); -} - -class _ConfirmRow extends StatelessWidget { - final String label; - final String value; - const _ConfirmRow({required this.label, required this.value}); - - @override - Widget build(BuildContext context) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, - style: const TextStyle( - fontSize: 13, color: Color(0xFF6B7280))), - Text(value, - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w600)), - ], + Widget build(BuildContext context) => Text( + text, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Color(0xFF374151), + ), ); } diff --git a/lib/features/client/screens/client_home_screen.dart b/lib/features/client/screens/client_home_screen.dart index ca5604c..c5ae8ae 100644 --- a/lib/features/client/screens/client_home_screen.dart +++ b/lib/features/client/screens/client_home_screen.dart @@ -214,7 +214,7 @@ class _ClientHomeScreenState extends ConsumerState { : '${_selectedTypes.length} types'; final priceLabel = _priceFilterActive - ? '\$${(_minPriceCents / 100).toStringAsFixed(0)} – \$${(_maxPriceCents / 100).toStringAsFixed(0)}/day' + ? '${(_minPriceCents / 100).toStringAsFixed(0)} – ${(_maxPriceCents / 100).toStringAsFixed(0)} MAD/day' : l10n.anyPrice; return ClientShell( @@ -995,8 +995,8 @@ class _PriceSheetState extends State<_PriceSheet> { } String _fmt(double v) => v >= _PriceSheet._cap - ? '\$${v.toStringAsFixed(0)}+' - : '\$${v.toStringAsFixed(0)}'; + ? '${v.toStringAsFixed(0)}+ MAD' + : '${v.toStringAsFixed(0)} MAD'; @override Widget build(BuildContext context) { diff --git a/lib/features/client/screens/client_vehicle_detail_screen.dart b/lib/features/client/screens/client_vehicle_detail_screen.dart index 9b69c1a..ed84eb0 100644 --- a/lib/features/client/screens/client_vehicle_detail_screen.dart +++ b/lib/features/client/screens/client_vehicle_detail_screen.dart @@ -42,8 +42,6 @@ class _DetailView extends StatelessWidget { ? searchParams.endDate!.difference(searchParams.startDate!).inDays : null; final fmt = DateFormat('MMM d, yyyy'); - final baseUrl = AppConstants.baseUrl.replaceAll('/api/v1', ''); - return Scaffold( body: CustomScrollView( slivers: [ @@ -53,9 +51,8 @@ class _DetailView extends StatelessWidget { flexibleSpace: FlexibleSpaceBar( background: vehicle.primaryPhoto != null ? CachedNetworkImage( - imageUrl: vehicle.primaryPhoto!.startsWith('http') - ? vehicle.primaryPhoto! - : '$baseUrl${vehicle.primaryPhoto!}', + imageUrl: + AppConstants.resolveMediaUrl(vehicle.primaryPhoto!), fit: BoxFit.cover, errorWidget: (_, _, _) => _placeholder(), ) @@ -135,7 +132,7 @@ class _DetailView extends StatelessWidget { fontSize: 13)), const Spacer(), Text( - '\$${vehicle.dailyRate.toStringAsFixed(2)}/day', + '${vehicle.dailyRate.toStringAsFixed(2)} MAD/day', style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -173,7 +170,7 @@ class _DetailView extends StatelessWidget { fontWeight: FontWeight.w600)), const Spacer(), Text( - '\$${(vehicle.dailyRateCents * days / 100).toStringAsFixed(2)}', + '${(vehicle.dailyRateCents * days / 100).toStringAsFixed(2)} MAD', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, diff --git a/lib/features/client/screens/widgets/marketplace_vehicle_card.dart b/lib/features/client/screens/widgets/marketplace_vehicle_card.dart index 1dfcbaf..e0bea93 100644 --- a/lib/features/client/screens/widgets/marketplace_vehicle_card.dart +++ b/lib/features/client/screens/widgets/marketplace_vehicle_card.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../../core/models/marketplace.dart'; @@ -142,7 +140,7 @@ class MarketplaceVehicleCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '\$${vehicle.dailyRate.toStringAsFixed(0)}', + '${vehicle.dailyRate.toStringAsFixed(0)} MAD', style: const TextStyle( fontSize: 22, fontWeight: FontWeight.bold, @@ -166,7 +164,7 @@ class MarketplaceVehicleCard extends StatelessWidget { color: cs.onSurfaceVariant), ), Text( - '\$${(totalCents / 100).toStringAsFixed(0)} total', + '${(totalCents / 100).toStringAsFixed(0)} MAD total', style: TextStyle( fontWeight: FontWeight.w700, fontSize: 14, @@ -211,15 +209,7 @@ class _VehicleImage extends StatelessWidget { ); } - 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; - } + String _resolveUrl(String photo) => AppConstants.resolveMediaUrl(photo); Widget _placeholder() => Container( height: 180, diff --git a/pubspec.lock b/pubspec.lock index 0bfdd2d..6d6fca7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -850,6 +850,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688 + url: "https://pub.dev" + source: hosted + version: "4.12.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2" + url: "https://pub.dev" + source: hosted + version: "3.25.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 468b9ba..0c5e165 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: shimmer: ^3.0.0 fl_chart: ^0.69.0 image_picker: ^1.1.2 + webview_flutter: ^4.0.0 dev_dependencies: flutter_test: diff --git a/rental_car_flutter_app_plan.md b/rental_car_flutter_app_plan.md new file mode 100644 index 0000000..38d5624 --- /dev/null +++ b/rental_car_flutter_app_plan.md @@ -0,0 +1,1113 @@ +# Rental Car Flutter App Development Plan + +## 1. Project Overview + +The goal is to build a cross-platform rental car application using Flutter. The app will allow customers to search for rental cars, view available vehicles, and submit booking requests. + +The agency dashboard already exists as a web application. For the first mobile version, the existing web dashboard will be reused inside the Flutter app through a WebView instead of rebuilding the entire dashboard natively. + +The app should work across: + +- Android +- iOS +- Web +- Desktop, if needed later + +Flutter will be used for the frontend because it supports multiple platforms from one codebase. + +--- + +## 2. Main User Types + +## 2.1 Customer + +Customers are normal users who want to rent a car. + +Customers should be able to: + +- Open the app +- View the company logo on the home page +- Click the **Search your Car** button +- Enter pick-up location +- Enter drop-off location +- Select pick-up date and time +- Select drop-off date and time +- Select car type +- View available cars +- View car details +- Submit a booking request + +## 2.2 Agency + +Agencies are rental companies that already use the existing web dashboard. + +Agencies should be able to: + +- Click the **Agency** space on the home page +- Open the existing web dashboard inside the mobile app +- Sign in using existing agency credentials +- Manage cars +- Manage bookings +- Manage pricing +- Manage company information + +--- + +## 3. MVP Strategy + +The first version should not rebuild the agency dashboard in Flutter. + +Instead, the Flutter app should: + +- Build a native customer-facing rental search experience +- Use WebView to display the existing agency dashboard +- Keep customer and agency flows separate +- Avoid duplicating dashboard code too early + +This keeps development faster, cheaper, and less likely to collapse under its own heroic ambition. + +--- + +## 4. Home Page + +## 4.1 Purpose + +The home page should be simple, clean, and direct. + +It should immediately give the user two choices: + +1. Search for a rental car +2. Access the agency area + +## 4.2 Home Page Elements + +The home page should include: + +- Company logo +- Main button labeled **Search your Car** +- Small clickable area labeled **Agency** +- Optional slogan under the logo +- Clean background +- Mobile-friendly layout + +## 4.3 Home Page Layout + +```text +------------------------------------------------ +| | +| Company Logo | +| | +| Find your rental car | +| | +| [ Search your Car ] | +| | +| | +| Agency | +| | +------------------------------------------------ +``` + +## 4.4 Home Page Actions + +### Search your Car + +When the user clicks **Search your Car**, the app opens the customer search screen. + +### Agency + +When the user clicks **Agency**, the app opens the existing web dashboard inside a Flutter WebView. + +--- + +## 5. Customer Search Flow + +## 5.1 Search Screen + +After clicking **Search your Car**, the user should see a search form. + +## 5.2 Search Fields + +The search form should include: + +- Pick-up location +- Drop-off location +- Pick-up date +- Pick-up time +- Drop-off date +- Drop-off time +- Car type selector + +## 5.3 Car Types + +Example car types: + +- Economy +- Compact +- Sedan +- SUV +- Luxury +- Van +- Pickup Truck +- Electric +- Hybrid + +## 5.4 Search Form Layout + +```text +------------------------------------------------ +| Search Your Car | +| | +| Pick-up Location | +| [ Enter location ] | +| | +| Drop-off Location | +| [ Enter location ] | +| | +| Pick-up Date & Time | +| [ Select date ] [ Select time ] | +| | +| Drop-off Date & Time | +| [ Select date ] [ Select time ] | +| | +| Car Type | +| [ Select car type ] | +| | +| [ Search Cars ] | +------------------------------------------------ +``` + +## 5.5 Validation Rules + +The app should validate: + +- Pick-up location is required +- Drop-off location is required +- Pick-up date is required +- Pick-up time is required +- Drop-off date is required +- Drop-off time is required +- Drop-off date/time must be after pick-up date/time +- Car type is required or defaults to **Any** +- Empty location strings should not be accepted + +--- + +## 6. Search Results Page + +## 6.1 Purpose + +The search results page displays available cars based on the customer’s search criteria. + +## 6.2 Car Card Information + +Each car card should show: + +- Car image +- Car name +- Car type +- Transmission type +- Fuel type +- Number of seats +- Price per day +- Agency name +- Availability status +- Button: **View Details** + +## 6.3 Example Car Card + +```text +------------------------------------------------ +| [Car Image] | +| Toyota Corolla | +| Sedan | Automatic | Gasoline | +| 5 Seats | +| $45/day | +| Agency: Fast Rent Cars | +| [ View Details ] | +------------------------------------------------ +``` + +--- + +## 7. Car Details Page + +## 7.1 Purpose + +The car details page shows full information about a selected vehicle. + +## 7.2 Details to Display + +The page should include: + +- Car images +- Car name +- Car type +- Brand +- Model +- Year +- Transmission +- Fuel type +- Number of seats +- Luggage capacity +- Price per day +- Deposit amount +- Mileage policy +- Insurance options +- Agency name +- Pick-up and drop-off policy +- Button: **Book Now** + +--- + +## 8. Booking Flow + +## 8.1 Booking Request + +When the customer clicks **Book Now**, the app should open a booking form. + +## 8.2 Booking Fields + +The booking should include: + +- Customer name +- Customer phone +- Customer email +- Selected car +- Pick-up location +- Drop-off location +- Pick-up date/time +- Drop-off date/time +- Total price +- Booking status + +## 8.3 Booking Status Options + +```text +Pending +Confirmed +Rejected +Cancelled +Completed +``` + +## 8.4 Payment Strategy + +For the first version, online payment should be skipped unless it already exists in the current system. + +Recommended MVP payment approach: + +- Customer submits booking request +- Agency reviews booking +- Agency confirms availability +- Customer pays manually or through an external payment link + +Online payment can be added later using: + +- Stripe +- PayPal +- Square +- Local payment provider + +Do not add payment processing too early. Payment systems look simple until refunds, disputes, taxes, and failed transactions arrive with knives. + +--- + +## 9. Agency Dashboard Integration + +## 9.1 Strategy + +The agency dashboard already exists as a web application. + +For version 1, the Flutter app should open the existing dashboard inside a WebView. + +This avoids rebuilding the dashboard in Flutter and allows agencies to continue using the current system. + +## 9.2 Agency Flow + +```text +Home Page +→ Agency +→ WebView opens existing dashboard login page +→ Agency signs in +→ Existing company dashboard opens +→ Agency manages cars, bookings, pricing, and profile +``` + +## 9.3 Recommended MVP Flow + +The simplest first version is: + +1. User clicks **Agency** +2. Flutter opens `AgencyDashboardWebViewScreen` +3. WebView loads the existing web dashboard login page +4. Agency signs in using existing credentials +5. Agency uses the current dashboard inside the mobile app + +## 9.4 Why This Is Better for MVP + +Using WebView allows the app to: + +- Reuse the existing dashboard +- Save development time +- Avoid duplicating business logic +- Avoid rebuilding agency management screens +- Keep agency operations consistent across web and mobile +- Launch faster + +## 9.5 Risks + +The existing dashboard must be tested carefully on mobile. + +Potential risks: + +- Dashboard may not be fully responsive +- Some buttons or tables may be hard to use on small screens +- Image upload may require WebView permissions +- File picker behavior may differ across Android and iOS +- Login sessions may expire unexpectedly +- Push notifications may not work through the web dashboard +- App store review may object if the app feels like only a wrapped website + +## 9.6 MVP Decision + +For version 1: + +- Use WebView for the agency dashboard +- Do not rebuild the dashboard natively +- Test the dashboard on Android and iPhone +- Only rebuild dashboard parts in Flutter if WebView creates serious usability problems + +--- + +## 10. Suggested Flutter Package for WebView + +Use: + +```yaml +webview_flutter: ^4.0.0 +``` + +Example screen: + +```dart +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class AgencyDashboardWebViewScreen extends StatefulWidget { + const AgencyDashboardWebViewScreen({super.key}); + + @override + State createState() => + _AgencyDashboardWebViewScreenState(); +} + +class _AgencyDashboardWebViewScreenState + extends State { + late final WebViewController controller; + + @override + void initState() { + super.initState(); + + controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..loadRequest( + Uri.parse('https://your-dashboard-url.com'), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Agency Dashboard'), + ), + body: WebViewWidget(controller: controller), + ); + } +} +``` + +Replace: + +```text +https://your-dashboard-url.com +``` + +with the real dashboard URL. + +--- + +## 11. Login Strategy + +## 11.1 Recommended First Version + +For MVP, let the existing dashboard handle login. + +Flow: + +```text +Agency clicks Agency +→ WebView opens dashboard login page +→ Agency logs in using existing credentials +→ Dashboard opens inside app +``` + +This avoids complicated token handoff between Flutter and the web dashboard. + +## 11.2 More Advanced Future Version + +Later, the Flutter app can have its own agency login screen. + +Flow: + +```text +Agency clicks Agency +→ Flutter sign-in screen +→ Authenticate agency +→ Generate secure session +→ Open dashboard already logged in +``` + +This requires secure authentication handling. + +Do not pass permanent tokens in the URL. + +Avoid: + +```text +https://dashboard.com?token=abc123 +``` + +Better options: + +- Secure cookies +- OAuth redirect +- Backend-generated session +- Short-lived one-time login token +- Firebase custom token flow +- Supabase session handoff + +--- + +## 12. Updated Screen List + +## 12.1 Customer Screens + +```text +HomeScreen +SearchCarScreen +SearchResultsScreen +CarDetailsScreen +BookingScreen +BookingConfirmationScreen +``` + +## 12.2 Agency Screens + +```text +AgencyDashboardWebViewScreen +``` + +## 12.3 Optional Future Agency Screens + +```text +NativeAgencySignInScreen +NativeAgencyDashboardScreen +ManageCarsScreen +AddCarScreen +EditCarScreen +ManageBookingsScreen +BookingDetailsScreen +AgencyProfileScreen +``` + +--- + +## 13. Suggested Flutter Project Structure + +```text +lib/ +│ +├── main.dart +├── app.dart +│ +├── core/ +│ ├── constants/ +│ ├── theme/ +│ ├── routing/ +│ ├── utils/ +│ └── widgets/ +│ +├── features/ +│ ├── home/ +│ ├── car_search/ +│ ├── car_details/ +│ ├── booking/ +│ └── agency_webview/ +│ +├── models/ +│ ├── car_model.dart +│ ├── booking_model.dart +│ └── agency_model.dart +│ +├── services/ +│ ├── car_service.dart +│ ├── booking_service.dart +│ └── agency_service.dart +│ +└── providers/ + ├── car_provider.dart + └── booking_provider.dart +``` + +--- + +## 14. Routing Plan + +Suggested routes: + +```text +/ + HomeScreen + +/search + SearchCarScreen + +/results + SearchResultsScreen + +/car/:carId + CarDetailsScreen + +/booking/:carId + BookingScreen + +/booking-confirmation + BookingConfirmationScreen + +/agency + AgencyDashboardWebViewScreen +``` + +Future native agency routes: + +```text +/agency/login + NativeAgencySignInScreen + +/agency/dashboard + NativeAgencyDashboardScreen + +/agency/cars + ManageCarsScreen + +/agency/bookings + ManageBookingsScreen +``` + +--- + +## 15. Recommended Flutter Packages + +## 15.1 State Management + +Recommended: + +```yaml +flutter_riverpod +``` + +Alternative options: + +```yaml +provider +bloc +``` + +## 15.2 Navigation + +Recommended: + +```yaml +go_router +``` + +## 15.3 Forms and Validation + +```yaml +flutter_form_builder +form_builder_validators +``` + +## 15.4 Date and Time + +```yaml +intl +``` + +Flutter also includes built-in date and time pickers. + +## 15.5 WebView + +```yaml +webview_flutter +``` + +## 15.6 Images and UI + +```yaml +cached_network_image +flutter_svg +google_fonts +``` + +--- + +## 16. Backend Recommendation + +If the current web dashboard already has a backend, the Flutter app should connect to the same backend. + +The customer search and booking flow should use the same data source as the existing dashboard. + +This prevents duplicate data, mismatched bookings, and the kind of confusion that makes support teams age in dog years. + +If no backend API is available yet, create one before building too much Flutter UI. + +Recommended backend options: + +- Existing dashboard backend +- Firebase +- Supabase +- Node.js API +- Laravel API + +--- + +## 17. Database Design + +If the current system already has these tables or collections, reuse them. + +## 17.1 Agencies + +```json +{ + "agencyId": "string", + "companyName": "string", + "email": "string", + "phone": "string", + "address": "string", + "logoUrl": "string", + "createdAt": "timestamp", + "isActive": true +} +``` + +## 17.2 Cars + +```json +{ + "carId": "string", + "agencyId": "string", + "name": "Toyota Corolla", + "brand": "Toyota", + "model": "Corolla", + "year": 2023, + "type": "Sedan", + "transmission": "Automatic", + "fuelType": "Gasoline", + "seats": 5, + "doors": 4, + "luggageCapacity": 2, + "pricePerDay": 45, + "depositAmount": 200, + "description": "Clean and fuel-efficient sedan.", + "imageUrls": [], + "isAvailable": true, + "createdAt": "timestamp" +} +``` + +## 17.3 Bookings + +```json +{ + "bookingId": "string", + "carId": "string", + "agencyId": "string", + "customerName": "string", + "customerEmail": "string", + "customerPhone": "string", + "pickupLocation": "string", + "dropoffLocation": "string", + "pickupDateTime": "timestamp", + "dropoffDateTime": "timestamp", + "carType": "Sedan", + "totalPrice": 180, + "status": "Pending", + "createdAt": "timestamp" +} +``` + +--- + +## 18. Security Requirements + +Security rules must enforce: + +- Agencies can only manage their own data +- Customers cannot access agency management features +- Public users can search available cars +- Booking creation must validate required fields +- Booking status changes must be restricted to agency users +- Dashboard sessions must be protected +- API keys must not be exposed inside the Flutter app + +The WebView dashboard must use HTTPS. + +Never load the dashboard over plain HTTP. + +--- + +## 19. Rental Business Logic + +## 19.1 Rental Duration + +The app should calculate rental duration using pick-up and drop-off date/time. + +Rules: + +- Minimum rental duration is 1 day +- If the rental is less than 24 hours, charge 1 day +- If the rental includes partial days, round up to the next full day +- Drop-off date/time must be after pick-up date/time + +Example: + +```text +Pick-up: June 1, 10:00 AM +Drop-off: June 3, 2:00 PM + +Rental duration = 3 days +``` + +## 19.2 Total Price + +Basic formula: + +```text +Total Price = Rental Days × Price Per Day +``` + +Future additions: + +- Taxes +- Deposit +- Insurance +- Discount +- Late return fee +- Extra driver fee +- Delivery fee + +--- + +## 20. MVP Feature List + +## 20.1 Customer MVP + +The customer side should include: + +- Home page +- Search form +- Search results page +- Car details page +- Booking request form +- Booking confirmation page + +## 20.2 Agency MVP + +The agency side should include: + +- Agency link on home page +- WebView dashboard screen +- Existing dashboard login +- Existing dashboard management tools + +## 20.3 Backend MVP + +The backend should support: + +- Car listing retrieval +- Search/filtering +- Booking creation +- Agency dashboard data +- Secure agency access + +--- + +## 21. Features to Avoid in Version 1 + +Do not include these in the first version unless they already exist and work: + +- Native Flutter agency dashboard +- Online payment +- Coupons +- Loyalty points +- Live chat +- GPS tracking +- AI recommendations +- Complex analytics +- Multi-language support +- Push notifications + +These features can come later. Early-stage apps usually fail because someone keeps adding “just one more thing” until the roadmap looks like a hostage note. + +--- + +## 22. Development Phases + +## Phase 1: Planning and Setup + +- Confirm app name +- Prepare company logo +- Confirm existing dashboard URL +- Confirm backend/API access +- Create Flutter project +- Set up routing +- Set up theme +- Add WebView package + +## Phase 2: Home Page + +- Build home screen +- Add company logo +- Add **Search your Car** button +- Add small **Agency** space +- Connect buttons to routes + +## Phase 3: Agency WebView + +- Create `AgencyDashboardWebViewScreen` +- Load dashboard URL +- Test dashboard login +- Test dashboard responsiveness +- Test image upload +- Test Android behavior +- Test iOS behavior + +## Phase 4: Customer Search UI + +- Build search form +- Add pick-up location field +- Add drop-off location field +- Add date pickers +- Add time pickers +- Add car type dropdown +- Add validation + +## Phase 5: Search Results + +- Create car model +- Use fake data first +- Build car cards +- Build search results page +- Add filtering by car type and location + +## Phase 6: Car Details + +- Build car details page +- Display car image and details +- Add price information +- Add **Book Now** button + +## Phase 7: Booking Flow + +- Build booking form +- Calculate rental days +- Calculate total price +- Submit booking request +- Show booking confirmation + +## Phase 8: Backend Integration + +- Connect to existing backend or API +- Fetch real car data +- Submit real booking requests +- Confirm bookings appear in agency dashboard +- Test data consistency + +## Phase 9: Testing + +Test: + +- Home page navigation +- Search form validation +- Search results filtering +- Car details page +- Booking creation +- WebView dashboard login +- Dashboard mobile responsiveness +- Dashboard image upload +- Android build +- iOS build +- Web build, if needed + +## Phase 10: Deployment + +- Prepare Android release +- Prepare iOS release +- Configure production backend +- Configure dashboard production URL +- Test final builds +- Prepare app store assets +- Submit to app stores + +--- + +## 23. WebView Testing Checklist + +Before accepting WebView as the final agency solution, test: + +- Dashboard opens correctly +- Login works +- Logout works +- Session stays active properly +- Dashboard layout fits mobile screens +- Menu/navigation works on touch devices +- Tables are readable +- Forms are usable +- Add car works +- Edit car works +- Delete/archive car works +- Booking management works +- Image upload works +- File picker works +- Camera/gallery permissions work +- Keyboard does not cover form fields +- Back button behavior works on Android +- Dashboard uses HTTPS +- No sensitive token is exposed in the URL + +--- + +## 24. Risks and Decisions + +## Risk 1: Dashboard Not Mobile Responsive + +If the existing dashboard does not work well on phone screens, the WebView approach may feel poor. + +Decision: + +Test first. Rebuild only the broken dashboard parts natively if needed. + +## Risk 2: Authentication Complexity + +Passing login sessions between Flutter and the web dashboard can become complicated. + +Decision: + +For MVP, let the web dashboard handle login inside WebView. + +## Risk 3: Duplicate Data + +If the mobile app uses a different backend from the dashboard, bookings and cars may become inconsistent. + +Decision: + +Use the same backend/API as the existing dashboard. + +## Risk 4: App Store Review + +If the app is only a website wrapper, app stores may reject it. + +Decision: + +Make the customer search and booking experience native Flutter. Use WebView only for the agency dashboard. + +## Risk 5: Scope Creep + +Trying to build customer app, native dashboard, payments, notifications, and analytics all at once will slow the launch. + +Decision: + +Build only the MVP first. + +--- + +## 25. MVP Success Criteria + +The MVP is successful when: + +- Customer can open the app +- Customer can search for a car +- Customer can view car details +- Customer can submit a booking request +- Agency can open the dashboard inside the app +- Agency can sign in +- Agency can manage cars and bookings using the existing dashboard +- The same data appears correctly between the customer app and dashboard +- The app works on Android and iOS +- The WebView dashboard is usable on mobile + +--- + +## 26. Recommended First Build Order + +Build in this order: + +1. Create Flutter project +2. Build home page +3. Add company logo +4. Add **Search your Car** button +5. Add **Agency** entry point +6. Add WebView dashboard screen +7. Test existing dashboard inside the app +8. Build search form +9. Build fake search results +10. Build car details page +11. Build booking form +12. Connect backend/API +13. Test booking flow with dashboard +14. Polish UI +15. Test Android and iOS +16. Deploy + +--- + +## 27. Final MVP Scope + +## Customer + +- Home page +- Search form +- Search results +- Car details +- Booking request +- Booking confirmation + +## Agency + +- Agency button/link +- WebView dashboard +- Existing dashboard login +- Existing dashboard management + +## Backend + +- Existing dashboard backend if available +- Car data +- Booking data +- Agency data +- Secure dashboard access + +--- + +## 28. Final Recommendation + +Use Flutter for the customer-facing mobile app. + +Use WebView for the existing agency dashboard in version 1. + +Do not rebuild the agency dashboard natively until there is clear evidence that the WebView version is not good enough. + +This approach reduces development time, protects the existing dashboard investment, and gets the app closer to launch without turning the project into a feature swamp.