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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user