666 lines
22 KiB
Dart
666 lines
22 KiB
Dart
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();
|
||
}
|
||
}
|