add dashboard to phone app
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user