update the app

This commit is contained in:
root
2026-05-25 15:56:00 -04:00
parent 277db06d26
commit 7ca43ba2f3
19 changed files with 1854 additions and 210 deletions
+3
View File
@@ -1,3 +1,6 @@
# Environment configs (contain URLs — do not commit)
env/
# Miscellaneous # Miscellaneous
*.class *.class
*.log *.log
@@ -4,4 +4,6 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Allow HTTP to localhost for dev dashboard WebView. Remove for production. -->
<application android:usesCleartextTraffic="true"/>
</manifest> </manifest>
+17
View File
@@ -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
+40
View File
@@ -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)"
+29 -11
View File
@@ -3,21 +3,39 @@ import 'dart:io';
class AppConstants { class AppConstants {
static const String apiBaseUrlOverride = static const String apiBaseUrlOverride =
String.fromEnvironment('API_BASE_URL'); String.fromEnvironment('API_BASE_URL');
static const String developmentAndroidApiUrl = 'http://10.0.2.2:4000/api/v1'; static const String dashboardUrlOverride =
static const String developmentLocalApiUrl = 'http://localhost:4000/api/v1'; 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 { static String get baseUrl {
if (apiBaseUrlOverride.isNotEmpty) { if (apiBaseUrlOverride.isNotEmpty) return apiBaseUrlOverride;
return apiBaseUrlOverride; if (Platform.isAndroid) return devAndroidApiUrl;
} return devLocalApiUrl;
}
if (Platform.isAndroid) { static String get dashboardBaseUrl {
return developmentAndroidApiUrl; 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'; static const String tokenKey = 'auth_token';
+49 -1
View File
@@ -10,10 +10,13 @@ import '../../features/auth/screens/employee_login_screen.dart';
import '../../features/dashboard/screens/home_screen.dart'; import '../../features/dashboard/screens/home_screen.dart';
import '../../features/dashboard/screens/vehicles_screen.dart'; import '../../features/dashboard/screens/vehicles_screen.dart';
import '../../features/dashboard/screens/vehicle_detail_screen.dart'; import '../../features/dashboard/screens/vehicle_detail_screen.dart';
import '../../features/dashboard/screens/vehicle_form_screen.dart';
import '../../features/dashboard/screens/reservations_screen.dart'; import '../../features/dashboard/screens/reservations_screen.dart';
import '../../features/dashboard/screens/reservation_detail_screen.dart'; import '../../features/dashboard/screens/reservation_detail_screen.dart';
import '../../features/dashboard/screens/new_reservation_screen.dart';
import '../../features/dashboard/screens/customers_screen.dart'; import '../../features/dashboard/screens/customers_screen.dart';
import '../../features/dashboard/screens/customer_detail_screen.dart'; import '../../features/dashboard/screens/customer_detail_screen.dart';
import '../../features/dashboard/screens/customer_form_screen.dart';
import '../../features/dashboard/screens/notifications_screen.dart'; import '../../features/dashboard/screens/notifications_screen.dart';
import '../../features/dashboard/screens/online_reservations_screen.dart'; import '../../features/dashboard/screens/online_reservations_screen.dart';
import '../../features/dashboard/screens/contract_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_home_screen.dart';
import '../../features/client/screens/client_vehicle_detail_screen.dart'; import '../../features/client/screens/client_vehicle_detail_screen.dart';
import '../../features/client/screens/booking_screen.dart'; import '../../features/client/screens/booking_screen.dart';
import '../../features/client/screens/booking_confirmation_screen.dart';
import '../../features/client/screens/my_bookings_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<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
final router = GoRouter( final router = GoRouter(
@@ -52,7 +58,13 @@ final routerProvider = Provider<GoRouter>((ref) {
} }
if (status == AuthStatus.unauthenticated) { 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'; return '/landing';
} }
@@ -156,6 +168,42 @@ final routerProvider = Provider<GoRouter>((ref) {
vehicle: state.extra as MarketplaceVehicle?, 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,
),
),
], ],
); );
+6
View File
@@ -34,6 +34,12 @@ class ApiClient {
handler.next(response); handler.next(response);
}, },
onError: (e, handler) { 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); handler.next(e);
}, },
)); ));
+4 -2
View File
@@ -74,7 +74,8 @@ class MarketplaceService {
required String firstName, required String firstName,
required String lastName, required String lastName,
required String email, required String email,
String? phone, required String phone,
required String driverLicense,
required DateTime startDate, required DateTime startDate,
required DateTime endDate, required DateTime endDate,
String? notes, String? notes,
@@ -86,7 +87,8 @@ class MarketplaceService {
'firstName': firstName, 'firstName': firstName,
'lastName': lastName, 'lastName': lastName,
'email': email, 'email': email,
if (phone != null && phone.isNotEmpty) 'phone': phone, 'phone': phone,
'driverLicense': driverLicense,
'startDate': '${startDate.toIso8601String().substring(0, 10)}T00:00:00.000Z', 'startDate': '${startDate.toIso8601String().substring(0, 10)}T00:00:00.000Z',
'endDate': '${endDate.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, if (notes != null && notes.isNotEmpty) 'notes': notes,
@@ -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<AgencyDashboardWebViewScreen> createState() =>
_AgencyDashboardWebViewScreenState();
}
class _AgencyDashboardWebViewScreenState
extends State<AgencyDashboardWebViewScreen> {
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<String>(
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'),
),
],
),
),
),
);
}
}
@@ -86,7 +86,7 @@ class LandingScreen extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton( TextButton(
onPressed: () => context.push('/login/employee'), onPressed: () => context.go('/agency'),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: Colors.white.withValues(alpha: 0.5), foregroundColor: Colors.white.withValues(alpha: 0.5),
), ),
@@ -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();
}
@@ -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,
),
),
],
);
}
}
+154 -172
View File
@@ -3,8 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/models/marketplace.dart'; import '../../../core/models/marketplace.dart';
import '../models/booking_result.dart';
import '../providers/client_providers.dart'; import '../providers/client_providers.dart';
const _orange = Color(0xFFFF6B00);
const _blue = Color(0xFF1A56DB);
class BookingScreen extends ConsumerStatefulWidget { class BookingScreen extends ConsumerStatefulWidget {
final String vehicleId; final String vehicleId;
final MarketplaceVehicle? vehicle; final MarketplaceVehicle? vehicle;
@@ -25,6 +29,7 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
final _lastNameCtrl = TextEditingController(); final _lastNameCtrl = TextEditingController();
final _emailCtrl = TextEditingController(); final _emailCtrl = TextEditingController();
final _phoneCtrl = TextEditingController(); final _phoneCtrl = TextEditingController();
final _licenseCtrl = TextEditingController();
final _notesCtrl = TextEditingController(); final _notesCtrl = TextEditingController();
bool _loading = false; bool _loading = false;
@@ -36,6 +41,7 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
_lastNameCtrl.dispose(); _lastNameCtrl.dispose();
_emailCtrl.dispose(); _emailCtrl.dispose();
_phoneCtrl.dispose(); _phoneCtrl.dispose();
_licenseCtrl.dispose();
_notesCtrl.dispose(); _notesCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -45,13 +51,13 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
final params = ref.read(searchParamsProvider); final params = ref.read(searchParamsProvider);
if (!params.hasDateRange) { if (!params.hasDateRange) {
setState(() => _error = 'Please select dates before booking.'); setState(() => _error = 'Please select rental dates before booking.');
return; return;
} }
final vehicle = widget.vehicle; final vehicle = widget.vehicle;
if (vehicle == null) { if (vehicle == null) {
setState(() => _error = 'Vehicle information missing.'); setState(() => _error = 'Vehicle information missing. Please go back and try again.');
return; return;
} }
@@ -61,6 +67,9 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
}); });
try { try {
final locale = Localizations.localeOf(context).languageCode;
final lang = ['en', 'fr', 'ar'].contains(locale) ? locale : 'en';
final reservationId = final reservationId =
await ref.read(marketplaceServiceProvider).createReservation( await ref.read(marketplaceServiceProvider).createReservation(
vehicleId: vehicle.id, vehicleId: vehicle.id,
@@ -68,110 +77,43 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
firstName: _firstNameCtrl.text.trim(), firstName: _firstNameCtrl.text.trim(),
lastName: _lastNameCtrl.text.trim(), lastName: _lastNameCtrl.text.trim(),
email: _emailCtrl.text.trim(), email: _emailCtrl.text.trim(),
phone: _phoneCtrl.text.trim().isEmpty phone: _phoneCtrl.text.trim(),
? null driverLicense: _licenseCtrl.text.trim(),
: _phoneCtrl.text.trim(),
startDate: params.startDate!, startDate: params.startDate!,
endDate: params.endDate!, endDate: params.endDate!,
notes: _notesCtrl.text.trim().isEmpty notes: _notesCtrl.text.trim().isEmpty
? null ? null
: _notesCtrl.text.trim(), : _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) { } catch (e) {
setState(() => setState(() => _error = _parseError(e));
_error = 'Booking failed. Please check your details and try again.');
} finally { } finally {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
} }
void _showSuccess( String _parseError(Object e) {
String reservationId, MarketplaceVehicle v, SearchParams params) { final s = e.toString().toLowerCase();
final fmt = DateFormat('MMM d, yyyy'); if (s.contains('unavailable') || s.contains('409')) {
final days = params.endDate!.difference(params.startDate!).inDays; return 'This vehicle is not available for the selected dates.';
final total = v.dailyRateCents * days / 100; }
if (s.contains('socket') || s.contains('connection')) {
showDialog( return 'Could not reach the server. Check your connection and try again.';
context: context, }
barrierDismissible: false, return 'Booking failed. Please check your details and try again.';
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'),
),
),
],
),
);
} }
@override @override
@@ -180,7 +122,7 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
final params = ref.watch(searchParamsProvider); final params = ref.watch(searchParamsProvider);
final hasDates = params.hasDateRange; final hasDates = params.hasDateRange;
final days = hasDates final days = hasDates
? params.endDate!.difference(params.startDate!).inDays ? params.endDate!.difference(params.startDate!).inDays.clamp(1, 9999)
: 0; : 0;
final fmt = DateFormat('MMM d, yyyy'); final fmt = DateFormat('MMM d, yyyy');
final total = vehicle != null && hasDates final total = vehicle != null && hasDates
@@ -192,52 +134,55 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
body: Form( body: Form(
key: _formKey, key: _formKey,
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 40),
children: [ children: [
// Vehicle summary // ── Vehicle summary ───────────────────────────────────────────
if (vehicle != null) if (vehicle != null) ...[
const SizedBox(height: 16),
Card( Card(
margin: const EdgeInsets.only(bottom: 16), margin: EdgeInsets.zero,
child: Padding( child: Padding(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.directions_car, const Icon(Icons.directions_car, color: _blue, size: 28),
color: Color(0xFF1A56DB), size: 28),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(vehicle.displayName, Text(
style: const TextStyle( vehicle.displayName,
fontWeight: FontWeight.bold, style: const TextStyle(
fontSize: 15)), fontWeight: FontWeight.bold, fontSize: 15),
Text(vehicle.company.brand.displayName, ),
style: const TextStyle( Text(
color: Color(0xFF6B7280), vehicle.company.brand.displayName,
fontSize: 12)), style: const TextStyle(
color: Color(0xFF6B7280), fontSize: 12),
),
], ],
), ),
), ),
if (total != null) if (total != null)
Text( Text(
'\$${total.toStringAsFixed(0)}', '${total.toStringAsFixed(0)} MAD',
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Color(0xFF1A56DB), color: _blue,
), ),
), ),
], ],
), ),
), ),
), ),
],
// Date summary // ── Date summary ──────────────────────────────────────────────
if (hasDates) if (hasDates) ...[
const SizedBox(height: 10),
Container( Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEBF5FF), color: const Color(0xFFEBF5FF),
@@ -246,23 +191,24 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.calendar_today, const Icon(Icons.calendar_today, color: _blue, size: 16),
color: Color(0xFF1A56DB), size: 16),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Expanded(
'${fmt.format(params.startDate!)}${fmt.format(params.endDate!)} · $days day${days == 1 ? '' : 's'}', child: Text(
style: const TextStyle( '${fmt.format(params.startDate!)}${fmt.format(params.endDate!)} · $days day${days == 1 ? '' : 's'}',
color: Color(0xFF1A56DB), style: const TextStyle(
fontWeight: FontWeight.w600, color: _blue,
fontSize: 13, fontWeight: FontWeight.w600,
fontSize: 13,
),
), ),
), ),
], ],
), ),
) ),
else ] else ...[
const SizedBox(height: 10),
Container( Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFDF6B2), color: const Color(0xFFFDF6B2),
@@ -273,19 +219,23 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
Icon(Icons.warning_amber, Icon(Icons.warning_amber,
color: Color(0xFFB45309), size: 16), color: Color(0xFFB45309), size: 16),
SizedBox(width: 8), SizedBox(width: 8),
Text( Expanded(
'No dates selected — go back and pick dates', child: Text(
style: TextStyle( 'No dates selected — go back and pick dates',
color: Color(0xFF92400E), fontSize: 13), style: TextStyle(
color: Color(0xFF92400E), fontSize: 13),
),
), ),
], ],
), ),
), ),
],
// ── Error ─────────────────────────────────────────────────────
if (_error != null) ...[ if (_error != null) ...[
const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFDE8E8), color: const Color(0xFFFDE8E8),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
@@ -295,8 +245,10 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
), ),
], ],
// Personal info // ── Your information ──────────────────────────────────────────
const SizedBox(height: 24),
const _SectionLabel('Your Information'), const _SectionLabel('Your Information'),
const SizedBox(height: 14),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -306,8 +258,7 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
decoration: decoration:
const InputDecoration(labelText: 'First Name'), const InputDecoration(labelText: 'First Name'),
validator: (v) => validator: _required,
v == null || v.trim().isEmpty ? 'Required' : null,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -318,8 +269,7 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
decoration: decoration:
const InputDecoration(labelText: 'Last Name'), const InputDecoration(labelText: 'Last Name'),
validator: (v) => validator: _required,
v == null || v.trim().isEmpty ? 'Required' : null,
), ),
), ),
], ],
@@ -333,9 +283,8 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
labelText: 'Email', labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined), prefixIcon: Icon(Icons.email_outlined),
), ),
validator: (v) => (v == null || !v.contains('@')) validator: (v) =>
? 'Enter a valid email' (v == null || !v.contains('@')) ? 'Enter a valid email' : null,
: null,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
TextFormField( TextFormField(
@@ -343,14 +292,26 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Phone (optional)', labelText: 'Phone',
prefixIcon: Icon(Icons.phone_outlined), 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), const SizedBox(height: 14),
TextFormField( TextFormField(
controller: _notesCtrl, controller: _notesCtrl,
maxLines: 2, maxLines: 3,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Notes (optional)', labelText: 'Notes (optional)',
@@ -358,29 +319,69 @@ class _BookingScreenState extends ConsumerState<BookingScreen> {
alignLabelWithHint: true, 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), const SizedBox(height: 28),
ElevatedButton( ElevatedButton(
onPressed: (_loading || !hasDates) ? null : _submit, onPressed: (_loading || !hasDates) ? null : _submit,
style: ElevatedButton.styleFrom( 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 child: _loading
? const SizedBox( ? const SizedBox(
height: 22, height: 22,
width: 22, width: 22,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: valueColor:
AlwaysStoppedAnimation<Color>(Colors.white)), AlwaysStoppedAnimation<Color>(Colors.white),
),
) )
: const Text('Confirm Booking', : const Text(
style: TextStyle(fontSize: 16)), '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 { class _SectionLabel extends StatelessWidget {
@@ -388,31 +389,12 @@ class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text); const _SectionLabel(this.text);
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Text(
padding: const EdgeInsets.only(bottom: 12), text,
child: Text(text, style: const TextStyle(
style: const TextStyle( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, fontSize: 15,
fontSize: 15, color: Color(0xFF374151),
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)),
],
); );
} }
@@ -214,7 +214,7 @@ class _ClientHomeScreenState extends ConsumerState<ClientHomeScreen> {
: '${_selectedTypes.length} types'; : '${_selectedTypes.length} types';
final priceLabel = _priceFilterActive final priceLabel = _priceFilterActive
? '\$${(_minPriceCents / 100).toStringAsFixed(0)} \$${(_maxPriceCents / 100).toStringAsFixed(0)}/day' ? '${(_minPriceCents / 100).toStringAsFixed(0)} ${(_maxPriceCents / 100).toStringAsFixed(0)} MAD/day'
: l10n.anyPrice; : l10n.anyPrice;
return ClientShell( return ClientShell(
@@ -995,8 +995,8 @@ class _PriceSheetState extends State<_PriceSheet> {
} }
String _fmt(double v) => v >= _PriceSheet._cap String _fmt(double v) => v >= _PriceSheet._cap
? '\$${v.toStringAsFixed(0)}+' ? '${v.toStringAsFixed(0)}+ MAD'
: '\$${v.toStringAsFixed(0)}'; : '${v.toStringAsFixed(0)} MAD';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -42,8 +42,6 @@ class _DetailView extends StatelessWidget {
? searchParams.endDate!.difference(searchParams.startDate!).inDays ? searchParams.endDate!.difference(searchParams.startDate!).inDays
: null; : null;
final fmt = DateFormat('MMM d, yyyy'); final fmt = DateFormat('MMM d, yyyy');
final baseUrl = AppConstants.baseUrl.replaceAll('/api/v1', '');
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@@ -53,9 +51,8 @@ class _DetailView extends StatelessWidget {
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: vehicle.primaryPhoto != null background: vehicle.primaryPhoto != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: vehicle.primaryPhoto!.startsWith('http') imageUrl:
? vehicle.primaryPhoto! AppConstants.resolveMediaUrl(vehicle.primaryPhoto!),
: '$baseUrl${vehicle.primaryPhoto!}',
fit: BoxFit.cover, fit: BoxFit.cover,
errorWidget: (_, _, _) => _placeholder(), errorWidget: (_, _, _) => _placeholder(),
) )
@@ -135,7 +132,7 @@ class _DetailView extends StatelessWidget {
fontSize: 13)), fontSize: 13)),
const Spacer(), const Spacer(),
Text( Text(
'\$${vehicle.dailyRate.toStringAsFixed(2)}/day', '${vehicle.dailyRate.toStringAsFixed(2)} MAD/day',
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -173,7 +170,7 @@ class _DetailView extends StatelessWidget {
fontWeight: FontWeight.w600)), fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
Text( Text(
'\$${(vehicle.dailyRateCents * days / 100).toStringAsFixed(2)}', '${(vehicle.dailyRateCents * days / 100).toStringAsFixed(2)} MAD',
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/models/marketplace.dart'; import '../../../../core/models/marketplace.dart';
@@ -142,7 +140,7 @@ class MarketplaceVehicleCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'\$${vehicle.dailyRate.toStringAsFixed(0)}', '${vehicle.dailyRate.toStringAsFixed(0)} MAD',
style: const TextStyle( style: const TextStyle(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -166,7 +164,7 @@ class MarketplaceVehicleCard extends StatelessWidget {
color: cs.onSurfaceVariant), color: cs.onSurfaceVariant),
), ),
Text( Text(
'\$${(totalCents / 100).toStringAsFixed(0)} total', '${(totalCents / 100).toStringAsFixed(0)} MAD total',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 14, fontSize: 14,
@@ -211,15 +209,7 @@ class _VehicleImage extends StatelessWidget {
); );
} }
String _resolveUrl(String photo) { String _resolveUrl(String photo) => AppConstants.resolveMediaUrl(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;
}
Widget _placeholder() => Container( Widget _placeholder() => Container(
height: 180, height: 180,
+32
View File
@@ -850,6 +850,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: win32:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -21,6 +21,7 @@ dependencies:
shimmer: ^3.0.0 shimmer: ^3.0.0
fl_chart: ^0.69.0 fl_chart: ^0.69.0
image_picker: ^1.1.2 image_picker: ^1.1.2
webview_flutter: ^4.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
File diff suppressed because it is too large Load Diff