Files
2026-05-25 05:10:43 -04:00

666 lines
22 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}