add dashboard to phone app

This commit is contained in:
root
2026-05-25 05:10:43 -04:00
parent d04c8ce437
commit 277db06d26
67 changed files with 7872 additions and 570 deletions
@@ -3,20 +3,48 @@ import '../../../core/models/analytics.dart';
import '../../../core/models/vehicle.dart';
import '../../../core/models/reservation.dart';
import '../../../core/models/customer.dart';
import '../../../core/models/notification.dart';
import '../../../core/models/offer.dart';
import '../../../core/models/settings.dart';
import '../../../core/models/report.dart';
import '../../../core/services/analytics_service.dart';
import '../../../core/services/vehicle_service.dart';
import '../../../core/services/reservation_service.dart';
import '../../../core/services/customer_service.dart';
import '../../../core/services/notification_service.dart';
import '../../../core/services/offer_service.dart';
import '../../../core/services/settings_service.dart';
import '../../../core/services/payment_service.dart';
import '../../../core/services/team_service.dart';
import '../../../core/services/contract_service.dart';
// ─── Service providers ────────────────────────────────────────
final analyticsServiceProvider = Provider((_) => AnalyticsService());
final vehicleServiceProvider = Provider((_) => VehicleService());
final reservationServiceProvider = Provider((_) => ReservationService());
final customerServiceProvider = Provider((_) => CustomerService());
final notificationServiceProvider = Provider((_) => NotificationService());
final offerServiceProvider = Provider((_) => OfferService());
final settingsServiceProvider = Provider((_) => SettingsService());
// ─── Analytics ────────────────────────────────────────────────
final dashboardMetricsProvider = FutureProvider<DashboardMetrics>((ref) async {
return ref.read(analyticsServiceProvider).getDashboard();
});
final reportProvider =
FutureProvider.family<ReportSummary, Map<String, String>>((ref, params) async {
return ref.read(analyticsServiceProvider).getReport(
startDate: params['startDate']!,
endDate: params['endDate']!,
status: params['status'],
);
});
// ─── Vehicles ─────────────────────────────────────────────────
final vehiclesProvider = FutureProvider.family<VehicleListResponse, Map<String, dynamic>>(
(ref, params) async {
return ref.read(vehicleServiceProvider).getVehicles(
@@ -32,6 +60,21 @@ final vehicleDetailProvider = FutureProvider.family<Vehicle, String>((ref, id) {
return ref.read(vehicleServiceProvider).getVehicle(id);
});
final maintenanceProvider =
FutureProvider.family<List<MaintenanceLog>, String>((ref, vehicleId) {
return ref.read(vehicleServiceProvider).getMaintenance(vehicleId);
});
typedef CalendarKey = ({String vehicleId, int year, int month});
final calendarProvider =
FutureProvider.family<List<CalendarEvent>, CalendarKey>((ref, key) {
return ref.read(vehicleServiceProvider)
.getCalendar(key.vehicleId, key.year, key.month);
});
// ─── Reservations ─────────────────────────────────────────────
final reservationsProvider =
FutureProvider.family<ReservationListResponse, Map<String, dynamic>>(
(ref, params) async {
@@ -39,7 +82,9 @@ final reservationsProvider =
page: params['page'] as int? ?? 1,
pageSize: params['pageSize'] as int? ?? 20,
status: params['status'] as String?,
source: params['source'] as String?,
search: params['search'] as String?,
customerId: params['customerId'] as String?,
);
},
);
@@ -49,6 +94,13 @@ final reservationDetailProvider =
return ref.read(reservationServiceProvider).getReservation(id);
});
final inspectionsProvider =
FutureProvider.family<List<ReservationInspection>, String>((ref, id) {
return ref.read(reservationServiceProvider).getInspections(id);
});
// ─── Customers ────────────────────────────────────────────────
final customersProvider =
FutureProvider.family<CustomerListResponse, Map<String, dynamic>>(
(ref, params) async {
@@ -64,3 +116,57 @@ final customerDetailProvider =
FutureProvider.family<Customer, String>((ref, id) {
return ref.read(customerServiceProvider).getCustomer(id);
});
// ─── Notifications ────────────────────────────────────────────
final notificationsProvider =
FutureProvider<List<AppNotification>>((ref) async {
return ref.read(notificationServiceProvider).getNotifications();
});
final unreadCountProvider = FutureProvider<int>((ref) async {
return ref.read(notificationServiceProvider).getUnreadCount();
});
// ─── Offers ───────────────────────────────────────────────────
final offersProvider = FutureProvider<List<CompanyOffer>>((ref) async {
return ref.read(offerServiceProvider).getOffers();
});
final offerDetailProvider =
FutureProvider.family<CompanyOffer, String>((ref, id) {
return ref.read(offerServiceProvider).getOffer(id);
});
// ─── Settings ─────────────────────────────────────────────────
final brandSettingsProvider = FutureProvider<BrandSettings>((ref) async {
return ref.read(settingsServiceProvider).getBrand();
});
// ─── Payments ─────────────────────────────────────────────────
final paymentServiceProvider = Provider((_) => PaymentService());
final reservationPaymentsProvider =
FutureProvider.family<List<Payment>, String>((ref, reservationId) {
return ref.read(paymentServiceProvider).getPayments(reservationId);
});
// ─── Team ─────────────────────────────────────────────────────
final teamServiceProvider = Provider((_) => TeamService());
final teamProvider = FutureProvider<List<TeamMember>>((ref) {
return ref.read(teamServiceProvider).getMembers();
});
// ─── Contracts ────────────────────────────────────────────────
final contractServiceProvider = Provider((_) => ContractService());
final contractProvider =
FutureProvider.family<ContractPayload, String>((ref, reservationId) {
return ref.read(contractServiceProvider).getContract(reservationId);
});
@@ -0,0 +1,665 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
import '../../../core/services/contract_service.dart';
import '../../../widgets/error_view.dart';
import '../../../widgets/status_badge.dart';
class ContractScreen extends ConsumerWidget {
final String reservationId;
const ContractScreen({super.key, required this.reservationId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(contractProvider(reservationId));
return Scaffold(
appBar: AppBar(
title: const Text('Rental Contract'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(contractProvider(reservationId)),
),
],
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: 'Could not load contract',
onRetry: () => ref.invalidate(contractProvider(reservationId)),
),
data: (c) => _ContractView(contract: c),
),
);
}
}
class _ContractView extends StatelessWidget {
final ContractPayload contract;
const _ContractView({required this.contract});
@override
Widget build(BuildContext context) {
final fmt = DateFormat('MMM d, yyyy');
final fmtMoney =
NumberFormat.currency(symbol: '\$', decimalDigits: 2);
return ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Header ──────────────────────────────────────────────
_Section(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contract.contractNumber != null)
Text(
'Contract ${contract.contractNumber}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF111928),
),
),
if (contract.invoiceNumber != null)
Text(
'Invoice ${contract.invoiceNumber}',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF6B7280)),
),
],
),
),
StatusBadge(status: contract.status),
],
),
const SizedBox(height: 8),
Text(
'Generated ${fmt.format(contract.generatedAt)}',
style: const TextStyle(
fontSize: 12, color: Color(0xFF9CA3AF)),
),
if (contract.notes != null && contract.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.notes_outlined,
size: 14, color: Color(0xFF92400E)),
const SizedBox(width: 6),
Expanded(
child: Text(
contract.notes!,
style: const TextStyle(
fontSize: 12, color: Color(0xFF92400E)),
),
),
],
),
),
],
],
),
),
const SizedBox(height: 12),
// ── Rental Period ────────────────────────────────────────
_Section(
title: 'Rental Period',
icon: Icons.calendar_today_outlined,
child: Column(
children: [
_Row2(
left: _LabelValue(
label: 'Check-out',
value: fmt.format(contract.startDate)),
right: _LabelValue(
label: 'Check-in',
value: fmt.format(contract.endDate)),
),
const SizedBox(height: 10),
_Row2(
left: _LabelValue(
label: 'Duration',
value:
'${contract.totalDays} day${contract.totalDays == 1 ? '' : 's'}'),
right: _LabelValue(
label: 'Payment',
value: _paymentLabel(contract.paymentStatus)),
),
if (contract.pickupLocation != null ||
contract.returnLocation != null) ...[
const SizedBox(height: 10),
if (contract.pickupLocation != null)
_LabelValue(
label: 'Pick-up location',
value: contract.pickupLocation!),
if (contract.returnLocation != null) ...[
const SizedBox(height: 6),
_LabelValue(
label: 'Return location',
value: contract.returnLocation!),
],
],
],
),
),
const SizedBox(height: 12),
// ── Vehicle ──────────────────────────────────────────────
_Section(
title: 'Vehicle',
icon: Icons.directions_car_outlined,
child: Column(
children: [
_Row2(
left: _LabelValue(
label: 'Vehicle',
value: contract.vehicle.displayName),
right: _LabelValue(
label: 'Plate',
value: contract.vehicle.licensePlate),
),
const SizedBox(height: 10),
_Row2(
left: _LabelValue(
label: 'Category',
value: contract.vehicle.category),
right: _LabelValue(
label: 'Color',
value: contract.vehicle.color ?? ''),
),
if (contract.vehicle.vin != null) ...[
const SizedBox(height: 10),
_LabelValue(
label: 'VIN', value: contract.vehicle.vin!),
],
],
),
),
const SizedBox(height: 12),
// ── Customer ─────────────────────────────────────────────
_Section(
title: 'Customer',
icon: Icons.person_outline,
child: Column(
children: [
_Row2(
left: _LabelValue(
label: 'Name',
value: contract.driver.fullName),
right: _LabelValue(
label: 'Email', value: contract.driver.email),
),
if (contract.driver.phone != null) ...[
const SizedBox(height: 10),
_LabelValue(
label: 'Phone', value: contract.driver.phone!),
],
if (contract.driver.driverLicense != null) ...[
const SizedBox(height: 10),
_Row2(
left: _LabelValue(
label: 'License #',
value: contract.driver.driverLicense!),
right: _LabelValue(
label: 'Country',
value:
contract.driver.licenseCountry ?? ''),
),
],
if (contract.driver.licenseExpiry != null) ...[
const SizedBox(height: 10),
_LabelValue(
label: 'License expiry',
value: contract.driver.licenseExpiry!),
],
],
),
),
const SizedBox(height: 12),
// ── Company ──────────────────────────────────────────────
_Section(
title: 'Rental Company',
icon: Icons.business_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contract.company.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF111928)),
),
if (contract.company.address != null) ...[
const SizedBox(height: 4),
Text(
contract.company.address!,
style: const TextStyle(
fontSize: 13, color: Color(0xFF6B7280)),
),
],
if (contract.company.email != null ||
contract.company.phone != null) ...[
const SizedBox(height: 8),
_Row2(
left: _LabelValue(
label: 'Email',
value: contract.company.email ?? ''),
right: _LabelValue(
label: 'Phone',
value: contract.company.phone ?? ''),
),
],
],
),
),
const SizedBox(height: 12),
// ── Invoice ──────────────────────────────────────────────
_Section(
title: 'Invoice',
icon: Icons.receipt_outlined,
child: Column(
children: [
...contract.lineItems.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(item.description,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF374151))),
if (item.qty > 1)
Text(
'${item.qty} × ${fmtMoney.format(item.unitPrice / 100)}',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF9CA3AF)),
),
],
),
),
Text(
fmtMoney.format(item.total / 100),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF111928)),
),
],
),
)),
const Divider(height: 16),
if (contract.taxTotal > 0) ...[
_AmountRow(
label: 'Subtotal',
amount: contract.subtotal,
currency: contract.currency),
const SizedBox(height: 4),
_AmountRow(
label: 'Taxes',
amount: contract.taxTotal,
currency: contract.currency),
const Divider(height: 12),
],
_AmountRow(
label: 'Total',
amount: contract.total,
currency: contract.currency,
bold: true,
color: const Color(0xFF1A56DB),
),
const SizedBox(height: 4),
_AmountRow(
label: 'Amount paid',
amount: contract.amountPaid,
currency: contract.currency,
color: const Color(0xFF057A55),
),
if (contract.balanceDue > 0) ...[
const SizedBox(height: 4),
_AmountRow(
label: 'Balance due',
amount: contract.balanceDue,
currency: contract.currency,
bold: true,
color: const Color(0xFFE02424),
),
],
],
),
),
// ── Payments ─────────────────────────────────────────────
if (contract.payments.isNotEmpty) ...[
const SizedBox(height: 12),
_Section(
title: 'Payments',
icon: Icons.payments_outlined,
child: Column(
children: contract.payments
.map((p) => _PaymentRow(payment: p))
.toList(),
),
),
],
// ── Inspections ──────────────────────────────────────────
if (contract.checkIn != null || contract.checkOut != null) ...[
const SizedBox(height: 12),
_Section(
title: 'Vehicle Inspections',
icon: Icons.fact_check_outlined,
child: Column(
children: [
if (contract.checkIn != null) ...[
_InspectionRow(
label: 'Check-out',
inspection: contract.checkIn!),
if (contract.checkOut != null)
const SizedBox(height: 10),
],
if (contract.checkOut != null)
_InspectionRow(
label: 'Check-in',
inspection: contract.checkOut!),
],
),
),
],
// ── Terms ────────────────────────────────────────────────
if (contract.terms != null && contract.terms!.isNotEmpty) ...[
const SizedBox(height: 12),
_Section(
title: 'Terms & Conditions',
icon: Icons.gavel_outlined,
child: Text(
contract.terms!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
height: 1.5),
),
),
],
if (contract.fuelPolicy != null &&
contract.fuelPolicy!.isNotEmpty) ...[
const SizedBox(height: 12),
_Section(
title: 'Fuel Policy',
icon: Icons.local_gas_station_outlined,
child: Text(
contract.fuelPolicy!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
height: 1.5),
),
),
],
const SizedBox(height: 24),
],
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
class _Section extends StatelessWidget {
final String? title;
final IconData? icon;
final Widget child;
const _Section({this.title, this.icon, required this.child});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Row(
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: const Color(0xFF6B7280)),
const SizedBox(width: 6),
],
Text(
title!,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Color(0xFF374151),
),
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
],
child,
],
),
),
);
}
}
class _LabelValue extends StatelessWidget {
final String label;
final String value;
const _LabelValue({required this.label, required this.value});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 10, color: Color(0xFF9CA3AF))),
Text(value,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF111928))),
],
);
}
class _Row2 extends StatelessWidget {
final Widget left;
final Widget right;
const _Row2({required this.left, required this.right});
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(child: left),
const SizedBox(width: 8),
Expanded(child: right),
],
);
}
class _AmountRow extends StatelessWidget {
final String label;
final int amount;
final String currency;
final bool bold;
final Color? color;
const _AmountRow({
required this.label,
required this.amount,
required this.currency,
this.bold = false,
this.color,
});
@override
Widget build(BuildContext context) {
final fmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
final style = TextStyle(
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
color: color ?? const Color(0xFF374151),
fontSize: bold ? 15 : 13,
);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: style),
Text(fmt.format(amount / 100), style: style),
],
);
}
}
class _PaymentRow extends StatelessWidget {
final ContractPayment payment;
const _PaymentRow({required this.payment});
@override
Widget build(BuildContext context) {
final fmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
final fmtDate = DateFormat('MMM d, yyyy');
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
payment.paymentMethod ?? payment.type,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF374151)),
),
if (payment.paidAt != null)
Text(
fmtDate.format(payment.paidAt!),
style: const TextStyle(
fontSize: 11, color: Color(0xFF9CA3AF)),
),
],
),
),
Text(
fmt.format(payment.amount / 100),
style: TextStyle(
fontWeight: FontWeight.w600,
color: payment.status == 'PAID'
? const Color(0xFF057A55)
: const Color(0xFF374151),
),
),
],
),
);
}
}
class _InspectionRow extends StatelessWidget {
final String label;
final ContractInspection inspection;
const _InspectionRow(
{required this.label, required this.inspection});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Color(0xFF374151)),
),
const SizedBox(height: 6),
Row(
children: [
if (inspection.mileage != null)
Expanded(
child: _LabelValue(
label: 'Mileage',
value: '${inspection.mileage} km'),
),
if (inspection.fuelLevel != null)
Expanded(
child: _LabelValue(
label: 'Fuel', value: inspection.fuelLevel!),
),
],
),
if (inspection.generalCondition != null) ...[
const SizedBox(height: 6),
_LabelValue(
label: 'Condition',
value: inspection.generalCondition!),
],
if (inspection.employeeNotes != null &&
inspection.employeeNotes!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
inspection.employeeNotes!,
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280)),
),
],
],
);
}
}
String _paymentLabel(String status) {
switch (status.toUpperCase()) {
case 'PAID':
return 'Paid';
case 'PARTIAL':
return 'Partial';
case 'PENDING':
return 'Pending';
default:
return status[0] + status.substring(1).toLowerCase();
}
}
@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/dashboard_providers.dart';
import '../../../core/models/reservation.dart';
class ContractsScreen extends ConsumerStatefulWidget {
const ContractsScreen({super.key});
@override
ConsumerState<ContractsScreen> createState() => _ContractsScreenState();
}
class _ContractsScreenState extends ConsumerState<ContractsScreen> {
final _searchCtrl = TextEditingController();
String _search = '';
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final params = {
'page': 1,
'pageSize': 100,
'status': 'CONFIRMED,ACTIVE,COMPLETED,CLOSED',
if (_search.isNotEmpty) 'search': _search,
};
final asyncData = ref.watch(reservationsProvider(params));
return Scaffold(
appBar: AppBar(
title: const Text('Contracts'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
hintText: 'Search by customer, vehicle, contract…',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _search.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchCtrl.clear();
setState(() => _search = '');
},
)
: null,
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
onChanged: (v) => setState(() => _search = v),
),
),
),
),
body: asyncData.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (response) {
final contracts = response.data
.where((r) => r.contractNumber != null)
.toList();
if (contracts.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.description_outlined,
size: 64, color: Color(0xFF9CA3AF)),
SizedBox(height: 12),
Text(
'No contracts yet',
style: TextStyle(
fontSize: 16,
color: Color(0xFF6B7280),
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async =>
ref.invalidate(reservationsProvider(params)),
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: contracts.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (_, i) => _ContractCard(reservation: contracts[i]),
),
);
},
),
);
}
}
class _ContractCard extends StatelessWidget {
final Reservation reservation;
const _ContractCard({required this.reservation});
@override
Widget build(BuildContext context) {
final r = reservation;
final vehicle = r.vehicle;
final customer = r.customer;
return Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () =>
context.push('/dashboard/reservations/${r.id}/contract'),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.description_outlined,
size: 18, color: Color(0xFF1A56DB)),
const SizedBox(width: 6),
Text(
r.contractNumber!,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: Color(0xFF1A56DB),
),
),
const Spacer(),
_StatusChip(status: r.status),
],
),
if (r.invoiceNumber != null) ...[
const SizedBox(height: 4),
Text(
'Invoice: ${r.invoiceNumber}',
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280)),
),
],
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.person_outline,
size: 16, color: Color(0xFF6B7280)),
const SizedBox(width: 4),
Expanded(
child: Text(
customer?.fullName ?? '',
style: const TextStyle(fontSize: 13),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.directions_car_outlined,
size: 16, color: Color(0xFF6B7280)),
const SizedBox(width: 4),
Expanded(
child: Text(
vehicle?.displayName ?? '',
style: const TextStyle(fontSize: 13),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.calendar_today_outlined,
size: 14, color: Color(0xFF9CA3AF)),
const SizedBox(width: 4),
Text(
'${_fmt(r.startDate)}${_fmt(r.endDate)}',
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280)),
),
const Spacer(),
Text(
'${(r.totalAmount / 100).toStringAsFixed(2)} DA',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
],
),
),
),
);
}
String _fmt(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
}
class _StatusChip extends StatelessWidget {
final String status;
const _StatusChip({required this.status});
@override
Widget build(BuildContext context) {
final (label, bg, fg) = switch (status) {
'CONFIRMED' => ('Confirmed', const Color(0xFFEBF5FB), const Color(0xFF1A56DB)),
'ACTIVE' => ('Active', const Color(0xFFECFDF5), const Color(0xFF059669)),
'COMPLETED' => ('Completed', const Color(0xFFF0FDF4), const Color(0xFF16A34A)),
'CLOSED' => ('Closed', const Color(0xFFF9FAFB), const Color(0xFF6B7280)),
_ => (status, const Color(0xFFF9FAFB), const Color(0xFF6B7280)),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: fg,
),
),
);
}
}
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/status_badge.dart';
import '../../../widgets/error_view.dart';
import '../../../widgets/reservation_card.dart';
import 'customer_form_screen.dart';
class CustomerDetailScreen extends ConsumerWidget {
final String id;
@@ -13,6 +16,9 @@ class CustomerDetailScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(customerDetailProvider(id));
final reservationsAsync = ref.watch(
reservationsProvider({'customerId': id, 'page': 1, 'pageSize': 10}),
);
return async.when(
loading: () =>
@@ -27,7 +33,62 @@ class CustomerDetailScreen extends ConsumerWidget {
data: (customer) {
final fmt = DateFormat('MMM d, yyyy');
return Scaffold(
appBar: AppBar(title: Text(customer.fullName)),
appBar: AppBar(
title: Text(customer.fullName),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) =>
CustomerFormScreen(customer: customer)),
);
if (result == true) {
ref.invalidate(customerDetailProvider(id));
}
},
),
PopupMenuButton<String>(
onSelected: (v) async {
if (v == 'flag') {
await _showFlagDialog(context, ref, customer.isFlagged);
} else if (v == 'approve') {
await _showApproveLicenseDialog(context, ref);
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'flag',
child: Row(
children: [
Icon(
customer.isFlagged ? Icons.flag_outlined : Icons.flag,
size: 18,
color: const Color(0xFFE02424),
),
const SizedBox(width: 8),
Text(
customer.isFlagged ? 'Remove Flag' : 'Flag Customer',
style: const TextStyle(color: Color(0xFFE02424)),
),
],
),
),
const PopupMenuItem(
value: 'approve',
child: Row(
children: [
Icon(Icons.badge_outlined, size: 18),
SizedBox(width: 8),
Text('Review License'),
],
),
),
],
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -128,12 +189,213 @@ class CustomerDetailScreen extends ConsumerWidget {
),
),
),
const SizedBox(height: 16),
if (customer.licenseStatus == 'PENDING')
_ActionButton(
label: 'Approve License',
color: const Color(0xFF0E9F6E),
icon: Icons.check_circle_outline,
onTap: () => _showApproveLicenseDialog(context, ref),
),
if (customer.licenseStatus == 'PENDING')
_ActionButton(
label: 'Reject License',
color: const Color(0xFFE02424),
icon: Icons.cancel_outlined,
onTap: () => _showRejectLicenseDialog(context, ref),
),
if (!customer.isFlagged)
_ActionButton(
label: 'Flag Customer',
color: const Color(0xFFE02424),
icon: Icons.flag_outlined,
onTap: () => _showFlagDialog(context, ref, false),
)
else
_ActionButton(
label: 'Remove Flag',
color: const Color(0xFF6B7280),
icon: Icons.flag_outlined,
onTap: () => _showFlagDialog(context, ref, true),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Reservations',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF111928),
),
),
TextButton(
onPressed: () =>
context.push('/dashboard/reservations'),
child: const Text('View all'),
),
],
),
const SizedBox(height: 8),
reservationsAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, _) => const SizedBox.shrink(),
data: (res) => res.data.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'No reservations yet',
style: TextStyle(color: Color(0xFF9CA3AF)),
),
),
)
: Column(
children: res.data
.map(
(r) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ReservationCard(
reservation: r,
onTap: () => context.push(
'/dashboard/reservations/${r.id}',
),
),
),
)
.toList(),
),
),
],
),
);
},
);
}
Future<void> _showFlagDialog(
BuildContext context, WidgetRef ref, bool isFlagged) async {
if (isFlagged) {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Remove Flag'),
content: const Text('Remove the flag from this customer?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Remove Flag')),
],
),
);
if (confirmed == true && context.mounted) {
await ref.read(customerServiceProvider).unflagCustomer(id);
ref.invalidate(customerDetailProvider(id));
}
} else {
final ctrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Flag Customer'),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(labelText: 'Reason'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE02424)),
onPressed: () => Navigator.pop(context, true),
child: const Text('Flag'),
),
],
),
);
if (confirmed == true && ctrl.text.isNotEmpty && context.mounted) {
await ref
.read(customerServiceProvider)
.flagCustomer(id, ctrl.text.trim());
ref.invalidate(customerDetailProvider(id));
}
}
}
Future<void> _showApproveLicenseDialog(
BuildContext context, WidgetRef ref) async {
final noteCtrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Approve License'),
content: TextField(
controller: noteCtrl,
decoration: const InputDecoration(labelText: 'Note (optional)'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0E9F6E)),
onPressed: () => Navigator.pop(context, true),
child: const Text('Approve'),
),
],
),
);
if (confirmed == true && context.mounted) {
await ref.read(customerServiceProvider).approveLicense(
id,
'APPROVED',
note: noteCtrl.text.isNotEmpty ? noteCtrl.text : null,
);
ref.invalidate(customerDetailProvider(id));
}
}
Future<void> _showRejectLicenseDialog(
BuildContext context, WidgetRef ref) async {
final noteCtrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Reject License'),
content: TextField(
controller: noteCtrl,
decoration: const InputDecoration(labelText: 'Reason'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE02424)),
onPressed: () => Navigator.pop(context, true),
child: const Text('Reject'),
),
],
),
);
if (confirmed == true && context.mounted) {
await ref.read(customerServiceProvider).approveLicense(
id,
'REJECTED',
note: noteCtrl.text.isNotEmpty ? noteCtrl.text : null,
);
ref.invalidate(customerDetailProvider(id));
}
}
}
class _InfoRow extends StatelessWidget {
@@ -151,11 +413,40 @@ class _InfoRow extends StatelessWidget {
Icon(icon, size: 18, color: const Color(0xFF6B7280)),
const SizedBox(width: 10),
Expanded(
child: Text(value,
style: const TextStyle(fontSize: 14)),
child: Text(value, style: const TextStyle(fontSize: 14)),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
final String label;
final Color color;
final IconData icon;
final VoidCallback onTap;
const _ActionButton({
required this.label,
required this.color,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color,
minimumSize: const Size.fromHeight(48),
),
icon: Icon(icon),
label: Text(label),
onPressed: onTap,
),
);
}
}
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/models/customer.dart';
import '../providers/dashboard_providers.dart';
class CustomerFormScreen extends ConsumerStatefulWidget {
final Customer? customer;
const CustomerFormScreen({super.key, this.customer});
@override
ConsumerState<CustomerFormScreen> createState() => _CustomerFormScreenState();
}
class _CustomerFormScreenState extends ConsumerState<CustomerFormScreen> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
final _firstName = TextEditingController();
final _lastName = TextEditingController();
final _email = TextEditingController();
final _phone = TextEditingController();
final _nationality = TextEditingController();
DateTime? _dob;
@override
void initState() {
super.initState();
final c = widget.customer;
if (c != null) {
_firstName.text = c.firstName;
_lastName.text = c.lastName;
_email.text = c.email ?? '';
_phone.text = c.phone ?? '';
_nationality.text = c.nationality ?? '';
_dob = c.dateOfBirth;
}
}
@override
void dispose() {
for (final c in [_firstName, _lastName, _email, _phone, _nationality]) {
c.dispose();
}
super.dispose();
}
Future<void> _pickDob() async {
final picked = await showDatePicker(
context: context,
initialDate: _dob ?? DateTime(1990),
firstDate: DateTime(1920),
lastDate: DateTime.now().subtract(const Duration(days: 365 * 16)),
);
if (picked != null) setState(() => _dob = picked);
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _loading = true);
try {
final data = {
'firstName': _firstName.text.trim(),
'lastName': _lastName.text.trim(),
if (_email.text.isNotEmpty) 'email': _email.text.trim(),
if (_phone.text.isNotEmpty) 'phone': _phone.text.trim(),
if (_nationality.text.isNotEmpty)
'nationality': _nationality.text.trim(),
if (_dob != null)
'dateOfBirth':
'${_dob!.toIso8601String().substring(0, 10)}T00:00:00.000Z',
};
final svc = ref.read(customerServiceProvider);
if (widget.customer == null) {
await svc.createCustomer(data);
} else {
await svc.updateCustomer(widget.customer!.id, data);
}
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.customer != null;
return Scaffold(
appBar: AppBar(
title: Text(isEdit ? 'Edit Customer' : 'New Customer'),
actions: [
if (_loading)
const Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2)),
)
else
TextButton(
onPressed: _submit,
child: const Text('Save'),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
_field('First Name', _firstName, required: true),
_field('Last Name', _lastName, required: true),
_field('Email', _email,
inputType: TextInputType.emailAddress),
_field('Phone', _phone, inputType: TextInputType.phone),
_field('Nationality', _nationality),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: InkWell(
onTap: _pickDob,
child: InputDecorator(
decoration:
const InputDecoration(labelText: 'Date of Birth'),
child: Text(
_dob != null
? DateFormat('MMM d, yyyy').format(_dob!)
: 'Tap to select',
style: TextStyle(
color: _dob != null
? null
: const Color(0xFF9CA3AF),
),
),
),
),
),
],
),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: Text(isEdit ? 'Save Changes' : 'Create Customer'),
),
],
),
),
);
}
Widget _field(String label, TextEditingController ctrl,
{bool required = false, TextInputType? inputType}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
controller: ctrl,
keyboardType: inputType,
decoration: InputDecoration(labelText: label),
validator: required
? (v) => v == null || v.isEmpty ? 'Required' : null
: null,
),
);
}
}
@@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart';
import '../../../widgets/loading_list.dart';
import '../../../widgets/error_view.dart';
import '../../../widgets/status_badge.dart';
import 'dashboard_shell.dart';
import 'customer_form_screen.dart';
class CustomersScreen extends ConsumerStatefulWidget {
const CustomersScreen({super.key});
@@ -24,16 +26,25 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
}
Map<String, dynamic> get _params => {
'page': 1,
'pageSize': 50,
if (_search.isNotEmpty) 'search': _search,
};
'page': 1,
'pageSize': 50,
if (_search.isNotEmpty) 'search': _search,
};
@override
Widget build(BuildContext context) {
final customersAsync = ref.watch(customersProvider(_params));
return Scaffold(
return DashboardShell(
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => const CustomerFormScreen()),
);
if (result == true) ref.invalidate(customersProvider(_params));
},
child: const Icon(Icons.person_add_outlined),
),
appBar: AppBar(
title: const Text('Customers'),
bottom: PreferredSize(
@@ -60,8 +71,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
),
data: (res) => res.data.isEmpty
? const Center(
child: Text('No customers found',
style: TextStyle(color: Color(0xFF9CA3AF))))
child: Text(
'No customers found',
style: TextStyle(color: Color(0xFF9CA3AF)),
),
)
: RefreshIndicator(
onRefresh: () async =>
ref.invalidate(customersProvider(_params)),
@@ -98,8 +112,7 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
@@ -111,9 +124,11 @@ class _CustomersScreenState extends ConsumerState<CustomersScreen> {
),
if (c.isFlagged) ...[
const SizedBox(width: 6),
const Icon(Icons.flag,
size: 14,
color: Color(0xFFE02424)),
const Icon(
Icons.flag,
size: 14,
color: Color(0xFFE02424),
),
],
],
),
@@ -2,11 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../providers/dashboard_providers.dart';
class DashboardShell extends ConsumerWidget {
final Widget child;
final PreferredSizeWidget? appBar;
final Widget body;
final Widget? floatingActionButton;
final Color? backgroundColor;
const DashboardShell({super.key, required this.child});
const DashboardShell({
super.key,
this.appBar,
required this.body,
this.floatingActionButton,
this.backgroundColor,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -19,8 +29,11 @@ class DashboardShell extends ConsumerWidget {
if (location.startsWith('/dashboard/customers')) currentIndex = 3;
return Scaffold(
drawer: _Drawer(employee: employee, ref: ref),
body: child,
appBar: appBar,
backgroundColor: backgroundColor,
drawer: _Drawer(employee: employee),
body: body,
floatingActionButton: floatingActionButton,
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (i) {
@@ -62,14 +75,16 @@ class DashboardShell extends ConsumerWidget {
}
}
class _Drawer extends StatelessWidget {
class _Drawer extends ConsumerWidget {
final dynamic employee;
final WidgetRef ref;
const _Drawer({required this.employee, required this.ref});
const _Drawer({required this.employee});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final unreadAsync = ref.watch(unreadCountProvider);
final unread = unreadAsync.valueOrNull ?? 0;
return Drawer(
child: SafeArea(
child: Column(
@@ -84,7 +99,9 @@ class _Drawer extends StatelessWidget {
child: Text(
employee?.firstName.substring(0, 1).toUpperCase() ?? 'U',
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
@@ -99,7 +116,9 @@ class _Drawer extends StatelessWidget {
Text(
employee?.role ?? '',
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280)),
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
),
@@ -108,14 +127,115 @@ class _Drawer extends StatelessWidget {
),
),
const Divider(),
const Spacer(),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_NavItem(
icon: Icons.dashboard_outlined,
label: 'Dashboard',
onTap: () {
Navigator.pop(context);
context.go('/dashboard');
},
),
_NavItem(
icon: Icons.directions_car_outlined,
label: 'Vehicles',
onTap: () {
Navigator.pop(context);
context.go('/dashboard/vehicles');
},
),
_NavItem(
icon: Icons.event_note_outlined,
label: 'Reservations',
onTap: () {
Navigator.pop(context);
context.go('/dashboard/reservations');
},
),
_NavItem(
icon: Icons.people_outlined,
label: 'Customers',
onTap: () {
Navigator.pop(context);
context.go('/dashboard/customers');
},
),
const Divider(),
_NavItem(
icon: Icons.public_outlined,
label: 'Online Requests',
onTap: () {
Navigator.pop(context);
context.push('/dashboard/online-reservations');
},
),
_NavItem(
icon: Icons.description_outlined,
label: 'Contracts',
onTap: () {
Navigator.pop(context);
context.push('/dashboard/contracts');
},
),
_NavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
badge: unread > 0 ? '$unread' : null,
onTap: () {
Navigator.pop(context);
context.push('/dashboard/notifications');
},
),
_NavItem(
icon: Icons.local_offer_outlined,
label: 'Offers',
onTap: () {
Navigator.pop(context);
context.push('/dashboard/offers');
},
),
_NavItem(
icon: Icons.bar_chart_outlined,
label: 'Reports',
onTap: () {
Navigator.pop(context);
context.push('/dashboard/reports');
},
),
_NavItem(
icon: Icons.people_outline,
label: 'Team',
onTap: () {
Navigator.pop(context);
context.push('/dashboard/team');
},
),
_NavItem(
icon: Icons.settings_outlined,
label: 'Settings',
onTap: () {
Navigator.pop(context);
context.push('/dashboard/settings');
},
),
],
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Color(0xFFE02424)),
title: const Text('Sign Out',
style: TextStyle(color: Color(0xFFE02424))),
title: const Text(
'Sign Out',
style: TextStyle(color: Color(0xFFE02424)),
),
onTap: () async {
final router = GoRouter.of(context);
Navigator.of(context).pop();
await ref.read(authProvider.notifier).logout();
router.go('/landing');
},
),
const SizedBox(height: 8),
@@ -125,3 +245,28 @@ class _Drawer extends StatelessWidget {
);
}
}
class _NavItem extends StatelessWidget {
final IconData icon;
final String label;
final String? badge;
final VoidCallback onTap;
const _NavItem({
required this.icon,
required this.label,
this.badge,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: badge != null
? Badge(label: Text(badge!), child: Icon(icon))
: Icon(icon),
title: Text(label),
onTap: onTap,
);
}
}
+175 -19
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/providers/auth_provider.dart';
import '../providers/dashboard_providers.dart';
import 'dashboard_shell.dart';
import '../../../widgets/stat_card.dart';
import '../../../widgets/error_view.dart';
import '../../../widgets/reservation_card.dart';
@@ -18,8 +19,18 @@ class HomeScreen extends ConsumerWidget {
final recentAsync = ref.watch(
reservationsProvider({'page': 1, 'pageSize': 5}),
);
final onlineAsync = ref.watch(
reservationsProvider({
'status': 'DRAFT',
'source': 'MARKETPLACE',
'page': 1,
'pageSize': 100,
}),
);
final pendingOnline = onlineAsync.valueOrNull?.data.length ?? 0;
final unreadCount = ref.watch(unreadCountProvider).valueOrNull ?? 0;
return Scaffold(
return DashboardShell(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -30,18 +41,21 @@ class HomeScreen extends ConsumerWidget {
),
Text(
DateFormat('EEEE, MMMM d').format(DateTime.now()),
style:
const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
),
],
),
actions: [
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
IconButton(
icon: unreadCount > 0
? Badge(
label: Text('$unreadCount'),
child: const Icon(Icons.notifications_outlined),
)
: const Icon(Icons.notifications_outlined),
onPressed: () => context.push('/dashboard/notifications'),
),
_AvatarMenu(employee: employee),
],
),
body: RefreshIndicator(
@@ -92,7 +106,7 @@ class HomeScreen extends ConsumerWidget {
child: StatCard(
title: 'Revenue',
value:
'\$${NumberFormat.compact().format(metrics.totalRevenue)}',
'\$${NumberFormat.compact().format(metrics.totalRevenue / 100)}',
icon: Icons.attach_money,
color: const Color(0xFF5521B5),
),
@@ -127,7 +141,8 @@ class HomeScreen extends ConsumerWidget {
value: metrics.occupancyRate / 100,
backgroundColor: const Color(0xFFE5E7EB),
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF1A56DB)),
Color(0xFF1A56DB),
),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
@@ -146,6 +161,44 @@ class HomeScreen extends ConsumerWidget {
],
),
),
if (pendingOnline > 0) ...[
const SizedBox(height: 16),
InkWell(
onTap: () => context.push('/dashboard/online-reservations'),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFF6B00)),
),
child: Row(
children: [
const Icon(
Icons.public,
color: Color(0xFFFF6B00),
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'$pendingOnline online request${pendingOnline == 1 ? '' : 's'} pending approval',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF92400E),
),
),
),
const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)),
],
),
),
),
],
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -168,7 +221,8 @@ class HomeScreen extends ConsumerWidget {
recentAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => const ErrorView(
message: 'Could not load recent reservations'),
message: 'Could not load recent reservations',
),
data: (res) => res.data.isEmpty
? const Center(
child: Padding(
@@ -181,15 +235,17 @@ class HomeScreen extends ConsumerWidget {
)
: Column(
children: res.data
.map((r) => Padding(
padding:
const EdgeInsets.only(bottom: 10),
child: ReservationCard(
reservation: r,
onTap: () => context.push(
'/dashboard/reservations/${r.id}'),
.map(
(r) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ReservationCard(
reservation: r,
onTap: () => context.push(
'/dashboard/reservations/${r.id}',
),
))
),
),
)
.toList(),
),
),
@@ -199,3 +255,103 @@ class HomeScreen extends ConsumerWidget {
);
}
}
class _AvatarMenu extends ConsumerWidget {
final dynamic employee;
const _AvatarMenu({required this.employee});
@override
Widget build(BuildContext context, WidgetRef ref) {
final initial = employee?.firstName.substring(0, 1).toUpperCase() ?? 'U';
final name = employee?.fullName ?? 'Employee';
final role = employee?.role ?? '';
return Padding(
padding: const EdgeInsets.only(right: 8),
child: PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'logout') {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Sign out'),
content: const Text('Are you sure you want to sign out?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFE02424),
),
child: const Text('Sign out'),
),
],
),
);
if (confirm == true) {
await ref.read(authProvider.notifier).logout();
if (context.mounted) context.go('/landing');
}
}
},
offset: const Offset(0, 44),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
itemBuilder: (_) => [
PopupMenuItem<String>(
enabled: false,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF111928),
fontSize: 14,
),
),
if (role.isNotEmpty)
Text(
role,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
const SizedBox(height: 4),
const Divider(height: 1),
],
),
),
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout, size: 18, color: Color(0xFFE02424)),
SizedBox(width: 10),
Text('Sign out', style: TextStyle(color: Color(0xFFE02424))),
],
),
),
],
child: CircleAvatar(
backgroundColor: const Color(0xFF1A56DB),
radius: 18,
child: Text(
initial,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
);
}
}
@@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
class NewReservationScreen extends ConsumerStatefulWidget {
const NewReservationScreen({super.key});
@override
ConsumerState<NewReservationScreen> createState() =>
_NewReservationScreenState();
}
class _NewReservationScreenState extends ConsumerState<NewReservationScreen> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
String? _vehicleId;
String? _vehicleName;
String? _customerId;
String? _customerName;
DateTime? _startDate;
DateTime? _endDate;
final _pickup = TextEditingController();
final _returnLoc = TextEditingController();
final _notes = TextEditingController();
final _deposit = TextEditingController(text: '0');
@override
void dispose() {
for (final c in [_pickup, _returnLoc, _notes, _deposit]) {
c.dispose();
}
super.dispose();
}
Future<void> _pickDateRange() async {
final range = await showDateRangePicker(
context: context,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
initialDateRange: _startDate != null && _endDate != null
? DateTimeRange(start: _startDate!, end: _endDate!)
: null,
);
if (range != null) {
setState(() {
_startDate = range.start;
_endDate = range.end;
});
}
}
Future<void> _pickVehicle() async {
final vehicles = await ref
.read(vehicleServiceProvider)
.getVehicles(status: 'AVAILABLE', pageSize: 100);
if (!mounted) return;
final result = await showModalBottomSheet<Map<String, String>>(
context: context,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
initialChildSize: 0.6,
expand: false,
builder: (_, scroll) => ListView.builder(
controller: scroll,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: vehicles.data.length,
itemBuilder: (_, i) {
final v = vehicles.data[i];
return ListTile(
title: Text(v.displayName),
subtitle: Text(v.licensePlate),
onTap: () => Navigator.pop(
context, {'id': v.id, 'name': v.displayName}),
);
},
),
),
);
if (result != null) {
setState(() {
_vehicleId = result['id'];
_vehicleName = result['name'];
});
}
}
Future<void> _pickCustomer() async {
final customers =
await ref.read(customerServiceProvider).getCustomers(pageSize: 100);
if (!mounted) return;
final result = await showModalBottomSheet<Map<String, String>>(
context: context,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
initialChildSize: 0.6,
expand: false,
builder: (_, scroll) => ListView.builder(
controller: scroll,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: customers.data.length,
itemBuilder: (_, i) {
final c = customers.data[i];
return ListTile(
title: Text(c.fullName),
subtitle: Text(c.email ?? c.phone ?? ''),
onTap: () => Navigator.pop(
context, {'id': c.id, 'name': c.fullName}),
);
},
),
),
);
if (result != null) {
setState(() {
_customerId = result['id'];
_customerName = result['name'];
});
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_vehicleId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a vehicle')));
return;
}
if (_customerId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a customer')));
return;
}
if (_startDate == null || _endDate == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select dates')));
return;
}
setState(() => _loading = true);
try {
final data = {
'vehicleId': _vehicleId!,
'customerId': _customerId!,
'startDate':
'${_startDate!.toIso8601String().substring(0, 10)}T00:00:00.000Z',
'endDate':
'${_endDate!.toIso8601String().substring(0, 10)}T00:00:00.000Z',
if (_pickup.text.isNotEmpty) 'pickupLocation': _pickup.text.trim(),
if (_returnLoc.text.isNotEmpty)
'returnLocation': _returnLoc.text.trim(),
if (_notes.text.isNotEmpty) 'notes': _notes.text.trim(),
'depositAmount': ((double.tryParse(_deposit.text) ?? 0) * 100).round(),
};
await ref.read(reservationServiceProvider).createReservation(data);
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final fmt = DateFormat('MMM d, yyyy');
return Scaffold(
appBar: AppBar(
title: const Text('New Reservation'),
actions: [
if (_loading)
const Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2)),
)
else
TextButton(
onPressed: _submit,
child: const Text('Create'),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_PickerTile(
label: 'Vehicle',
value: _vehicleName,
icon: Icons.directions_car_outlined,
onTap: _pickVehicle,
placeholder: 'Select available vehicle',
),
const SizedBox(height: 12),
_PickerTile(
label: 'Customer',
value: _customerName,
icon: Icons.person_outlined,
onTap: _pickCustomer,
placeholder: 'Select customer',
),
const SizedBox(height: 12),
_PickerTile(
label: 'Rental Period',
value: _startDate != null && _endDate != null
? '${fmt.format(_startDate!)}${fmt.format(_endDate!)}'
: null,
icon: Icons.date_range_outlined,
onTap: _pickDateRange,
placeholder: 'Select start & end dates',
),
const SizedBox(height: 16),
Card(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
_textField('Pickup Location', _pickup),
_textField('Return Location', _returnLoc),
_textField('Deposit (\$)', _deposit,
inputType: TextInputType.number),
_textField('Notes', _notes, maxLines: 3),
],
),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: const Text('Create Reservation'),
),
],
),
),
);
}
Widget _textField(String label, TextEditingController ctrl,
{TextInputType? inputType, int maxLines = 1}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
controller: ctrl,
keyboardType: inputType,
maxLines: maxLines,
decoration: InputDecoration(labelText: label),
),
);
}
}
class _PickerTile extends StatelessWidget {
final String label;
final String? value;
final String placeholder;
final IconData icon;
final VoidCallback onTap;
const _PickerTile({
required this.label,
required this.value,
required this.placeholder,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E7EB)),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(icon, color: const Color(0xFF6B7280)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 11, color: Color(0xFF9CA3AF))),
const SizedBox(height: 2),
Text(
value ?? placeholder,
style: TextStyle(
fontWeight:
value != null ? FontWeight.w500 : FontWeight.normal,
color: value != null
? const Color(0xFF111928)
: const Color(0xFF9CA3AF),
),
),
],
),
),
const Icon(Icons.chevron_right, color: Color(0xFF9CA3AF)),
],
),
),
);
}
}
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/error_view.dart';
class NotificationsScreen extends ConsumerWidget {
const NotificationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(notificationsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Notifications'),
actions: [
IconButton(
icon: const Icon(Icons.done_all),
tooltip: 'Mark all read',
onPressed: () async {
await ref.read(notificationServiceProvider).markAllRead();
ref.invalidate(notificationsProvider);
ref.invalidate(unreadCountProvider);
},
),
],
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: 'Failed to load notifications',
onRetry: () => ref.invalidate(notificationsProvider),
),
data: (notifications) {
if (notifications.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_none,
size: 64, color: Color(0xFFD1D5DB)),
SizedBox(height: 16),
Text('No notifications',
style: TextStyle(color: Color(0xFF9CA3AF))),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(notificationsProvider),
child: ListView.separated(
itemCount: notifications.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, indent: 72),
itemBuilder: (_, i) {
final n = notifications[i];
return _NotificationTile(
notification: n,
onRead: () async {
if (!n.isRead) {
await ref
.read(notificationServiceProvider)
.markRead(n.id);
ref.invalidate(notificationsProvider);
ref.invalidate(unreadCountProvider);
}
},
);
},
),
);
},
),
);
}
}
class _NotificationTile extends StatelessWidget {
final dynamic notification;
final VoidCallback onRead;
const _NotificationTile(
{required this.notification, required this.onRead});
IconData _icon(String type) {
switch (type) {
case 'RESERVATION_CREATED':
case 'RESERVATION_CONFIRMED':
return Icons.event_available;
case 'RESERVATION_CANCELLED':
return Icons.event_busy;
case 'PAYMENT_RECEIVED':
return Icons.payments_outlined;
case 'LICENSE_UPLOADED':
return Icons.badge_outlined;
default:
return Icons.notifications_outlined;
}
}
@override
Widget build(BuildContext context) {
final fmt = DateFormat('MMM d, h:mm a');
final isRead = notification.isRead as bool;
return InkWell(
onTap: onRead,
child: Container(
color: isRead ? null : const Color(0xFFF0F7FF),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isRead
? const Color(0xFFF3F4F6)
: const Color(0xFFE1EFFE),
shape: BoxShape.circle,
),
child: Icon(
_icon(notification.type as String),
size: 20,
color: isRead
? const Color(0xFF6B7280)
: const Color(0xFF1A56DB),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification.title as String,
style: TextStyle(
fontWeight:
isRead ? FontWeight.normal : FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
notification.body as String,
style: const TextStyle(
fontSize: 13, color: Color(0xFF6B7280)),
),
const SizedBox(height: 4),
Text(
fmt.format(notification.createdAt as DateTime),
style: const TextStyle(
fontSize: 11, color: Color(0xFF9CA3AF)),
),
],
),
),
if (!isRead)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(top: 6),
decoration: const BoxDecoration(
color: Color(0xFF1A56DB),
shape: BoxShape.circle,
),
),
],
),
),
);
}
}
@@ -0,0 +1,452 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/models/offer.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/error_view.dart';
class OffersScreen extends ConsumerWidget {
const OffersScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(offersProvider);
return Scaffold(
appBar: AppBar(title: const Text('Offers & Promotions')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
final created = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => const _OfferFormSheet(),
);
if (created == true) ref.invalidate(offersProvider);
},
icon: const Icon(Icons.add),
label: const Text('New Offer'),
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: 'Failed to load offers',
onRetry: () => ref.invalidate(offersProvider),
),
data: (offers) {
if (offers.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_offer_outlined,
size: 64, color: Color(0xFFD1D5DB)),
SizedBox(height: 16),
Text('No offers yet',
style: TextStyle(color: Color(0xFF9CA3AF))),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(offersProvider),
child: ListView.separated(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: offers.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, i) => _OfferCard(
offer: offers[i],
onToggle: () async {
final svc = ref.read(offerServiceProvider);
if (offers[i].isActive) {
await svc.deactivateOffer(offers[i].id);
} else {
await svc.activateOffer(offers[i].id);
}
ref.invalidate(offersProvider);
},
onDelete: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete Offer'),
content: Text(
'Delete "${offers[i].title}"? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE02424)),
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
await ref.read(offerServiceProvider).deleteOffer(offers[i].id);
ref.invalidate(offersProvider);
}
},
),
),
);
},
),
);
}
}
class _OfferCard extends StatelessWidget {
final CompanyOffer offer;
final VoidCallback onToggle;
final VoidCallback onDelete;
const _OfferCard({
required this.offer,
required this.onToggle,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final fmt = DateFormat('MMM d, yyyy');
final isExpired = offer.validUntil.isBefore(DateTime.now());
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
offer.title,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 15),
),
),
_StatusChip(
active: offer.isActive && !isExpired,
expired: isExpired),
const SizedBox(width: 8),
PopupMenuButton<String>(
onSelected: (v) {
if (v == 'toggle') onToggle();
if (v == 'delete') onDelete();
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'toggle',
child: Text(offer.isActive ? 'Deactivate' : 'Activate'),
),
const PopupMenuItem(
value: 'delete',
child: Text('Delete',
style: TextStyle(color: Color(0xFFE02424))),
),
],
),
],
),
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF0F7FF),
borderRadius: BorderRadius.circular(20),
),
child: Text(
offer.discountLabel,
style: const TextStyle(
color: Color(0xFF1A56DB),
fontWeight: FontWeight.w600,
fontSize: 13),
),
),
if (offer.promoCode != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
offer.promoCode!,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
fontWeight: FontWeight.w600),
),
),
],
],
),
const SizedBox(height: 8),
Text(
'${fmt.format(offer.validFrom)}${fmt.format(offer.validUntil)}',
style:
const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
),
if (offer.maxRedemptions != null) ...[
const SizedBox(height: 4),
Text(
'${offer.redemptionCount} / ${offer.maxRedemptions} uses',
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280)),
),
],
],
),
),
);
}
}
class _StatusChip extends StatelessWidget {
final bool active;
final bool expired;
const _StatusChip({required this.active, required this.expired});
@override
Widget build(BuildContext context) {
Color bg;
Color fg;
String label;
if (expired) {
bg = const Color(0xFFF3F4F6);
fg = const Color(0xFF6B7280);
label = 'Expired';
} else if (active) {
bg = const Color(0xFFDEF7EC);
fg = const Color(0xFF03543F);
label = 'Active';
} else {
bg = const Color(0xFFFDE8E8);
fg = const Color(0xFF9B1C1C);
label = 'Inactive';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration:
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)),
child: Text(label,
style: TextStyle(fontSize: 11, color: fg, fontWeight: FontWeight.w600)),
);
}
}
class _OfferFormSheet extends ConsumerStatefulWidget {
const _OfferFormSheet();
@override
ConsumerState<_OfferFormSheet> createState() => _OfferFormSheetState();
}
class _OfferFormSheetState extends ConsumerState<_OfferFormSheet> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
final _title = TextEditingController();
final _description = TextEditingController();
final _promoCode = TextEditingController();
final _discountValue = TextEditingController(text: '10');
final _maxRedemptions = TextEditingController();
String _type = 'PERCENTAGE';
bool _isPublic = false;
DateTime _validFrom = DateTime.now();
DateTime _validUntil = DateTime.now().add(const Duration(days: 30));
static const _types = ['PERCENTAGE', 'FIXED_AMOUNT', 'FREE_DAY'];
@override
void dispose() {
for (final c in [
_title, _description, _promoCode, _discountValue, _maxRedemptions
]) {
c.dispose();
}
super.dispose();
}
Future<void> _pickDateRange() async {
final range = await showDateRangePicker(
context: context,
firstDate: DateTime.now().subtract(const Duration(days: 1)),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
initialDateRange: DateTimeRange(start: _validFrom, end: _validUntil),
);
if (range != null) {
setState(() {
_validFrom = range.start;
_validUntil = range.end;
});
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _loading = true);
try {
int discVal = int.tryParse(_discountValue.text) ?? 0;
if (_type == 'FIXED_AMOUNT') discVal = discVal * 100;
await ref.read(offerServiceProvider).createOffer({
'title': _title.text.trim(),
if (_description.text.isNotEmpty)
'description': _description.text.trim(),
'type': _type,
'discountValue': discVal,
if (_promoCode.text.isNotEmpty) 'promoCode': _promoCode.text.trim(),
'isPublic': _isPublic,
'isActive': true,
'isFeatured': false,
'validFrom':
'${_validFrom.toIso8601String().substring(0, 10)}T00:00:00.000Z',
'validUntil':
'${_validUntil.toIso8601String().substring(0, 10)}T23:59:59.000Z',
if (_maxRedemptions.text.isNotEmpty)
'maxRedemptions': int.parse(_maxRedemptions.text),
'categories': <String>[],
});
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final fmt = DateFormat('MMM d, yyyy');
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text('New Offer',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
const Spacer(),
if (_loading)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
else
TextButton(
onPressed: _submit,
child: const Text('Create')),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _title,
decoration: const InputDecoration(labelText: 'Title'),
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _description,
decoration: const InputDecoration(labelText: 'Description'),
maxLines: 2,
),
const SizedBox(height: 12),
InputDecorator(
decoration: const InputDecoration(labelText: 'Type'),
child: DropdownButton<String>(
value: _type,
isExpanded: true,
underline: const SizedBox(),
items: _types
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
onChanged: (v) => setState(() => _type = v!),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _discountValue,
decoration: InputDecoration(
labelText: _type == 'PERCENTAGE'
? 'Discount (%)'
: _type == 'FIXED_AMOUNT'
? 'Amount (\$)'
: 'Free Days',
),
keyboardType: TextInputType.number,
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _promoCode,
decoration: const InputDecoration(
labelText: 'Promo Code (optional)'),
textCapitalization: TextCapitalization.characters,
),
const SizedBox(height: 12),
TextFormField(
controller: _maxRedemptions,
decoration: const InputDecoration(
labelText: 'Max Redemptions (optional)'),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
InkWell(
onTap: _pickDateRange,
child: InputDecorator(
decoration:
const InputDecoration(labelText: 'Valid Period'),
child: Text(
'${fmt.format(_validFrom)}${fmt.format(_validUntil)}'),
),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Public offer'),
subtitle: const Text('Visible to customers on marketplace'),
value: _isPublic,
onChanged: (v) => setState(() => _isPublic = v),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _loading ? null : _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: const Text('Create Offer'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
import '../../../core/models/reservation.dart';
import '../../../widgets/error_view.dart';
import '../../../widgets/status_badge.dart';
const _params = {
'status': 'DRAFT',
'source': 'MARKETPLACE',
'page': 1,
'pageSize': 100,
};
class OnlineReservationsScreen extends ConsumerWidget {
const OnlineReservationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(reservationsProvider(_params));
return Scaffold(
appBar: AppBar(
title: const Text('Online Reservations'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(reservationsProvider(_params)),
),
],
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: 'Could not load online reservations',
onRetry: () => ref.invalidate(reservationsProvider(_params)),
),
data: (result) {
final items = result.data;
if (items.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 64, color: Color(0xFF6EE7B7)),
SizedBox(height: 16),
Text(
'All caught up!',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF111928)),
),
SizedBox(height: 6),
Text(
'No pending marketplace requests.',
style: TextStyle(color: Color(0xFF6B7280)),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async =>
ref.invalidate(reservationsProvider(_params)),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (_, i) => _ReservationCard(
reservation: items[i],
onAction: () => ref.invalidate(reservationsProvider(_params)),
),
),
);
},
),
);
}
}
class _ReservationCard extends ConsumerWidget {
final Reservation reservation;
final VoidCallback onAction;
const _ReservationCard({
required this.reservation,
required this.onAction,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final r = reservation;
final fmt = DateFormat('MMM d, yyyy');
final fmtMoney =
NumberFormat.currency(symbol: '\$', decimalDigits: 2);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: vehicle + status
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (r.vehicle != null)
Text(
r.vehicle!.displayName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: Color(0xFF111928),
),
)
else
const Text('Vehicle unavailable',
style:
TextStyle(color: Color(0xFF9CA3AF))),
if (r.vehicle != null)
Text(
r.vehicle!.licensePlate,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280)),
),
],
),
),
StatusBadge(status: r.status),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// Customer
_InfoRow(
icon: Icons.person_outline,
label: r.customer != null
? '${r.customer!.firstName} ${r.customer!.lastName}'
: 'Unknown customer',
sub: r.customer?.email,
),
const SizedBox(height: 8),
// Dates
_InfoRow(
icon: Icons.calendar_today_outlined,
label:
'${fmt.format(r.startDate)}${fmt.format(r.endDate)}',
sub: '${r.days} day${r.days == 1 ? '' : 's'}',
),
const SizedBox(height: 8),
// Amount
_InfoRow(
icon: Icons.attach_money,
label: fmtMoney.format(r.totalAmount / 100),
sub: 'Total',
),
const SizedBox(height: 14),
// Actions
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.close, size: 16),
label: const Text('Decline'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFE02424),
side: const BorderSide(color: Color(0xFFE02424)),
),
onPressed: () =>
_showDeclineDialog(context, ref, r.id),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.check, size: 16),
label: const Text('Confirm'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF057A55),
),
onPressed: () => _confirm(context, ref, r.id),
),
),
],
),
],
),
),
);
}
Future<void> _confirm(
BuildContext context, WidgetRef ref, String id) async {
try {
await ref.read(reservationServiceProvider).confirmReservation(id);
onAction();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Reservation confirmed'),
backgroundColor: Color(0xFF057A55),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to confirm: $e')),
);
}
}
}
void _showDeclineDialog(
BuildContext context, WidgetRef ref, String id) {
final reasonCtrl = TextEditingController();
bool saving = false;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setState) => AlertDialog(
title: const Text('Decline Reservation'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Optionally provide a reason — it will be recorded on the reservation.',
style:
TextStyle(fontSize: 13, color: Color(0xFF6B7280)),
),
const SizedBox(height: 12),
TextField(
controller: reasonCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Reason (optional)',
hintText:
'e.g. Vehicle not available for these dates',
),
),
],
),
actions: [
TextButton(
onPressed: saving ? null : () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE02424)),
onPressed: saving
? null
: () async {
setState(() => saving = true);
try {
await ref
.read(reservationServiceProvider)
.cancelReservation(
id,
reasonCtrl.text.trim().isEmpty
? 'Declined by company'
: reasonCtrl.text.trim());
onAction();
if (ctx.mounted) Navigator.pop(ctx);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Reservation declined')),
);
}
} catch (e) {
setState(() => saving = false);
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content:
Text('Failed to decline: $e')),
);
}
}
},
child: saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white)),
)
: const Text('Decline'),
),
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String? sub;
const _InfoRow({required this.icon, required this.label, this.sub});
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: const Color(0xFF9CA3AF)),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF374151))),
if (sub != null)
Text(sub!,
style: const TextStyle(
fontSize: 11, color: Color(0xFF9CA3AF))),
],
),
),
],
);
}
@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/error_view.dart';
class ReportsScreen extends ConsumerStatefulWidget {
const ReportsScreen({super.key});
@override
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
}
class _ReportsScreenState extends ConsumerState<ReportsScreen> {
DateTime _start = DateTime.now().subtract(const Duration(days: 30));
DateTime _end = DateTime.now();
String? _statusFilter;
Map<String, String> get _params => {
'startDate':
'${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z',
'endDate': '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
'status': ?_statusFilter,
};
Future<void> _pickRange() async {
final range = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime.now(),
initialDateRange: DateTimeRange(start: _start, end: _end),
);
if (range != null) {
setState(() {
_start = range.start;
_end = range.end;
});
}
}
@override
Widget build(BuildContext context) {
final fmt = DateFormat('MMM d, yyyy');
final fmtMoney = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
final reportAsync = ref.watch(reportProvider(_params));
return Scaffold(
appBar: AppBar(
title: const Text('Reports'),
actions: [
PopupMenuButton<String?>(
icon: Icon(
Icons.filter_list,
color: _statusFilter != null ? const Color(0xFF1A56DB) : null,
),
onSelected: (v) => setState(() => _statusFilter = v),
itemBuilder: (_) => const [
PopupMenuItem(value: null, child: Text('All statuses')),
PopupMenuItem(value: 'CONFIRMED', child: Text('Confirmed')),
PopupMenuItem(value: 'ACTIVE', child: Text('Active')),
PopupMenuItem(value: 'CLOSED', child: Text('Closed')),
PopupMenuItem(value: 'CANCELLED', child: Text('Cancelled')),
],
),
],
),
body: Column(
children: [
_DateRangeBar(
start: _start,
end: _end,
fmt: fmt,
onTap: _pickRange,
),
Expanded(
child: reportAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: 'Failed to load report',
onRetry: () => ref.invalidate(reportProvider(_params)),
),
data: (report) => CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverToBoxAdapter(
child: Column(
children: [
Row(
children: [
Expanded(
child: _MetricCard(
label: 'Reservations',
value:
report.totalReservations.toString(),
icon: Icons.event_note,
color: const Color(0xFF1A56DB),
),
),
const SizedBox(width: 12),
Expanded(
child: _MetricCard(
label: 'Revenue',
value: fmtMoney
.format(report.rentalRevenue / 100),
icon: Icons.attach_money,
color: const Color(0xFF0E9F6E),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MetricCard(
label: 'Collected',
value: fmtMoney
.format(report.totalPaid / 100),
icon: Icons.check_circle_outline,
color: const Color(0xFF0E9F6E),
),
),
const SizedBox(width: 12),
Expanded(
child: _MetricCard(
label: 'Outstanding',
value: fmtMoney
.format(report.totalOutstanding / 100),
icon: Icons.pending_outlined,
color: const Color(0xFFE02424),
),
),
],
),
],
),
),
),
if (report.items.isNotEmpty) ...[
const SliverPadding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverToBoxAdapter(
child: Text(
'Reservations',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.separated(
itemCount: report.items.length,
separatorBuilder: (_, _) =>
const Divider(height: 1),
itemBuilder: (_, i) {
final item = report.items[i];
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
item.customerName ?? 'Walk-in',
style: const TextStyle(
fontWeight: FontWeight.w500),
),
subtitle: Text(
'${item.vehicleName ?? 'Vehicle'}${fmt.format(item.startDate)} ${fmt.format(item.endDate)}',
style: const TextStyle(fontSize: 12),
),
trailing: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
fmtMoney.format(item.totalAmount / 100),
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
Text(
item.paymentStatus,
style: TextStyle(
fontSize: 11,
color: item.paymentStatus == 'PAID'
? const Color(0xFF0E9F6E)
: const Color(0xFF9CA3AF),
),
),
],
),
);
},
),
),
],
],
),
),
),
],
),
);
}
}
class _DateRangeBar extends StatelessWidget {
final DateTime start;
final DateTime end;
final DateFormat fmt;
final VoidCallback onTap;
const _DateRangeBar({
required this.start,
required this.end,
required this.fmt,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
color: Color(0xFFF9FAFB),
border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))),
),
child: Row(
children: [
const Icon(Icons.date_range_outlined,
size: 18, color: Color(0xFF6B7280)),
const SizedBox(width: 8),
Text(
'${fmt.format(start)}${fmt.format(end)}',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const Spacer(),
const Text('Change',
style: TextStyle(
color: Color(0xFF1A56DB), fontSize: 13)),
],
),
),
);
}
}
class _MetricCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
const _MetricCard({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: color),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
),
),
);
}
}
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../providers/dashboard_providers.dart';
import '../../../core/services/reservation_service.dart';
import '../../../widgets/status_badge.dart';
import '../../../widgets/error_view.dart';
@@ -13,6 +15,8 @@ class ReservationDetailScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(reservationDetailProvider(id));
final paymentsAsync = ref.watch(reservationPaymentsProvider(id));
final inspectionsAsync = ref.watch(inspectionsProvider(id));
return async.when(
loading: () =>
@@ -31,13 +35,22 @@ class ReservationDetailScreen extends ConsumerWidget {
res.status == 'DRAFT' || res.status == 'CONFIRMED';
final canCheckin = res.status == 'CONFIRMED';
final canCheckout = res.status == 'ACTIVE';
final canClose = res.status == 'COMPLETED';
final canRecordPayment = (res.status != 'CANCELLED') &&
res.paymentStatus != 'PAID';
return Scaffold(
appBar: AppBar(
title: Text('Reservation #${id.substring(0, 8)}'),
actions: [
IconButton(
icon: const Icon(Icons.description_outlined),
tooltip: 'View Contract',
onPressed: () => context.push(
'/dashboard/reservations/$id/contract'),
),
StatusBadge(status: res.status),
const SizedBox(width: 16),
const SizedBox(width: 8),
],
),
body: ListView(
@@ -82,7 +95,7 @@ class ReservationDetailScreen extends ConsumerWidget {
children: [
_InfoRow(
label: 'Total',
value: '\$${res.totalAmount.toStringAsFixed(2)}'),
value: '\$${(res.totalAmount / 100).toStringAsFixed(2)}'),
Row(
children: [
const Text('Status: ',
@@ -94,7 +107,105 @@ class ReservationDetailScreen extends ConsumerWidget {
],
),
),
paymentsAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (payments) {
if (payments.isEmpty) return const SizedBox.shrink();
return _Section(
title: 'Payment History',
child: Column(
children: payments
.map((p) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
p.paymentMethod,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13),
),
Text(
DateFormat('MMM d, yyyy')
.format(p.createdAt),
style: const TextStyle(
fontSize: 11,
color: Color(0xFF9CA3AF)),
),
],
),
),
Text(
'\$${(p.amount / 100).toStringAsFixed(2)}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF0E9F6E),
),
),
],
),
))
.toList(),
),
);
},
),
// Inspections
inspectionsAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (inspections) {
final checkin = inspections
.where((i) => i.type == 'CHECKIN')
.firstOrNull;
final checkout = inspections
.where((i) => i.type == 'CHECKOUT')
.firstOrNull;
final canEditCheckin = res.status == 'CONFIRMED' ||
res.status == 'ACTIVE';
final canEditCheckout = res.status == 'ACTIVE' ||
res.status == 'COMPLETED';
if (!canEditCheckin && !canEditCheckout && inspections.isEmpty) {
return const SizedBox.shrink();
}
return _Section(
title: 'Inspections',
child: Column(
children: [
_InspectionCard(
label: 'Departure',
inspection: checkin,
canEdit: canEditCheckin,
onEdit: () => _showInspectionSheet(
context, ref, 'CHECKIN', checkin),
),
const SizedBox(height: 10),
_InspectionCard(
label: 'Return',
inspection: checkout,
canEdit: canEditCheckout,
onEdit: () => _showInspectionSheet(
context, ref, 'CHECKOUT', checkout),
),
],
),
);
},
),
const SizedBox(height: 16),
if (canRecordPayment)
_ActionButton(
label: 'Record Payment',
color: const Color(0xFF0E9F6E),
icon: Icons.payments_outlined,
onTap: () => _showPaymentDialog(context, ref),
),
if (canConfirm)
_ActionButton(
label: 'Confirm Reservation',
@@ -121,6 +232,37 @@ class ReservationDetailScreen extends ConsumerWidget {
icon: Icons.logout,
onTap: () => _showMileageDialog(context, ref, 'checkout'),
),
if (canClose)
_ActionButton(
label: 'Close Reservation',
color: const Color(0xFF5521B5),
icon: Icons.lock_outline,
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Close Reservation'),
content: const Text(
'Mark this reservation as closed? This finalises the rental.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Close'),
),
],
),
);
if (confirmed == true && context.mounted) {
await ref
.read(reservationServiceProvider)
.closeReservation(id);
ref.invalidate(reservationDetailProvider(id));
}
},
),
if (canCancel)
_ActionButton(
label: 'Cancel Reservation',
@@ -135,6 +277,91 @@ class ReservationDetailScreen extends ConsumerWidget {
);
}
void _showPaymentDialog(BuildContext context, WidgetRef ref) {
final amountCtrl = TextEditingController();
String method = 'CASH';
String type = 'CHARGE';
const methods = ['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD'];
const types = ['CHARGE', 'DEPOSIT'];
showDialog(
context: context,
builder: (_) => StatefulBuilder(
builder: (ctx, setState) => AlertDialog(
title: const Text('Record Payment'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: amountCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Amount (\$)'),
),
const SizedBox(height: 12),
InputDecorator(
decoration: const InputDecoration(labelText: 'Method'),
child: DropdownButton<String>(
value: method,
isExpanded: true,
underline: const SizedBox(),
items: methods
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
.toList(),
onChanged: (v) => setState(() => method = v!),
),
),
const SizedBox(height: 12),
InputDecorator(
decoration: const InputDecoration(labelText: 'Type'),
child: DropdownButton<String>(
value: type,
isExpanded: true,
underline: const SizedBox(),
items: types
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
onChanged: (v) => setState(() => type = v!),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0E9F6E)),
onPressed: () async {
final dollars = double.tryParse(amountCtrl.text);
if (dollars == null || dollars <= 0) return;
Navigator.pop(ctx);
try {
await ref.read(paymentServiceProvider).recordManualPayment(
id,
{
'amount': (dollars * 100).round(),
'currency': 'USD',
'type': type,
'paymentMethod': method,
},
);
ref.invalidate(reservationDetailProvider(id));
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Payment failed: $e')));
}
}
},
child: const Text('Record'),
),
],
),
),
);
}
void _showMileageDialog(
BuildContext context, WidgetRef ref, String type) {
final ctrl = TextEditingController();
@@ -178,6 +405,24 @@ class ReservationDetailScreen extends ConsumerWidget {
);
}
void _showInspectionSheet(
BuildContext context,
WidgetRef ref,
String type,
ReservationInspection? existing,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => _InspectionSheet(
reservationId: id,
type: type,
existing: existing,
onSaved: () => ref.invalidate(inspectionsProvider(id)),
),
);
}
void _showCancelDialog(BuildContext context, WidgetRef ref) {
final ctrl = TextEditingController();
showDialog(
@@ -293,3 +538,364 @@ class _ActionButton extends StatelessWidget {
);
}
}
// ── Fuel level helpers ────────────────────────────────────────────────────────
const _fuelLevels = [
'FULL',
'SEVEN_EIGHTHS',
'THREE_QUARTERS',
'FIVE_EIGHTHS',
'HALF',
'THREE_EIGHTHS',
'QUARTER',
'ONE_EIGHTH',
'EMPTY',
];
const _fuelLabels = {
'FULL': 'Full',
'SEVEN_EIGHTHS': '7/8',
'THREE_QUARTERS': '3/4',
'FIVE_EIGHTHS': '5/8',
'HALF': '1/2',
'THREE_EIGHTHS': '3/8',
'QUARTER': '1/4',
'ONE_EIGHTH': '1/8',
'EMPTY': 'Empty',
};
double _fuelFraction(String level) {
const fractions = {
'FULL': 1.0,
'SEVEN_EIGHTHS': 0.875,
'THREE_QUARTERS': 0.75,
'FIVE_EIGHTHS': 0.625,
'HALF': 0.5,
'THREE_EIGHTHS': 0.375,
'QUARTER': 0.25,
'ONE_EIGHTH': 0.125,
'EMPTY': 0.0,
};
return fractions[level] ?? 0;
}
// ── InspectionCard ────────────────────────────────────────────────────────────
class _InspectionCard extends StatelessWidget {
final String label;
final ReservationInspection? inspection;
final bool canEdit;
final VoidCallback onEdit;
const _InspectionCard({
required this.label,
required this.inspection,
required this.canEdit,
required this.onEdit,
});
@override
Widget build(BuildContext context) {
final i = inspection;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF9FAFB),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: Color(0xFF374151))),
const SizedBox(height: 6),
if (i == null)
const Text('Not recorded',
style: TextStyle(
fontSize: 12, color: Color(0xFF9CA3AF)))
else ...[
if (i.fuelLevel != null)
_FuelBar(level: i.fuelLevel!),
if (i.mileage != null) ...[
const SizedBox(height: 4),
Text('${i.mileage} km',
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280))),
],
if (i.generalCondition != null &&
i.generalCondition!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(i.generalCondition!,
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280))),
],
if (i.employeeNotes != null &&
i.employeeNotes!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(i.employeeNotes!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF9CA3AF),
fontStyle: FontStyle.italic)),
],
],
],
),
),
if (canEdit)
IconButton(
icon: Icon(
i == null ? Icons.add_circle_outline : Icons.edit_outlined,
size: 20,
color: const Color(0xFF1A56DB),
),
onPressed: onEdit,
),
],
),
);
}
}
class _FuelBar extends StatelessWidget {
final String level;
const _FuelBar({required this.level});
@override
Widget build(BuildContext context) {
final fraction = _fuelFraction(level);
final label = _fuelLabels[level] ?? level;
final color = fraction > 0.5
? const Color(0xFF057A55)
: fraction > 0.25
? const Color(0xFFFF6B00)
: const Color(0xFFE02424);
return Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: fraction,
minHeight: 8,
backgroundColor: const Color(0xFFE5E7EB),
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
),
const SizedBox(width: 8),
Text(label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color)),
],
);
}
}
// ── InspectionSheet ───────────────────────────────────────────────────────────
class _InspectionSheet extends ConsumerStatefulWidget {
final String reservationId;
final String type; // CHECKIN | CHECKOUT
final ReservationInspection? existing;
final VoidCallback onSaved;
const _InspectionSheet({
required this.reservationId,
required this.type,
this.existing,
required this.onSaved,
});
@override
ConsumerState<_InspectionSheet> createState() => _InspectionSheetState();
}
class _InspectionSheetState extends ConsumerState<_InspectionSheet> {
late String _fuelLevel;
final _mileageCtrl = TextEditingController();
final _conditionCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
bool _saving = false;
String? _error;
@override
void initState() {
super.initState();
final e = widget.existing;
_fuelLevel = e?.fuelLevel ?? 'FULL';
if (e?.mileage != null) _mileageCtrl.text = '${e!.mileage}';
_conditionCtrl.text = e?.generalCondition ?? '';
_notesCtrl.text = e?.employeeNotes ?? '';
}
@override
void dispose() {
_mileageCtrl.dispose();
_conditionCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_saving = true;
_error = null;
});
try {
await ref.read(reservationServiceProvider).upsertInspection(
widget.reservationId,
widget.type,
{
'fuelLevel': _fuelLevel,
if (_mileageCtrl.text.isNotEmpty)
'mileage': int.parse(_mileageCtrl.text),
if (_conditionCtrl.text.trim().isNotEmpty)
'generalCondition': _conditionCtrl.text.trim(),
if (_notesCtrl.text.trim().isNotEmpty)
'employeeNotes': _notesCtrl.text.trim(),
},
);
widget.onSaved();
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() {
_saving = false;
_error = 'Failed to save. Please try again.';
});
}
}
@override
Widget build(BuildContext context) {
final title = widget.type == 'CHECKIN'
? 'Departure Inspection'
: 'Return Inspection';
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// Fuel level
const Text('Fuel Level',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF374151))),
const SizedBox(height: 8),
_FuelBar(level: _fuelLevel),
const SizedBox(height: 8),
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _fuelLevels.length,
separatorBuilder: (_, _) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final level = _fuelLevels[i];
final selected = level == _fuelLevel;
return GestureDetector(
onTap: () => setState(() => _fuelLevel = level),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: selected
? const Color(0xFF1A56DB)
: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_fuelLabels[level] ?? level,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: selected
? Colors.white
: const Color(0xFF374151),
),
),
),
);
},
),
),
const SizedBox(height: 14),
// Mileage
TextField(
controller: _mileageCtrl,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(labelText: 'Mileage (km)'),
),
const SizedBox(height: 12),
// General condition
TextField(
controller: _conditionCtrl,
decoration: const InputDecoration(
labelText: 'General Condition (optional)'),
),
const SizedBox(height: 12),
// Notes
TextField(
controller: _notesCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Notes (optional)',
alignLabelWithHint: true,
),
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(_error!,
style:
const TextStyle(color: Color(0xFF9B1C1C))),
],
const SizedBox(height: 20),
ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white)),
)
: Text(widget.existing == null
? 'Record Inspection'
: 'Update Inspection'),
),
],
),
),
);
}
}
@@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart';
import '../../../widgets/reservation_card.dart';
import '../../../widgets/loading_list.dart';
import '../../../widgets/error_view.dart';
import 'dashboard_shell.dart';
import 'new_reservation_screen.dart';
class ReservationsScreen extends ConsumerStatefulWidget {
const ReservationsScreen({super.key});
@@ -16,8 +18,24 @@ class ReservationsScreen extends ConsumerStatefulWidget {
class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabs;
final _statuses = [null, 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
final _labels = ['All', 'Confirmed', 'Active', 'Completed', 'Cancelled'];
final _statuses = [
null,
'DRAFT',
'CONFIRMED',
'ACTIVE',
'COMPLETED',
'CLOSED',
'CANCELLED',
];
final _labels = [
'All',
'Pending',
'Confirmed',
'Active',
'Completed',
'Closed',
'Cancelled',
];
final _searchController = TextEditingController();
String _search = '';
@@ -35,15 +53,27 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
}
Map<String, dynamic> _params(int tabIndex) => {
'page': 1,
'pageSize': 50,
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
if (_search.isNotEmpty) 'search': _search,
};
'page': 1,
'pageSize': 50,
if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex],
if (_search.isNotEmpty) 'search': _search,
};
@override
Widget build(BuildContext context) {
return Scaffold(
return DashboardShell(
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => const NewReservationScreen()),
);
if (result == true) {
ref.invalidate(reservationsProvider);
setState(() {});
}
},
child: const Icon(Icons.add),
),
appBar: AppBar(
title: const Text('Reservations'),
bottom: TabBar(
@@ -74,8 +104,7 @@ class _ReservationsScreenState extends ConsumerState<ReservationsScreen>
_statuses.length,
(i) => _ReservationTab(
params: _params(i),
onTap: (id) =>
context.push('/dashboard/reservations/$id'),
onTap: (id) => context.push('/dashboard/reservations/$id'),
),
),
),
@@ -103,8 +132,11 @@ class _ReservationTab extends ConsumerWidget {
),
data: (res) => res.data.isEmpty
? const Center(
child: Text('No reservations found',
style: TextStyle(color: Color(0xFF9CA3AF))))
child: Text(
'No reservations found',
style: TextStyle(color: Color(0xFF9CA3AF)),
),
)
: RefreshIndicator(
onRefresh: () async =>
ref.invalidate(reservationsProvider(params)),
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/error_view.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(brandSettingsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Brand Settings')),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: 'Failed to load settings',
onRetry: () => ref.invalidate(brandSettingsProvider),
),
data: (settings) => _SettingsForm(settings: settings),
),
);
}
}
class _SettingsForm extends ConsumerStatefulWidget {
final dynamic settings;
const _SettingsForm({required this.settings});
@override
ConsumerState<_SettingsForm> createState() => _SettingsFormState();
}
class _SettingsFormState extends ConsumerState<_SettingsForm> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
late final _displayName =
TextEditingController(text: widget.settings.displayName as String);
late final _tagline =
TextEditingController(text: widget.settings.tagline as String?);
late final _email =
TextEditingController(text: widget.settings.publicEmail as String?);
late final _phone =
TextEditingController(text: widget.settings.publicPhone as String?);
late final _city =
TextEditingController(text: widget.settings.publicCity as String?);
late final _country =
TextEditingController(text: widget.settings.publicCountry as String?);
late final _website =
TextEditingController(text: widget.settings.websiteUrl as String?);
late final _whatsapp =
TextEditingController(text: widget.settings.whatsappNumber as String?);
late bool _listed = widget.settings.isListedOnMarketplace as bool;
@override
void dispose() {
for (final c in [
_displayName, _tagline, _email, _phone, _city, _country, _website, _whatsapp
]) {
c.dispose();
}
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _loading = true);
try {
await ref.read(settingsServiceProvider).updateBrand({
'displayName': _displayName.text.trim(),
if (_tagline.text.isNotEmpty) 'tagline': _tagline.text.trim(),
if (_email.text.isNotEmpty) 'publicEmail': _email.text.trim(),
if (_phone.text.isNotEmpty) 'publicPhone': _phone.text.trim(),
if (_city.text.isNotEmpty) 'publicCity': _city.text.trim(),
if (_country.text.isNotEmpty) 'publicCountry': _country.text.trim(),
if (_website.text.isNotEmpty) 'websiteUrl': _website.text.trim(),
if (_whatsapp.text.isNotEmpty) 'whatsappNumber': _whatsapp.text.trim(),
'isListedOnMarketplace': _listed,
});
ref.invalidate(brandSettingsProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings saved')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_section('Brand', [
_field('Display Name', _displayName, required: true),
_field('Tagline', _tagline),
]),
const SizedBox(height: 16),
_section('Contact', [
_field('Public Email', _email,
inputType: TextInputType.emailAddress),
_field('Public Phone', _phone, inputType: TextInputType.phone),
_field('WhatsApp Number', _whatsapp,
inputType: TextInputType.phone),
_field('Website URL', _website, inputType: TextInputType.url),
]),
const SizedBox(height: 16),
_section('Location', [
_field('City', _city),
_field('Country', _country),
]),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Listed on Marketplace'),
subtitle: const Text(
'Show your fleet on the public marketplace',
),
value: _listed,
onChanged: (v) => setState(() => _listed = v),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save Settings'),
),
],
),
);
}
Widget _section(String title, List<Widget> children) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF6B7280))),
const SizedBox(height: 10),
Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(children: children),
),
),
],
);
}
Widget _field(String label, TextEditingController ctrl,
{bool required = false, TextInputType? inputType}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
controller: ctrl,
keyboardType: inputType,
decoration: InputDecoration(labelText: label),
validator: required
? (v) => v == null || v.isEmpty ? 'Required' : null
: null,
),
);
}
}
@@ -0,0 +1,341 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/dashboard_providers.dart';
import '../../../widgets/error_view.dart';
import '../../../core/providers/auth_provider.dart';
class TeamScreen extends ConsumerWidget {
const TeamScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(teamProvider);
final myRole = ref.watch(authProvider).employee?.role ?? '';
final isOwner = myRole == 'OWNER';
return Scaffold(
appBar: AppBar(title: const Text('Team')),
floatingActionButton: isOwner
? FloatingActionButton.extended(
onPressed: () async {
final invited = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => const _InviteSheet(),
);
if (invited == true) ref.invalidate(teamProvider);
},
icon: const Icon(Icons.person_add_outlined),
label: const Text('Invite'),
)
: null,
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: 'Failed to load team',
onRetry: () => ref.invalidate(teamProvider),
),
data: (members) {
if (members.isEmpty) {
return const Center(
child: Text('No team members',
style: TextStyle(color: Color(0xFF9CA3AF))),
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(teamProvider),
child: ListView.separated(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: members.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (_, i) => _MemberCard(
member: members[i],
isOwner: isOwner,
onDeactivate: () async {
await ref
.read(teamServiceProvider)
.deactivateMember(members[i].id);
ref.invalidate(teamProvider);
},
onReactivate: () async {
await ref
.read(teamServiceProvider)
.reactivateMember(members[i].id);
ref.invalidate(teamProvider);
},
onRoleChange: (role) async {
await ref
.read(teamServiceProvider)
.updateRole(members[i].id, role);
ref.invalidate(teamProvider);
},
),
),
);
},
),
);
}
}
class _MemberCard extends StatelessWidget {
final dynamic member;
final bool isOwner;
final VoidCallback onDeactivate;
final VoidCallback onReactivate;
final ValueChanged<String> onRoleChange;
const _MemberCard({
required this.member,
required this.isOwner,
required this.onDeactivate,
required this.onReactivate,
required this.onRoleChange,
});
@override
Widget build(BuildContext context) {
final isActive = member.isActive as bool;
final role = member.role as String;
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
CircleAvatar(
backgroundColor: isActive
? const Color(0xFFE1EFFE)
: const Color(0xFFF3F4F6),
radius: 22,
child: Text(
(member.firstName as String)
.substring(0, 1)
.toUpperCase(),
style: TextStyle(
color: isActive
? const Color(0xFF1A56DB)
: const Color(0xFF9CA3AF),
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
member.fullName as String,
style: TextStyle(
fontWeight: FontWeight.w600,
color: isActive ? null : const Color(0xFF9CA3AF),
),
),
const SizedBox(width: 8),
_RoleBadge(role: role),
],
),
Text(
member.email as String,
style: const TextStyle(
fontSize: 12, color: Color(0xFF6B7280)),
),
if (!isActive)
const Text(
'Inactive',
style: TextStyle(
fontSize: 11, color: Color(0xFF9CA3AF)),
),
],
),
),
if (isOwner && role != 'OWNER')
PopupMenuButton<String>(
onSelected: (v) {
if (v == 'deactivate') onDeactivate();
if (v == 'reactivate') onReactivate();
if (v == 'MANAGER' || v == 'AGENT') onRoleChange(v);
},
itemBuilder: (_) => [
if (role != 'MANAGER')
const PopupMenuItem(
value: 'MANAGER', child: Text('Make Manager')),
if (role != 'AGENT')
const PopupMenuItem(
value: 'AGENT', child: Text('Make Agent')),
const PopupMenuDivider(),
if (isActive)
const PopupMenuItem(
value: 'deactivate',
child: Text('Deactivate',
style: TextStyle(color: Color(0xFFE02424))),
)
else
const PopupMenuItem(
value: 'reactivate',
child: Text('Reactivate',
style: TextStyle(color: Color(0xFF0E9F6E))),
),
],
),
],
),
),
);
}
}
class _RoleBadge extends StatelessWidget {
final String role;
const _RoleBadge({required this.role});
@override
Widget build(BuildContext context) {
final colors = {
'OWNER': (const Color(0xFFFFF3CD), const Color(0xFF92400E)),
'MANAGER': (const Color(0xFFE1EFFE), const Color(0xFF1E40AF)),
'AGENT': (const Color(0xFFF3F4F6), const Color(0xFF374151)),
};
final (bg, fg) = colors[role] ?? (const Color(0xFFF3F4F6), const Color(0xFF374151));
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration:
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
child: Text(role,
style: TextStyle(
fontSize: 10, color: fg, fontWeight: FontWeight.w600)),
);
}
}
class _InviteSheet extends ConsumerStatefulWidget {
const _InviteSheet();
@override
ConsumerState<_InviteSheet> createState() => _InviteSheetState();
}
class _InviteSheetState extends ConsumerState<_InviteSheet> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
final _firstName = TextEditingController();
final _lastName = TextEditingController();
final _email = TextEditingController();
String _role = 'AGENT';
@override
void dispose() {
for (final c in [_firstName, _lastName, _email]) {
c.dispose();
}
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _loading = true);
try {
await ref.read(teamServiceProvider).inviteMember({
'firstName': _firstName.text.trim(),
'lastName': _lastName.text.trim(),
'email': _email.text.trim(),
'role': _role,
});
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('Invite Team Member',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
const Spacer(),
if (_loading)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
else
TextButton(
onPressed: _submit, child: const Text('Invite')),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _firstName,
decoration: const InputDecoration(labelText: 'First Name'),
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _lastName,
decoration: const InputDecoration(labelText: 'Last Name'),
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Email'),
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 12),
InputDecorator(
decoration: const InputDecoration(labelText: 'Role'),
child: DropdownButton<String>(
value: _role,
isExpanded: true,
underline: const SizedBox(),
items: const [
DropdownMenuItem(value: 'MANAGER', child: Text('Manager')),
DropdownMenuItem(value: 'AGENT', child: Text('Agent')),
],
onChanged: (v) => setState(() => _role = v!),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _loading ? null : _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
child: const Text('Send Invitation'),
),
],
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,347 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/models/vehicle.dart';
import '../providers/dashboard_providers.dart';
class VehicleFormScreen extends ConsumerStatefulWidget {
final Vehicle? vehicle;
const VehicleFormScreen({super.key, this.vehicle});
@override
ConsumerState<VehicleFormScreen> createState() => _VehicleFormScreenState();
}
class _VehicleFormScreenState extends ConsumerState<VehicleFormScreen> {
final _formKey = GlobalKey<FormState>();
bool _loading = false;
late final _make = TextEditingController(text: widget.vehicle?.make);
late final _model = TextEditingController(text: widget.vehicle?.model);
late final _year = TextEditingController(
text: widget.vehicle?.year.toString() ?? DateTime.now().year.toString());
late final _plate =
TextEditingController(text: widget.vehicle?.licensePlate);
late final _rate = TextEditingController(
text: widget.vehicle != null
? (widget.vehicle!.dailyRate / 100).toStringAsFixed(2)
: '');
late final _mileage = TextEditingController(
text: widget.vehicle?.mileage?.toString());
late final _seats = TextEditingController(
text: widget.vehicle?.seats?.toString() ?? '5');
late final _color = TextEditingController(text: widget.vehicle?.color ?? '');
String _category = 'ECONOMY';
String _transmission = 'AUTOMATIC';
String _fuelType = 'GASOLINE';
static const _categories = [
'ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK',
];
static const _transmissions = ['AUTOMATIC', 'MANUAL'];
static const _fuels = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'];
@override
void initState() {
super.initState();
if (widget.vehicle != null) {
_category = widget.vehicle!.category;
_transmission = widget.vehicle!.transmission ?? 'AUTOMATIC';
_fuelType = widget.vehicle!.fuelType ?? 'GASOLINE';
}
}
@override
void dispose() {
for (final c in [_make, _model, _year, _plate, _rate, _mileage, _seats, _color]) {
c.dispose();
}
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _loading = true);
try {
final data = {
'make': _make.text.trim(),
'model': _model.text.trim(),
'year': int.parse(_year.text),
'licensePlate': _plate.text.trim(),
'dailyRate': (double.parse(_rate.text) * 100).round(),
'category': _category,
'transmission': _transmission,
'fuelType': _fuelType,
'seats': int.tryParse(_seats.text) ?? 5,
if (_mileage.text.isNotEmpty) 'mileage': int.parse(_mileage.text),
if (_color.text.isNotEmpty) 'color': _color.text.trim(),
};
final svc = ref.read(vehicleServiceProvider);
if (widget.vehicle == null) {
await svc.createVehicle(data);
} else {
await svc.updateVehicle(widget.vehicle!.id, data);
}
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.vehicle != null;
return Scaffold(
appBar: AppBar(
title: Text(isEdit ? 'Edit Vehicle' : 'New Vehicle'),
actions: [
if (_loading)
const Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2)),
)
else
TextButton(
onPressed: _submit,
child: const Text('Save'),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_section('Basic Info', [
_field('Make', _make, required: true),
_field('Model', _model, required: true),
_field('Year', _year, required: true,
inputType: TextInputType.number),
_field('License Plate', _plate, required: true),
_field('Color', _color),
]),
const SizedBox(height: 16),
_section('Specs', [
_dropdown('Category', _category, _categories,
(v) => setState(() => _category = v!)),
_dropdown('Transmission', _transmission, _transmissions,
(v) => setState(() => _transmission = v!)),
_dropdown('Fuel Type', _fuelType, _fuels,
(v) => setState(() => _fuelType = v!)),
_field('Seats', _seats, inputType: TextInputType.number),
_field('Mileage (km)', _mileage,
inputType: TextInputType.number),
]),
const SizedBox(height: 16),
_section('Pricing', [
_field('Daily Rate (\$)', _rate,
required: true, inputType: TextInputType.number),
]),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _submit,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: Text(isEdit ? 'Save Changes' : 'Create Vehicle'),
),
],
),
),
);
}
Widget _section(String title, List<Widget> children) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF6B7280))),
const SizedBox(height: 10),
Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(children: children),
),
),
],
);
}
Widget _field(String label, TextEditingController ctrl,
{bool required = false, TextInputType? inputType}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
controller: ctrl,
keyboardType: inputType,
decoration: InputDecoration(labelText: label),
validator: required
? (v) => v == null || v.isEmpty ? 'Required' : null
: null,
),
);
}
Widget _dropdown(String label, String value, List<String> items,
ValueChanged<String?> onChanged) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: InputDecorator(
decoration: InputDecoration(labelText: label),
child: DropdownButton<String>(
value: value,
isExpanded: true,
underline: const SizedBox(),
items: items
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: onChanged,
),
),
);
}
}
class VehiclePhotosSheet extends ConsumerStatefulWidget {
final String vehicleId;
final List<String> currentPhotos;
const VehiclePhotosSheet({
super.key,
required this.vehicleId,
required this.currentPhotos,
});
@override
ConsumerState<VehiclePhotosSheet> createState() => _VehiclePhotosSheetState();
}
class _VehiclePhotosSheetState extends ConsumerState<VehiclePhotosSheet> {
bool _uploading = false;
Future<void> _pickAndUpload() async {
final picker = ImagePicker();
final files = await picker.pickMultiImage(imageQuality: 80);
if (files.isEmpty) return;
setState(() => _uploading = true);
try {
await ref
.read(vehicleServiceProvider)
.uploadPhotos(widget.vehicleId, files.map((f) => f.path).toList());
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Upload failed: $e')));
}
} finally {
if (mounted) setState(() => _uploading = false);
}
}
Future<void> _deletePhoto(int idx) async {
try {
await ref
.read(vehicleServiceProvider)
.deletePhoto(widget.vehicleId, idx);
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Delete failed: $e')));
}
}
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text('Photos',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const Spacer(),
if (_uploading)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
else
IconButton(
icon: const Icon(Icons.add_photo_alternate_outlined),
onPressed: _pickAndUpload,
),
],
),
const SizedBox(height: 12),
if (widget.currentPhotos.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text('No photos yet',
style: TextStyle(color: Color(0xFF9CA3AF))),
)
else
SizedBox(
height: 100,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: widget.currentPhotos.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, i) => Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
widget.currentPhotos[i],
width: 100,
height: 100,
fit: BoxFit.cover,
),
),
Positioned(
top: 2,
right: 2,
child: GestureDetector(
onTap: () => _deletePhoto(i),
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Color(0xFFE02424),
shape: BoxShape.circle,
),
child: const Icon(Icons.close,
size: 14, color: Colors.white),
),
),
),
],
),
),
),
const SizedBox(height: 8),
],
),
),
);
}
}
@@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart';
import '../../../widgets/vehicle_card.dart';
import '../../../widgets/loading_list.dart';
import '../../../widgets/error_view.dart';
import 'dashboard_shell.dart';
import 'vehicle_form_screen.dart';
class VehiclesScreen extends ConsumerStatefulWidget {
const VehiclesScreen({super.key});
@@ -25,17 +27,26 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
}
Map<String, dynamic> get _params => {
'page': 1,
'pageSize': 50,
if (_statusFilter != null) 'status': _statusFilter,
if (_search.isNotEmpty) 'search': _search,
};
'page': 1,
'pageSize': 50,
if (_statusFilter != null) 'status': _statusFilter,
if (_search.isNotEmpty) 'search': _search,
};
@override
Widget build(BuildContext context) {
final vehiclesAsync = ref.watch(vehiclesProvider(_params));
return Scaffold(
return DashboardShell(
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => const VehicleFormScreen()),
);
if (result == true) ref.invalidate(vehiclesProvider(_params));
},
child: const Icon(Icons.add),
),
appBar: AppBar(
title: const Text('Vehicles'),
bottom: PreferredSize(
@@ -57,9 +68,7 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
PopupMenuButton<String?>(
icon: Icon(
Icons.filter_list,
color: _statusFilter != null
? const Color(0xFF1A56DB)
: null,
color: _statusFilter != null ? const Color(0xFF1A56DB) : null,
),
onSelected: (v) => setState(() => _statusFilter = v),
itemBuilder: (_) => [
@@ -67,7 +76,9 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
const PopupMenuItem(value: 'AVAILABLE', child: Text('Available')),
const PopupMenuItem(value: 'RENTED', child: Text('Rented')),
const PopupMenuItem(
value: 'MAINTENANCE', child: Text('Maintenance')),
value: 'MAINTENANCE',
child: Text('Maintenance'),
),
],
),
],
@@ -80,15 +91,17 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
),
data: (res) => res.data.isEmpty
? const Center(
child: Text('No vehicles found',
style: TextStyle(color: Color(0xFF9CA3AF))))
child: Text(
'No vehicles found',
style: TextStyle(color: Color(0xFF9CA3AF)),
),
)
: RefreshIndicator(
onRefresh: () async =>
ref.invalidate(vehiclesProvider(_params)),
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisExtent: 240,
mainAxisSpacing: 12,
@@ -96,8 +109,8 @@ class _VehiclesScreenState extends ConsumerState<VehiclesScreen> {
itemCount: res.data.length,
itemBuilder: (_, i) => VehicleCard(
vehicle: res.data[i],
onTap: () => context
.push('/dashboard/vehicles/${res.data[i].id}'),
onTap: () =>
context.push('/dashboard/vehicles/${res.data[i].id}'),
),
),
),