first app design

This commit is contained in:
root
2026-05-24 02:35:37 -04:00
parent c842eed601
commit d04c8ce437
138 changed files with 9672 additions and 0 deletions
@@ -0,0 +1,66 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/analytics.dart';
import '../../../core/models/vehicle.dart';
import '../../../core/models/reservation.dart';
import '../../../core/models/customer.dart';
import '../../../core/services/analytics_service.dart';
import '../../../core/services/vehicle_service.dart';
import '../../../core/services/reservation_service.dart';
import '../../../core/services/customer_service.dart';
final analyticsServiceProvider = Provider((_) => AnalyticsService());
final vehicleServiceProvider = Provider((_) => VehicleService());
final reservationServiceProvider = Provider((_) => ReservationService());
final customerServiceProvider = Provider((_) => CustomerService());
final dashboardMetricsProvider = FutureProvider<DashboardMetrics>((ref) async {
return ref.read(analyticsServiceProvider).getDashboard();
});
final vehiclesProvider = FutureProvider.family<VehicleListResponse, Map<String, dynamic>>(
(ref, params) async {
return ref.read(vehicleServiceProvider).getVehicles(
page: params['page'] as int? ?? 1,
pageSize: params['pageSize'] as int? ?? 20,
status: params['status'] as String?,
search: params['search'] as String?,
);
},
);
final vehicleDetailProvider = FutureProvider.family<Vehicle, String>((ref, id) {
return ref.read(vehicleServiceProvider).getVehicle(id);
});
final reservationsProvider =
FutureProvider.family<ReservationListResponse, Map<String, dynamic>>(
(ref, params) async {
return ref.read(reservationServiceProvider).getReservations(
page: params['page'] as int? ?? 1,
pageSize: params['pageSize'] as int? ?? 20,
status: params['status'] as String?,
search: params['search'] as String?,
);
},
);
final reservationDetailProvider =
FutureProvider.family<Reservation, String>((ref, id) {
return ref.read(reservationServiceProvider).getReservation(id);
});
final customersProvider =
FutureProvider.family<CustomerListResponse, Map<String, dynamic>>(
(ref, params) async {
return ref.read(customerServiceProvider).getCustomers(
page: params['page'] as int? ?? 1,
pageSize: params['pageSize'] as int? ?? 20,
search: params['search'] as String?,
);
},
);
final customerDetailProvider =
FutureProvider.family<Customer, String>((ref, id) {
return ref.read(customerServiceProvider).getCustomer(id);
});
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/status_badge.dart';
import '../../../widgets/error_view.dart';
class CustomerDetailScreen extends ConsumerWidget {
final String id;
const CustomerDetailScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(customerDetailProvider(id));
return async.when(
loading: () =>
const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (e, _) => Scaffold(
appBar: AppBar(),
body: ErrorView(
message: 'Could not load customer',
onRetry: () => ref.invalidate(customerDetailProvider(id)),
),
),
data: (customer) {
final fmt = DateFormat('MMM d, yyyy');
return Scaffold(
appBar: AppBar(title: Text(customer.fullName)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundColor: customer.isFlagged
? const Color(0xFFFDE8E8)
: const Color(0xFFE1EFFE),
child: Text(
customer.firstName.substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 32,
color: customer.isFlagged
? const Color(0xFFE02424)
: const Color(0xFF1A56DB),
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
Text(
customer.fullName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
StatusBadge(status: customer.licenseStatus),
if (customer.isFlagged) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFFDE8E8),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.flag,
size: 14, color: Color(0xFFE02424)),
const SizedBox(width: 6),
Text(
customer.flagReason ?? 'Flagged',
style: const TextStyle(
color: Color(0xFF9B1C1C), fontSize: 13),
),
],
),
),
],
],
),
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Contact Information',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFF6B7280),
fontSize: 12,
),
),
const SizedBox(height: 12),
if (customer.email != null)
_InfoRow(
icon: Icons.email_outlined,
value: customer.email!),
if (customer.phone != null)
_InfoRow(
icon: Icons.phone_outlined,
value: customer.phone!),
if (customer.nationality != null)
_InfoRow(
icon: Icons.flag_outlined,
value: customer.nationality!),
if (customer.dateOfBirth != null)
_InfoRow(
icon: Icons.cake_outlined,
value: fmt.format(customer.dateOfBirth!),
),
_InfoRow(
icon: Icons.calendar_today_outlined,
value: 'Joined ${fmt.format(customer.createdAt)}',
),
],
),
),
),
],
),
);
},
);
}
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String value;
const _InfoRow({required this.icon, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(icon, size: 18, color: const Color(0xFF6B7280)),
const SizedBox(width: 10),
Expanded(
child: Text(value,
style: const TextStyle(fontSize: 14)),
),
],
),
);
}
}
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/loading_list.dart';
import '../../../widgets/error_view.dart';
import '../../../widgets/status_badge.dart';
class CustomersScreen extends ConsumerStatefulWidget {
const CustomersScreen({super.key});
@override
ConsumerState<CustomersScreen> createState() => _CustomersScreenState();
}
class _CustomersScreenState extends ConsumerState<CustomersScreen> {
final _searchController = TextEditingController();
String _search = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Map<String, dynamic> get _params => {
'page': 1,
'pageSize': 50,
if (_search.isNotEmpty) 'search': _search,
};
@override
Widget build(BuildContext context) {
final customersAsync = ref.watch(customersProvider(_params));
return Scaffold(
appBar: AppBar(
title: const Text('Customers'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: 'Search customers...',
prefixIcon: Icon(Icons.search, size: 20),
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
onChanged: (v) => setState(() => _search = v),
),
),
),
),
body: customersAsync.when(
loading: () => const LoadingList(itemHeight: 80),
error: (e, _) => ErrorView(
message: 'Failed to load customers',
onRetry: () => ref.invalidate(customersProvider(_params)),
),
data: (res) => res.data.isEmpty
? const Center(
child: Text('No customers found',
style: TextStyle(color: Color(0xFF9CA3AF))))
: RefreshIndicator(
onRefresh: () async =>
ref.invalidate(customersProvider(_params)),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: res.data.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, i) {
final c = res.data[i];
return Card(
child: InkWell(
onTap: () =>
context.push('/dashboard/customers/${c.id}'),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
CircleAvatar(
backgroundColor: c.isFlagged
? const Color(0xFFFDE8E8)
: const Color(0xFFE1EFFE),
radius: 22,
child: Text(
c.firstName.substring(0, 1).toUpperCase(),
style: TextStyle(
color: c.isFlagged
? const Color(0xFFE02424)
: const Color(0xFF1A56DB),
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
c.fullName,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
if (c.isFlagged) ...[
const SizedBox(width: 6),
const Icon(Icons.flag,
size: 14,
color: Color(0xFFE02424)),
],
],
),
if (c.email != null)
Text(
c.email!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
),
),
StatusBadge(status: c.licenseStatus),
],
),
),
),
);
},
),
),
),
);
}
}
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
class DashboardShell extends ConsumerWidget {
final Widget child;
const DashboardShell({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final location = GoRouterState.of(context).matchedLocation;
final employee = ref.watch(authProvider).employee;
int currentIndex = 0;
if (location.startsWith('/dashboard/vehicles')) currentIndex = 1;
if (location.startsWith('/dashboard/reservations')) currentIndex = 2;
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
return Scaffold(
drawer: _Drawer(employee: employee, ref: ref),
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (i) {
switch (i) {
case 0:
context.go('/dashboard');
case 1:
context.go('/dashboard/vehicles');
case 2:
context.go('/dashboard/reservations');
case 3:
context.go('/dashboard/customers');
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Dashboard',
),
NavigationDestination(
icon: Icon(Icons.directions_car_outlined),
selectedIcon: Icon(Icons.directions_car),
label: 'Vehicles',
),
NavigationDestination(
icon: Icon(Icons.event_note_outlined),
selectedIcon: Icon(Icons.event_note),
label: 'Reservations',
),
NavigationDestination(
icon: Icon(Icons.people_outlined),
selectedIcon: Icon(Icons.people),
label: 'Customers',
),
],
),
);
}
}
class _Drawer extends StatelessWidget {
final dynamic employee;
final WidgetRef ref;
const _Drawer({required this.employee, required this.ref});
@override
Widget build(BuildContext context) {
return Drawer(
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
CircleAvatar(
backgroundColor: const Color(0xFF1A56DB),
radius: 24,
child: Text(
employee?.firstName.substring(0, 1).toUpperCase() ?? 'U',
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
employee?.fullName ?? 'Employee',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
employee?.role ?? '',
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280)),
),
],
),
),
],
),
),
const Divider(),
const Spacer(),
ListTile(
leading: const Icon(Icons.logout, color: Color(0xFFE02424)),
title: const Text('Sign Out',
style: TextStyle(color: Color(0xFFE02424))),
onTap: () async {
Navigator.of(context).pop();
await ref.read(authProvider.notifier).logout();
},
),
const SizedBox(height: 8),
],
),
),
);
}
}
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/providers/auth_provider.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/stat_card.dart';
import '../../../widgets/error_view.dart';
import '../../../widgets/reservation_card.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final employee = ref.watch(authProvider).employee;
final metricsAsync = ref.watch(dashboardMetricsProvider);
final recentAsync = ref.watch(
reservationsProvider({'page': 1, 'pageSize': 5}),
);
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Hello, ${employee?.firstName ?? 'there'}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Text(
DateFormat('EEEE, MMMM d').format(DateTime.now()),
style:
const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
),
],
),
actions: [
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
),
],
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(dashboardMetricsProvider);
ref.invalidate(reservationsProvider);
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
metricsAsync.when(
loading: () => const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => ErrorView(
message: 'Could not load metrics',
onRetry: () => ref.invalidate(dashboardMetricsProvider),
),
data: (metrics) => Column(
children: [
Row(
children: [
Expanded(
child: StatCard(
title: 'Total Vehicles',
value: '${metrics.totalVehicles}',
icon: Icons.directions_car,
color: const Color(0xFF1A56DB),
subtitle: '${metrics.availableVehicles} available',
),
),
const SizedBox(width: 12),
Expanded(
child: StatCard(
title: 'Active Rentals',
value: '${metrics.activeReservations}',
icon: Icons.event_available,
color: const Color(0xFF0E9F6E),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: StatCard(
title: 'Revenue',
value:
'\$${NumberFormat.compact().format(metrics.totalRevenue)}',
icon: Icons.attach_money,
color: const Color(0xFF5521B5),
),
),
const SizedBox(width: 12),
Expanded(
child: StatCard(
title: 'Customers',
value: '${metrics.totalCustomers}',
icon: Icons.people,
color: const Color(0xFFB45309),
),
),
],
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Fleet Occupancy',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF374151),
),
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: metrics.occupancyRate / 100,
backgroundColor: const Color(0xFFE5E7EB),
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF1A56DB)),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
Text(
'${metrics.occupancyRate.toStringAsFixed(1)}% occupied',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF6B7280),
),
),
],
),
),
),
],
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Reservations',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF111928),
),
),
TextButton(
onPressed: () => context.go('/dashboard/reservations'),
child: const Text('View all'),
),
],
),
const SizedBox(height: 8),
recentAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => const ErrorView(
message: 'Could not load recent reservations'),
data: (res) => res.data.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No reservations yet',
style: TextStyle(color: Color(0xFF9CA3AF)),
),
),
)
: Column(
children: res.data
.map((r) => Padding(
padding:
const EdgeInsets.only(bottom: 10),
child: ReservationCard(
reservation: r,
onTap: () => context.push(
'/dashboard/reservations/${r.id}'),
),
))
.toList(),
),
),
],
),
),
);
}
}
@@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/status_badge.dart';
import '../../../widgets/error_view.dart';
class ReservationDetailScreen extends ConsumerWidget {
final String id;
const ReservationDetailScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(reservationDetailProvider(id));
return async.when(
loading: () =>
const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (e, _) => Scaffold(
appBar: AppBar(),
body: ErrorView(
message: 'Could not load reservation',
onRetry: () => ref.invalidate(reservationDetailProvider(id)),
),
),
data: (res) {
final fmt = DateFormat('MMM d, yyyy');
final canConfirm = res.status == 'DRAFT';
final canCancel =
res.status == 'DRAFT' || res.status == 'CONFIRMED';
final canCheckin = res.status == 'CONFIRMED';
final canCheckout = res.status == 'ACTIVE';
return Scaffold(
appBar: AppBar(
title: Text('Reservation #${id.substring(0, 8)}'),
actions: [
StatusBadge(status: res.status),
const SizedBox(width: 16),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_Section(
title: 'Vehicle',
child: res.vehicle != null
? _InfoRow(label: 'Vehicle', value: res.vehicle!.displayName)
: const Text('N/A'),
),
_Section(
title: 'Customer',
child: res.customer != null
? Column(
children: [
_InfoRow(
label: 'Name', value: res.customer!.fullName),
if (res.customer!.email != null)
_InfoRow(
label: 'Email', value: res.customer!.email!),
if (res.customer!.phone != null)
_InfoRow(
label: 'Phone', value: res.customer!.phone!),
],
)
: const Text('Walk-in customer'),
),
_Section(
title: 'Dates',
child: Column(
children: [
_InfoRow(label: 'Start', value: fmt.format(res.startDate)),
_InfoRow(label: 'End', value: fmt.format(res.endDate)),
_InfoRow(label: 'Duration', value: '${res.days} days'),
],
),
),
_Section(
title: 'Payment',
child: Column(
children: [
_InfoRow(
label: 'Total',
value: '\$${res.totalAmount.toStringAsFixed(2)}'),
Row(
children: [
const Text('Status: ',
style: TextStyle(color: Color(0xFF6B7280))),
const SizedBox(width: 4),
StatusBadge(status: res.paymentStatus),
],
),
],
),
),
const SizedBox(height: 16),
if (canConfirm)
_ActionButton(
label: 'Confirm Reservation',
color: const Color(0xFF0E9F6E),
icon: Icons.check_circle_outline,
onTap: () async {
await ref
.read(reservationServiceProvider)
.confirmReservation(id);
ref.invalidate(reservationDetailProvider(id));
},
),
if (canCheckin)
_ActionButton(
label: 'Check In',
color: const Color(0xFF1A56DB),
icon: Icons.login,
onTap: () => _showMileageDialog(context, ref, 'checkin'),
),
if (canCheckout)
_ActionButton(
label: 'Check Out',
color: const Color(0xFF5521B5),
icon: Icons.logout,
onTap: () => _showMileageDialog(context, ref, 'checkout'),
),
if (canCancel)
_ActionButton(
label: 'Cancel Reservation',
color: const Color(0xFFE02424),
icon: Icons.cancel_outlined,
onTap: () => _showCancelDialog(context, ref),
),
],
),
);
},
);
}
void _showMileageDialog(
BuildContext context, WidgetRef ref, String type) {
final ctrl = TextEditingController();
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(type == 'checkin' ? 'Check In' : 'Check Out'),
content: TextField(
controller: ctrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Current mileage (km)',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel')),
ElevatedButton(
onPressed: () async {
final mileage = int.tryParse(ctrl.text);
if (mileage == null) return;
Navigator.pop(context);
try {
if (type == 'checkin') {
await ref
.read(reservationServiceProvider)
.checkin(id, mileage);
} else {
await ref
.read(reservationServiceProvider)
.checkout(id, mileage);
}
ref.invalidate(reservationDetailProvider(id));
} catch (_) {}
},
child: const Text('Confirm'),
),
],
),
);
}
void _showCancelDialog(BuildContext context, WidgetRef ref) {
final ctrl = TextEditingController();
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Cancel Reservation'),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(labelText: 'Reason'),
maxLines: 3,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Back')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE02424)),
onPressed: () async {
Navigator.pop(context);
await ref
.read(reservationServiceProvider)
.cancelReservation(id, ctrl.text);
ref.invalidate(reservationDetailProvider(id));
},
child: const Text('Cancel Reservation'),
),
],
),
);
}
}
class _Section extends StatelessWidget {
final String title;
final Widget child;
const _Section({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFF6B7280),
fontSize: 12)),
const SizedBox(height: 10),
child,
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Text('$label: ',
style: const TextStyle(color: Color(0xFF6B7280))),
Expanded(
child: Text(value,
style: const TextStyle(fontWeight: FontWeight.w500)),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
final String label;
final Color color;
final IconData icon;
final VoidCallback onTap;
const _ActionButton({
required this.label,
required this.color,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color,
minimumSize: const Size.fromHeight(48),
),
icon: Icon(icon),
label: Text(label),
onPressed: onTap,
),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/reservation_card.dart';
import '../../../widgets/loading_list.dart';
import '../../../widgets/error_view.dart';
class ReservationsScreen extends ConsumerStatefulWidget {
const ReservationsScreen({super.key});
@override
ConsumerState<ReservationsScreen> createState() => _ReservationsScreenState();
}
class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabs;
final _statuses = [null, 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
final _labels = ['All', 'Confirmed', 'Active', 'Completed', 'Cancelled'];
final _searchController = TextEditingController();
String _search = '';
@override
void initState() {
super.initState();
_tabs = TabController(length: _statuses.length, vsync: this);
}
@override
void dispose() {
_tabs.dispose();
_searchController.dispose();
super.dispose();
}
Map<String, dynamic> _params(int tabIndex) => {
'page': 1,
'pageSize': 50,
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
if (_search.isNotEmpty) 'search': _search,
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Reservations'),
bottom: TabBar(
controller: _tabs,
isScrollable: true,
tabs: _labels.map((l) => Tab(text: l)).toList(),
onTap: (_) => setState(() {}),
),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: 'Search reservations...',
prefixIcon: Icon(Icons.search, size: 20),
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
onChanged: (v) => setState(() => _search = v),
),
),
Expanded(
child: TabBarView(
controller: _tabs,
children: List.generate(
_statuses.length,
(i) => _ReservationTab(
params: _params(i),
onTap: (id) =>
context.push('/dashboard/reservations/$id'),
),
),
),
),
],
),
);
}
}
class _ReservationTab extends ConsumerWidget {
final Map<String, dynamic> params;
final void Function(String id) onTap;
const _ReservationTab({required this.params, required this.onTap});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(reservationsProvider(params));
return async.when(
loading: () => const LoadingList(itemHeight: 110),
error: (e, _) => ErrorView(
message: 'Failed to load reservations',
onRetry: () => ref.invalidate(reservationsProvider(params)),
),
data: (res) => res.data.isEmpty
? const Center(
child: Text('No reservations found',
style: TextStyle(color: Color(0xFF9CA3AF))))
: RefreshIndicator(
onRefresh: () async =>
ref.invalidate(reservationsProvider(params)),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: res.data.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, i) => ReservationCard(
reservation: res.data[i],
onTap: () => onTap(res.data[i].id),
),
),
),
);
}
}
@@ -0,0 +1,203 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/dashboard_providers.dart';
import '../../../core/constants/app_constants.dart';
import '../../../widgets/status_badge.dart';
import '../../../widgets/error_view.dart';
class VehicleDetailScreen extends ConsumerWidget {
final String id;
const VehicleDetailScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vehicleAsync = ref.watch(vehicleDetailProvider(id));
return vehicleAsync.when(
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Scaffold(
appBar: AppBar(),
body: ErrorView(
message: 'Could not load vehicle',
onRetry: () => ref.invalidate(vehicleDetailProvider(id)),
),
),
data: (vehicle) {
final baseUrl =
AppConstants.baseUrl.replaceAll('/api/v1', '');
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 240,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: vehicle.primaryPhoto != null
? CachedNetworkImage(
imageUrl: vehicle.primaryPhoto!.startsWith('http')
? vehicle.primaryPhoto!
: '$baseUrl${vehicle.primaryPhoto!}',
fit: BoxFit.cover,
errorWidget: (_, _, _) => Container(
color: const Color(0xFFF3F4F6),
child: const Icon(Icons.directions_car,
size: 72, color: Color(0xFFD1D5DB)),
),
)
: Container(
color: const Color(0xFFF3F4F6),
child: const Icon(Icons.directions_car,
size: 72, color: Color(0xFFD1D5DB)),
),
),
),
SliverPadding(
padding: const EdgeInsets.all(20),
sliver: SliverList(
delegate: SliverChildListDelegate([
Row(
children: [
Expanded(
child: Text(
vehicle.displayName,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Color(0xFF111928),
),
),
),
StatusBadge(status: vehicle.status),
],
),
const SizedBox(height: 8),
Text(
vehicle.licensePlate,
style: const TextStyle(
fontSize: 15, color: Color(0xFF6B7280)),
),
const SizedBox(height: 20),
_InfoGrid(vehicle: vehicle),
const SizedBox(height: 20),
const Text(
'Pricing',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF111928),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.attach_money,
color: Color(0xFF1A56DB)),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'\$${vehicle.dailyRate.toStringAsFixed(2)}/day',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF1A56DB),
),
),
const Text(
'Daily rate',
style: TextStyle(
fontSize: 12,
color: Color(0xFF6B7280)),
),
],
),
const Spacer(),
Switch(
value: vehicle.isPublished,
onChanged: (v) async {
try {
await ref
.read(vehicleServiceProvider)
.togglePublish(id, v);
ref.invalidate(vehicleDetailProvider(id));
} catch (_) {}
},
),
const Text(
'Published',
style: TextStyle(
fontSize: 12, color: Color(0xFF6B7280)),
),
],
),
),
),
]),
),
),
],
),
);
},
);
}
}
class _InfoGrid extends StatelessWidget {
final dynamic vehicle;
const _InfoGrid({required this.vehicle});
@override
Widget build(BuildContext context) {
final items = [
('Category', vehicle.category),
if (vehicle.seats != null) ('Seats', '${vehicle.seats}'),
if (vehicle.transmission != null) ('Transmission', vehicle.transmission!),
if (vehicle.fuelType != null) ('Fuel', vehicle.fuelType!),
if (vehicle.mileage != null)
('Mileage', '${vehicle.mileage} km'),
];
return Wrap(
spacing: 12,
runSpacing: 12,
children: items
.map((item) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.$1,
style: const TextStyle(
fontSize: 11, color: Color(0xFF9CA3AF)),
),
const SizedBox(height: 2),
Text(
item.$2,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF111928),
),
),
],
),
))
.toList(),
);
}
}
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/vehicle_card.dart';
import '../../../widgets/loading_list.dart';
import '../../../widgets/error_view.dart';
class VehiclesScreen extends ConsumerStatefulWidget {
const VehiclesScreen({super.key});
@override
ConsumerState<VehiclesScreen> createState() => _VehiclesScreenState();
}
class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
String? _statusFilter;
final _searchController = TextEditingController();
String _search = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Map<String, dynamic> get _params => {
'page': 1,
'pageSize': 50,
if (_statusFilter != null) 'status': _statusFilter,
if (_search.isNotEmpty) 'search': _search,
};
@override
Widget build(BuildContext context) {
final vehiclesAsync = ref.watch(vehiclesProvider(_params));
return Scaffold(
appBar: AppBar(
title: const Text('Vehicles'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: 'Search vehicles...',
prefixIcon: Icon(Icons.search, size: 20),
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
onChanged: (v) => setState(() => _search = v),
),
),
),
actions: [
PopupMenuButton<String?>(
icon: Icon(
Icons.filter_list,
color: _statusFilter != null
? const Color(0xFF1A56DB)
: null,
),
onSelected: (v) => setState(() => _statusFilter = v),
itemBuilder: (_) => [
const PopupMenuItem(value: null, child: Text('All')),
const PopupMenuItem(value: 'AVAILABLE', child: Text('Available')),
const PopupMenuItem(value: 'RENTED', child: Text('Rented')),
const PopupMenuItem(
value: 'MAINTENANCE', child: Text('Maintenance')),
],
),
],
),
body: vehiclesAsync.when(
loading: () => const LoadingList(itemHeight: 240),
error: (e, _) => ErrorView(
message: 'Failed to load vehicles',
onRetry: () => ref.invalidate(vehiclesProvider(_params)),
),
data: (res) => res.data.isEmpty
? const Center(
child: Text('No vehicles found',
style: TextStyle(color: Color(0xFF9CA3AF))))
: RefreshIndicator(
onRefresh: () async =>
ref.invalidate(vehiclesProvider(_params)),
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisExtent: 240,
mainAxisSpacing: 12,
),
itemCount: res.data.length,
itemBuilder: (_, i) => VehicleCard(
vehicle: res.data[i],
onTap: () => context
.push('/dashboard/vehicles/${res.data[i].id}'),
),
),
),
),
);
}
}