add dashboard to phone app

This commit is contained in:
root
2026-05-25 05:10:43 -04:00
parent d04c8ce437
commit 277db06d26
67 changed files with 7872 additions and 570 deletions
@@ -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();
}
}