159 lines
6.1 KiB
Dart
159 lines
6.1 KiB
Dart
import '../models/fuel_entry.dart';
|
||
import '../models/vehicle.dart';
|
||
|
||
/// One vehicle's totals — and, for the line-items/receipt-images report
|
||
/// sections, its individual entries — within a [FuelReport]'s date range.
|
||
/// [entries] is sorted oldest-first, the natural order for a ledger of
|
||
/// line items building up to a total.
|
||
class VehicleReportRow {
|
||
final Vehicle vehicle;
|
||
final List<FuelEntry> entries;
|
||
final double totalGallons;
|
||
final double totalCost;
|
||
|
||
VehicleReportRow({
|
||
required this.vehicle,
|
||
required this.entries,
|
||
required this.totalGallons,
|
||
required this.totalCost,
|
||
});
|
||
|
||
int get entryCount => entries.length;
|
||
}
|
||
|
||
/// One entry paired with the vehicle it belongs to — [FuelEntry] only knows
|
||
/// [FuelEntry.vehicleId], not the [Vehicle] itself, so anything that needs
|
||
/// to show both together (like [FuelReport.allEntries], for a combined
|
||
/// cross-vehicle list) needs this pairing.
|
||
class ReportEntry {
|
||
final FuelEntry entry;
|
||
final Vehicle vehicle;
|
||
|
||
ReportEntry({required this.entry, required this.vehicle});
|
||
}
|
||
|
||
/// Every qualifying entry in a single calendar month, across every vehicle
|
||
/// in the report — the "Gallons by month" breakdown is this, one per
|
||
/// month present in [FuelReport.monthlyTotals].
|
||
class MonthlyTotal {
|
||
/// The first of the month, e.g. `DateTime(2026, 8)` for August 2026.
|
||
final DateTime month;
|
||
final int fillCount;
|
||
final double totalGallons;
|
||
final double totalCost;
|
||
|
||
MonthlyTotal({
|
||
required this.month,
|
||
required this.fillCount,
|
||
required this.totalGallons,
|
||
required this.totalCost,
|
||
});
|
||
|
||
double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
|
||
}
|
||
|
||
/// Per-vehicle fuel totals for entries with a receipt, dated within
|
||
/// [startDate]–[endDate] inclusive. See [buildFuelReport].
|
||
class FuelReport {
|
||
final DateTime startDate;
|
||
final DateTime endDate;
|
||
final List<VehicleReportRow> rows;
|
||
|
||
FuelReport({required this.startDate, required this.endDate, required this.rows});
|
||
|
||
double get totalGallons => rows.fold(0.0, (sum, r) => sum + r.totalGallons);
|
||
double get totalCost => rows.fold(0.0, (sum, r) => sum + r.totalCost);
|
||
int get fillCount => rows.fold(0, (sum, r) => sum + r.entryCount);
|
||
double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
|
||
|
||
/// Every entry across every vehicle, oldest first — the natural order for
|
||
/// a combined "purchase detail" ledger that isn't grouped by vehicle.
|
||
List<ReportEntry> get allEntries {
|
||
final list = [
|
||
for (final row in rows)
|
||
for (final entry in row.entries) ReportEntry(entry: entry, vehicle: row.vehicle),
|
||
];
|
||
list.sort((a, b) => a.entry.date.compareTo(b.entry.date));
|
||
return list;
|
||
}
|
||
|
||
/// [allEntries] grouped by calendar month, in chronological order — the
|
||
/// "Purchase detail" section's grouping, and the source data for
|
||
/// [monthlyTotals].
|
||
Map<DateTime, List<ReportEntry>> get entriesByMonth {
|
||
final map = <DateTime, List<ReportEntry>>{};
|
||
for (final reportEntry in allEntries) {
|
||
final month = DateTime(reportEntry.entry.date.year, reportEntry.entry.date.month);
|
||
(map[month] ??= []).add(reportEntry);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/// One [MonthlyTotal] per calendar month with qualifying entries, oldest
|
||
/// first — the "Gallons by month" chart/table's data.
|
||
List<MonthlyTotal> get monthlyTotals {
|
||
final byMonth = entriesByMonth;
|
||
final months = byMonth.keys.toList()..sort();
|
||
return [
|
||
for (final month in months)
|
||
MonthlyTotal(
|
||
month: month,
|
||
fillCount: byMonth[month]!.length,
|
||
totalGallons: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.gallons),
|
||
totalCost: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.totalCost),
|
||
),
|
||
];
|
||
}
|
||
}
|
||
|
||
/// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive,
|
||
/// judged by each entry's purchase date, in local time), restricted to
|
||
/// entries that actually have a receipt attached — a report meant to
|
||
/// substantiate a fuel tax refund claim is only as good as the
|
||
/// documentation behind each entry, so an entry logged without a photo
|
||
/// isn't something the report should be claiming on your behalf. "Has a
|
||
/// receipt" means a local copy or a cloud copy — [FuelEntry.receiptImagePath]
|
||
/// or [FuelEntry.receiptDriveFileId] — not specifically an already-synced
|
||
/// one, since sync status is a technical detail unrelated to whether the
|
||
/// purchase is documented.
|
||
///
|
||
/// Vehicles with no qualifying entries in range are omitted rather than
|
||
/// shown with an all-zero row. Rows are sorted by [Vehicle.displayLabel].
|
||
FuelReport buildFuelReport({
|
||
required List<Vehicle> vehicles,
|
||
required List<FuelEntry> fuelEntries,
|
||
required DateTime startDate,
|
||
required DateTime endDate,
|
||
}) {
|
||
final rangeStart = DateTime(startDate.year, startDate.month, startDate.day);
|
||
final rangeEndExclusive =
|
||
DateTime(endDate.year, endDate.month, endDate.day).add(const Duration(days: 1));
|
||
|
||
final vehiclesById = {for (final v in vehicles) v.id: v};
|
||
final entriesByVehicle = <String, List<FuelEntry>>{};
|
||
|
||
for (final entry in fuelEntries) {
|
||
final hasReceipt = entry.receiptImagePath != null || entry.receiptDriveFileId != null;
|
||
if (!hasReceipt) continue;
|
||
if (entry.date.isBefore(rangeStart) || !entry.date.isBefore(rangeEndExclusive)) continue;
|
||
(entriesByVehicle[entry.vehicleId] ??= []).add(entry);
|
||
}
|
||
|
||
final rows = <VehicleReportRow>[];
|
||
for (final entry in entriesByVehicle.entries) {
|
||
final vehicle = vehiclesById[entry.key];
|
||
// No matching (active) vehicle — e.g. deleted since — nothing sane to
|
||
// attribute these entries to.
|
||
if (vehicle == null) continue;
|
||
final sortedEntries = entry.value.toList()..sort((a, b) => a.date.compareTo(b.date));
|
||
rows.add(VehicleReportRow(
|
||
vehicle: vehicle,
|
||
entries: sortedEntries,
|
||
totalGallons: sortedEntries.fold(0.0, (sum, e) => sum + e.gallons),
|
||
totalCost: sortedEntries.fold(0.0, (sum, e) => sum + e.totalCost),
|
||
));
|
||
}
|
||
rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel));
|
||
|
||
return FuelReport(startDate: rangeStart, endDate: endDate, rows: rows);
|
||
}
|