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 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 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 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> get entriesByMonth { final map = >{}; 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 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), ), ]; } } /// The date range the Reports tab defaults to on open: a year-long window /// from July 1 through the following June 30, matching how Missouri's /// fuel tax refund periods run. Which specific year that window falls in /// rolls over on August 1: from August of year Y through July of year /// Y+1, this stays July Y–June (Y+1) throughout — the most recently /// completed (or currently running) period — rather than jumping to a /// brand new, still-empty one the moment August 1 hits. (DateTime start, DateTime end) defaultReportDateRange(DateTime now) { final fiscalStartYear = now.month >= 8 ? now.year : now.year - 1; return (DateTime(fiscalStartYear, 7, 1), DateTime(fiscalStartYear + 1, 6, 30)); } /// 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 vehicles, required List 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 = >{}; 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 = []; 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); }