A whole bunch of changes
This commit is contained in:
parent
901ec678a3
commit
956231048e
122 changed files with 5442 additions and 914 deletions
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show ThemeMode;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ const _prefsKeyCloudFolderPath = 'cloud_folder_path';
|
|||
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
||||
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
||||
const _prefsKeyThemeMode = 'theme_mode';
|
||||
|
||||
const defaultStaleLockMinutes = 10;
|
||||
const minStaleLockMinutes = 1;
|
||||
|
|
@ -85,6 +87,8 @@ class AppState extends ChangeNotifier {
|
|||
/// the original camera/gallery image is kept as-is.
|
||||
bool keepMaxQualityReceiptPhotos = false;
|
||||
|
||||
ThemeMode themeMode = ThemeMode.system;
|
||||
|
||||
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
||||
/// of spinning forever — an unhandled exception here previously left
|
||||
/// `isLoading` stuck at true with no feedback at all.
|
||||
|
|
@ -109,6 +113,10 @@ class AppState extends ChangeNotifier {
|
|||
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
|
||||
keepMaxQualityReceiptPhotos =
|
||||
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
|
||||
themeMode = ThemeMode.values.firstWhere(
|
||||
(mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
|
||||
orElse: () => ThemeMode.system,
|
||||
);
|
||||
} catch (e) {
|
||||
initError = e;
|
||||
isLoading = false;
|
||||
|
|
@ -289,6 +297,13 @@ class AppState extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
themeMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyThemeMode, mode.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
|
||||
/// Settings "Advanced" section, which restricts the picker to that range
|
||||
/// anyway; this is a defensive backstop for any other caller.
|
||||
|
|
@ -373,6 +388,34 @@ class AppState extends ChangeNotifier {
|
|||
return entry;
|
||||
}
|
||||
|
||||
/// Corrects the logged values (date, gallons, price/gal, total cost) for
|
||||
/// an existing fuel entry — the receipt photo itself isn't editable here,
|
||||
/// only the data recorded about it (e.g. fixing a misread OCR value).
|
||||
/// [entryId]'s receipt photo reference (local path and/or cloud file id)
|
||||
/// carries over untouched.
|
||||
Future<void> updateFuelEntry({
|
||||
required String entryId,
|
||||
required DateTime date,
|
||||
required double gallons,
|
||||
required double pricePerGallon,
|
||||
required double totalCost,
|
||||
}) async {
|
||||
final existing = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||
final updated = FuelEntry(
|
||||
id: existing.id,
|
||||
vehicleId: existing.vehicleId,
|
||||
date: date,
|
||||
gallons: gallons,
|
||||
pricePerGallon: pricePerGallon,
|
||||
totalCost: totalCost,
|
||||
receiptImagePath: existing.receiptImagePath,
|
||||
receiptDriveFileId: existing.receiptDriveFileId,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await database.saveFuelEntry(updated);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> deleteFuelEntry(String entryId) async {
|
||||
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||
await database.deleteReceiptImageFile(entry.receiptImagePath);
|
||||
|
|
@ -380,6 +423,30 @@ class AppState extends ChangeNotifier {
|
|||
await _persist();
|
||||
}
|
||||
|
||||
/// Wipes every vehicle, fuel entry, and local receipt photo — "starting
|
||||
/// completely fresh". Uses the same soft-delete tombstones as any other
|
||||
/// delete rather than a hard SQL `DELETE`, so if a cloud sync is
|
||||
/// connected, the deletion pushes on next sync instead of the old data
|
||||
/// just getting silently re-imported from the remote copy. This is purely
|
||||
/// local + whatever propagates through sync — it never reaches into the
|
||||
/// cloud folder directly to delete anything sitting there.
|
||||
Future<void> purgeAllData() async {
|
||||
await database.purgeAllData(DateTime.now().toUtc());
|
||||
await _persist();
|
||||
}
|
||||
|
||||
/// Wipes every fuel entry (and its local receipt photo) dated within
|
||||
/// [start]–[end] inclusive, across all vehicles. Vehicles themselves are
|
||||
/// left alone. Same soft-delete/sync-propagation reasoning as
|
||||
/// [purgeAllData].
|
||||
Future<void> purgeFuelEntriesInRange(DateTime start, DateTime end) async {
|
||||
final localPaths = await database.purgeFuelEntriesInRange(start, end, DateTime.now().toUtc());
|
||||
for (final path in localPaths) {
|
||||
await database.deleteReceiptImageFile(path);
|
||||
}
|
||||
await _persist();
|
||||
}
|
||||
|
||||
List<FuelEntry> entriesForVehicle(String vehicleId) {
|
||||
final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList();
|
||||
list.sort((a, b) => b.date.compareTo(a.date));
|
||||
|
|
@ -401,6 +468,14 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
FuelEntry? fuelEntryById(String id) {
|
||||
try {
|
||||
return fuelEntries.firstWhere((e) => e.id == id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact match against an active vehicle's VIN — same comparison
|
||||
/// [DatabaseService.vinExists] does, not case-insensitive, since VIN
|
||||
/// isn't normalized to any particular case on manual entry (only OCR
|
||||
|
|
|
|||
|
|
@ -267,6 +267,67 @@ class DatabaseService {
|
|||
return copied.path;
|
||||
}
|
||||
|
||||
/// Soft-deletes every active vehicle and fuel entry — as if the user had
|
||||
/// deleted each one individually (see [softDeleteVehicle]/
|
||||
/// [softDeleteFuelEntry] for why these aren't real `DELETE`s: the
|
||||
/// tombstones are what let the deletion propagate to other synced devices
|
||||
/// on the next sync, rather than the rows just being silently re-imported
|
||||
/// from the cloud copy). Also empties the local receipts folder outright
|
||||
/// rather than deleting file-by-file, to catch any orphaned file a
|
||||
/// tracked path might have missed.
|
||||
Future<void> purgeAllData(DateTime deletedAt) async {
|
||||
final ts = deletedAt.millisecondsSinceEpoch;
|
||||
await _db.update(
|
||||
'fuel_entries',
|
||||
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
|
||||
where: 'deleted_at IS NULL',
|
||||
);
|
||||
await _db.update(
|
||||
'vehicles',
|
||||
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
|
||||
where: 'deleted_at IS NULL',
|
||||
);
|
||||
|
||||
if (await receiptsDirectory.exists()) {
|
||||
await receiptsDirectory.delete(recursive: true);
|
||||
}
|
||||
await receiptsDirectory.create(recursive: true);
|
||||
}
|
||||
|
||||
/// Soft-deletes every active fuel entry (across all vehicles) whose
|
||||
/// [FuelEntry.date] falls within [start]–[end] inclusive, and returns
|
||||
/// their local receipt image paths so the caller can clean those files up
|
||||
/// too. Vehicles are left untouched — a date range only makes sense
|
||||
/// against the fuel entries logged against them, not the vehicles
|
||||
/// themselves.
|
||||
Future<List<String>> purgeFuelEntriesInRange(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
DateTime deletedAt,
|
||||
) async {
|
||||
final rangeStart = DateTime(start.year, start.month, start.day).millisecondsSinceEpoch;
|
||||
final rangeEndExclusive =
|
||||
DateTime(end.year, end.month, end.day).add(const Duration(days: 1)).millisecondsSinceEpoch;
|
||||
final ts = deletedAt.millisecondsSinceEpoch;
|
||||
|
||||
final rows = await _db.query(
|
||||
'fuel_entries',
|
||||
where: 'deleted_at IS NULL AND date >= ? AND date < ?',
|
||||
whereArgs: [rangeStart, rangeEndExclusive],
|
||||
);
|
||||
final localPaths =
|
||||
rows.map((r) => r['receipt_image_path'] as String?).whereType<String>().toList();
|
||||
|
||||
await _db.update(
|
||||
'fuel_entries',
|
||||
{'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
|
||||
where: 'deleted_at IS NULL AND date >= ? AND date < ?',
|
||||
whereArgs: [rangeStart, rangeEndExclusive],
|
||||
);
|
||||
|
||||
return localPaths;
|
||||
}
|
||||
|
||||
Future<void> deleteReceiptImageFile(String? path) async {
|
||||
if (path == null) return;
|
||||
final file = File(path);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,55 @@
|
|||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
|
||||
/// One vehicle's totals within a [FuelReport]'s date range.
|
||||
/// 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 int entryCount;
|
||||
final List<FuelEntry> entries;
|
||||
final double totalGallons;
|
||||
final double totalCost;
|
||||
|
||||
VehicleReportRow({
|
||||
required this.vehicle,
|
||||
required this.entryCount,
|
||||
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
|
||||
|
|
@ -27,6 +63,47 @@ class FuelReport {
|
|||
|
||||
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,
|
||||
|
|
@ -68,11 +145,12 @@ FuelReport buildFuelReport({
|
|||
// 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,
|
||||
entryCount: entry.value.length,
|
||||
totalGallons: entry.value.fold(0.0, (sum, e) => sum + e.gallons),
|
||||
totalCost: entry.value.fold(0.0, (sum, e) => sum + e.totalCost),
|
||||
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));
|
||||
|
|
|
|||
70
lib/services/fuel_report_images.dart
Normal file
70
lib/services/fuel_report_images.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import 'cloud/cloud_storage_provider.dart';
|
||||
import 'fuel_report.dart';
|
||||
|
||||
/// Result of [loadReceiptImageBytes]: the photo bytes that were resolved,
|
||||
/// plus which entries couldn't be — e.g. because the receipt was uploaded
|
||||
/// through a *different* cloud provider than the one currently connected
|
||||
/// (its stored reference is meaningless to the active provider's API), or
|
||||
/// the entry is offline/the remote file's since been deleted. Surfacing
|
||||
/// [failedEntries] instead of only ever silently dropping them is what
|
||||
/// makes that distinguishable from "there was never a receipt at all".
|
||||
class ReceiptImageLoadResult {
|
||||
final Map<String, Uint8List> bytesByEntryId;
|
||||
final List<FuelEntry> failedEntries;
|
||||
|
||||
ReceiptImageLoadResult({required this.bytesByEntryId, required this.failedEntries});
|
||||
}
|
||||
|
||||
/// Reads (or downloads) the receipt photo for every entry in [report].
|
||||
///
|
||||
/// Prefers the local copy when one's still on-device; falls back to
|
||||
/// downloading via [activeProvider] for entries that have already been
|
||||
/// uploaded and cleared locally. An entry that fails to download (offline,
|
||||
/// revoked cloud access, since-deleted remote file, a reference belonging
|
||||
/// to a different provider than the one currently connected, ...) is left
|
||||
/// out of [ReceiptImageLoadResult.bytesByEntryId] and reported in
|
||||
/// [ReceiptImageLoadResult.failedEntries] instead — one missing receipt
|
||||
/// photo shouldn't block the rest of the document, but it also shouldn't
|
||||
/// vanish without a trace.
|
||||
Future<ReceiptImageLoadResult> loadReceiptImageBytes({
|
||||
required FuelReport report,
|
||||
required CloudStorageProvider? activeProvider,
|
||||
}) async {
|
||||
final bytesByEntryId = <String, Uint8List>{};
|
||||
final failedEntries = <FuelEntry>[];
|
||||
CloudStorageSession? session;
|
||||
try {
|
||||
for (final row in report.rows) {
|
||||
for (final entry in row.entries) {
|
||||
final localPath = entry.receiptImagePath;
|
||||
if (localPath != null) {
|
||||
final file = File(localPath);
|
||||
if (await file.exists()) {
|
||||
bytesByEntryId[entry.id] = Uint8List.fromList(await file.readAsBytes());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
final driveFileId = entry.receiptDriveFileId;
|
||||
if (driveFileId == null || activeProvider == null || !activeProvider.isSignedIn) {
|
||||
failedEntries.add(entry);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
session ??= activeProvider.beginSession();
|
||||
final bytes = await session.downloadFileBytes(driveFileId);
|
||||
bytesByEntryId[entry.id] = Uint8List.fromList(bytes);
|
||||
} catch (_) {
|
||||
failedEntries.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
session?.close();
|
||||
}
|
||||
return ReceiptImageLoadResult(bytesByEntryId: bytesByEntryId, failedEntries: failedEntries);
|
||||
}
|
||||
|
|
@ -7,77 +7,596 @@ import 'package:pdf/widgets.dart' as pw;
|
|||
import 'fuel_report.dart';
|
||||
|
||||
final _dateFormat = DateFormat.yMMMd();
|
||||
final _dateTimeFormat = DateFormat.yMMMd().add_jm();
|
||||
final _monthFormat = DateFormat('MMM');
|
||||
final _monthHeadingFormat = DateFormat.yMMMM();
|
||||
final _dayMonthFormat = DateFormat('d MMM');
|
||||
final _generatedFormat = DateFormat('d MMM yyyy');
|
||||
final _currencyFormat = NumberFormat.simpleCurrency();
|
||||
|
||||
/// Renders [report] as a single-page-friendly PDF: a per-vehicle table
|
||||
/// (VIN, nickname, entry count, gallons, total cost) plus a grand-total
|
||||
/// row, suitable for attaching to a fuel tax refund submission or handing
|
||||
/// to a printer.
|
||||
Future<Uint8List> buildFuelReportPdf(FuelReport report) async {
|
||||
// Palette lifted from the report design mockup — kept in one place so
|
||||
// every section (banner, cards, tables, watermark) draws from the same
|
||||
// set of colors rather than each picking its own.
|
||||
final _bannerBackground = PdfColor.fromHex('#EAF3DE');
|
||||
final _bannerText = PdfColor.fromHex('#27500A');
|
||||
final _textPrimary = PdfColor.fromHex('#1F1E1C');
|
||||
final _textMuted = PdfColor.fromHex('#6B6A64');
|
||||
final _textFaint = PdfColor.fromHex('#94938C');
|
||||
final _borderLight = PdfColor.fromHex('#E5E3DB');
|
||||
final _borderStrong = PdfColor.fromHex('#CFCDC4');
|
||||
final _accentGreen = PdfColor.fromHex('#639922');
|
||||
final _accentGreenDark = PdfColor.fromHex('#3B6D11');
|
||||
final _cardBackground = PdfColor.fromHex('#F7F6F1');
|
||||
|
||||
final _tableBorder = pw.TableBorder.all(color: _borderLight, width: 0.5);
|
||||
final _tableHeaderDecoration = pw.BoxDecoration(color: PdfColor.fromHex('#EFEEE7'));
|
||||
final _tableEvenRowDecoration = const pw.BoxDecoration(color: PdfColors.white);
|
||||
final _tableOddRowDecoration = pw.BoxDecoration(color: _cardBackground);
|
||||
final _sectionHeadingStyle = pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary);
|
||||
|
||||
/// Renders [report] as a PDF, styled after the app's report design mockup:
|
||||
/// a branded letterhead banner, a headline total, three at-a-glance stat
|
||||
/// cards, a "gallons by month" bar chart + table, and a by-vehicle
|
||||
/// breakdown. When requested, a "Line Items" section (a table per vehicle)
|
||||
/// and/or a "Receipt Images" section (photos per vehicle, sized to
|
||||
/// actually be legible — not a small inline thumbnail) follow, each
|
||||
/// grouped by vehicle and sorted oldest-first within it. When both are on,
|
||||
/// each line item and its receipt photo are tagged with the same number
|
||||
/// (assigned once, continuing across vehicles) so the two can be matched
|
||||
/// up by eye despite living in separate sections.
|
||||
///
|
||||
/// [receiptImages] supplies the actual photo bytes, keyed by
|
||||
/// [FuelEntry.id] — see `loadReceiptImageBytes`; an entry with no entry in
|
||||
/// that map (couldn't be read locally or downloaded) is left out of the
|
||||
/// images section rather than shown as a broken image.
|
||||
Future<Uint8List> buildFuelReportPdf(
|
||||
FuelReport report, {
|
||||
bool includeLineItems = false,
|
||||
bool includeReceiptImages = false,
|
||||
Map<String, Uint8List> receiptImages = const {},
|
||||
}) async {
|
||||
final doc = pw.Document();
|
||||
final generatedAt = DateTime.now();
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.letter,
|
||||
header: (context) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text(
|
||||
'${_dateFormat.format(report.startDate)} – ${_dateFormat.format(report.endDate)}',
|
||||
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
|
||||
),
|
||||
pw.SizedBox(height: 16),
|
||||
],
|
||||
margin: pw.EdgeInsets.zero,
|
||||
header: (context) => _buildBanner(generatedAt),
|
||||
footer: (context) => _buildFooter(report, context),
|
||||
build: (context) => _buildBody(
|
||||
report,
|
||||
includeLineItems: includeLineItems,
|
||||
includeReceiptImages: includeReceiptImages,
|
||||
receiptImages: receiptImages,
|
||||
),
|
||||
build: (context) => [
|
||||
if (report.rows.isEmpty)
|
||||
pw.Text('No vehicles had a receipted fuel entry in this date range.')
|
||||
else
|
||||
pw.TableHelper.fromTextArray(
|
||||
headers: ['Vehicle', 'VIN', 'Entries', 'Gallons', 'Total Cost'],
|
||||
data: [
|
||||
for (final row in report.rows)
|
||||
[
|
||||
row.vehicle.nickname?.trim().isNotEmpty == true ? row.vehicle.nickname! : '—',
|
||||
row.vehicle.vin,
|
||||
row.entryCount.toString(),
|
||||
row.totalGallons.toStringAsFixed(3),
|
||||
_currencyFormat.format(row.totalCost),
|
||||
],
|
||||
],
|
||||
cellAlignments: const {
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
4: pw.Alignment.centerRight,
|
||||
},
|
||||
headerAlignments: const {
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
4: pw.Alignment.centerRight,
|
||||
},
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Divider(),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text(
|
||||
'${report.totalGallons.toStringAsFixed(3)} gal · ${_currencyFormat.format(report.totalCost)}',
|
||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
/// A *flat* list of top-level widgets — deliberately not one big [pw.Column]
|
||||
/// wrapping everything. [pw.MultiPage] paginates by walking its `build:`
|
||||
/// list one top-level item at a time, resetting its "stuck" bookkeeping
|
||||
/// only once each one fully finishes; a [pw.Table]/[pw.Wrap] (both of which
|
||||
/// span pages on their own, e.g. a long line-items table or a big receipt
|
||||
/// photo grid) nested *inside* one enclosing spanning [pw.Column] instead
|
||||
/// of being its own top-level entry defeats that bookkeeping and throws
|
||||
/// `TooManyPagesException` on anything long enough to need several page
|
||||
/// breaks — even though the content itself is perfectly valid.
|
||||
List<pw.Widget> _buildBody(
|
||||
FuelReport report, {
|
||||
required bool includeLineItems,
|
||||
required bool includeReceiptImages,
|
||||
required Map<String, Uint8List> receiptImages,
|
||||
}) {
|
||||
pw.Widget pad(pw.Widget child) =>
|
||||
pw.Padding(padding: const pw.EdgeInsets.symmetric(horizontal: 36), child: child);
|
||||
|
||||
if (report.rows.isEmpty) {
|
||||
return [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
|
||||
child: _buildTitleBlock(report),
|
||||
),
|
||||
pad(pw.Text('No vehicles had a receipted fuel entry in this date range.')),
|
||||
];
|
||||
}
|
||||
|
||||
final widgets = <pw.Widget>[
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
|
||||
child: _buildTitleBlock(report),
|
||||
),
|
||||
pad(_buildHeroStat(report)),
|
||||
pw.SizedBox(height: 22),
|
||||
pad(_buildStatCardsRow(report)),
|
||||
pw.SizedBox(height: 26),
|
||||
pad(_buildMonthlySection(report)),
|
||||
pw.SizedBox(height: 26),
|
||||
pad(_buildVehicleSection(report)),
|
||||
];
|
||||
|
||||
if (includeLineItems || includeReceiptImages) {
|
||||
widgets.add(pw.SizedBox(height: 26));
|
||||
widgets.addAll(
|
||||
_buildLineItemsAndImagesSections(
|
||||
report,
|
||||
includeLineItems: includeLineItems,
|
||||
includeReceiptImages: includeReceiptImages,
|
||||
receiptImages: receiptImages,
|
||||
).map(pad),
|
||||
);
|
||||
}
|
||||
|
||||
widgets.add(pw.SizedBox(height: 8));
|
||||
return widgets;
|
||||
}
|
||||
|
||||
pw.Widget _buildBanner(DateTime generatedAt) {
|
||||
// The watermark lives here, inside the banner's own bounded height,
|
||||
// rather than overlaid on the flowing body content below — a Stack
|
||||
// wrapping unbounded/multi-page content doesn't paginate (MultiPage
|
||||
// needs a SpanningWidget or a fixed-size child for that), so scoping it
|
||||
// to this fixed-height strip is what keeps it safe across page breaks.
|
||||
return pw.Container(
|
||||
color: _bannerBackground,
|
||||
child: pw.Stack(
|
||||
children: [
|
||||
pw.Positioned(
|
||||
right: 12,
|
||||
top: -14,
|
||||
child: pw.SizedBox(width: 60, height: 60, child: _buildWatermark(alpha: 0.14)),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 36, vertical: 14),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Show Me The Fuel Refund',
|
||||
style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold, color: _bannerText),
|
||||
),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.white,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Text(
|
||||
'Generated ${_generatedFormat.format(generatedAt)}',
|
||||
style: pw.TextStyle(fontSize: 10, color: _bannerText),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildFooter(FuelReport report, pw.Context context) {
|
||||
return pw.Container(
|
||||
padding: const pw.EdgeInsets.fromLTRB(36, 8, 36, 20),
|
||||
decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderStrong, width: 0.5))),
|
||||
margin: const pw.EdgeInsets.only(top: 6),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'${report.fillCount} purchase${report.fillCount == 1 ? '' : 's'} · '
|
||||
'${report.rows.length} vehicle${report.rows.length == 1 ? '' : 's'} · '
|
||||
'${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}',
|
||||
style: pw.TextStyle(fontSize: 9, color: _textFaint),
|
||||
),
|
||||
pw.Text(
|
||||
'Page ${context.pageNumber} of ${context.pagesCount}',
|
||||
style: pw.TextStyle(fontSize: 9, color: _textFaint),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A faint brand-colored fuel-can silhouette — purely decorative,
|
||||
/// matching the report mockup's watermark shape.
|
||||
pw.Widget _buildWatermark({required double alpha}) {
|
||||
const svg = '''
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12,8 L76,8 L79,22 L73,33 L83,47 L78,58 L84,67 L86,89 L77,89 L77,70 L20,70 L20,30 Z" fill="#639922"/>
|
||||
</svg>
|
||||
''';
|
||||
return pw.SvgImage(svg: svg, colorFilter: _accentGreen.withAlpha(alpha));
|
||||
}
|
||||
|
||||
pw.Widget _buildTitleBlock(FuelReport report) {
|
||||
final months = report.monthlyTotals.length;
|
||||
final vehicles = report.rows.length;
|
||||
final fills = report.fillCount;
|
||||
final subtitle = StringBuffer('${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}');
|
||||
if (report.rows.isNotEmpty) {
|
||||
subtitle.write(
|
||||
' · $months month${months == 1 ? '' : 's'} · $vehicles vehicle${vehicles == 1 ? '' : 's'} · '
|
||||
'$fills fill-up${fills == 1 ? '' : 's'}',
|
||||
);
|
||||
}
|
||||
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(bottom: 20),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Text(subtitle.toString(), style: pw.TextStyle(fontSize: 12, color: _textMuted)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildHeroStat(FuelReport report) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Total fuel purchased', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||
children: [
|
||||
pw.Text(
|
||||
report.totalGallons.toStringAsFixed(3),
|
||||
style: pw.TextStyle(fontSize: 34, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
|
||||
),
|
||||
pw.SizedBox(width: 6),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(bottom: 5),
|
||||
child: pw.Text('gal', style: pw.TextStyle(fontSize: 13, color: _accentGreenDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildStatCardsRow(FuelReport report) {
|
||||
pw.Widget statCard(String label, String value) => pw.Expanded(
|
||||
child: pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: _cardBackground,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(label, style: pw.TextStyle(fontSize: 10, color: _textMuted)),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Text(value, style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return pw.Row(
|
||||
children: [
|
||||
statCard('Spent at the pump', _currencyFormat.format(report.totalCost)),
|
||||
pw.SizedBox(width: 10),
|
||||
statCard('Avg price/gal', _currencyFormat.format(report.avgPricePerGallon)),
|
||||
pw.SizedBox(width: 10),
|
||||
statCard('Fill-ups logged', report.fillCount.toString()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildMonthlySection(FuelReport report) {
|
||||
final months = report.monthlyTotals;
|
||||
if (months.isEmpty) return pw.SizedBox();
|
||||
|
||||
const chartHeight = 64.0;
|
||||
final maxGallons = months.fold(0.0, (max, m) => m.totalGallons > max ? m.totalGallons : max);
|
||||
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Gallons by month', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
|
||||
pw.SizedBox(height: 8),
|
||||
if (months.length > 1) ...[
|
||||
pw.SizedBox(
|
||||
height: chartHeight,
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final month in months)
|
||||
pw.Expanded(
|
||||
child: pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 3),
|
||||
child: pw.Container(
|
||||
height: maxGallons == 0
|
||||
? 2
|
||||
: (chartHeight * (month.totalGallons / maxGallons)).clamp(2, chartHeight),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: _accentGreen,
|
||||
borderRadius: const pw.BorderRadius.only(
|
||||
topLeft: pw.Radius.circular(2),
|
||||
topRight: pw.Radius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Row(
|
||||
children: [
|
||||
for (final month in months)
|
||||
pw.Expanded(
|
||||
child: pw.Center(
|
||||
child: pw.Text(_monthFormat.format(month.month), style: pw.TextStyle(fontSize: 9, color: _textFaint)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
],
|
||||
pw.TableHelper.fromTextArray(
|
||||
headers: ['Month', 'Fills', 'Gallons', 'Spent', 'Avg/gal'],
|
||||
data: [
|
||||
for (final month in months)
|
||||
[
|
||||
_monthHeadingFormat.format(month.month),
|
||||
month.fillCount.toString(),
|
||||
month.totalGallons.toStringAsFixed(3),
|
||||
_currencyFormat.format(month.totalCost),
|
||||
_currencyFormat.format(month.avgPricePerGallon),
|
||||
],
|
||||
],
|
||||
cellAlignments: const {
|
||||
1: pw.Alignment.centerRight,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
4: pw.Alignment.centerRight,
|
||||
},
|
||||
headerAlignments: const {
|
||||
1: pw.Alignment.centerRight,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
4: pw.Alignment.centerRight,
|
||||
},
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
|
||||
cellStyle: const pw.TextStyle(fontSize: 10),
|
||||
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
border: _tableBorder,
|
||||
headerDecoration: _tableHeaderDecoration,
|
||||
rowDecoration: _tableEvenRowDecoration,
|
||||
oddRowDecoration: _tableOddRowDecoration,
|
||||
),
|
||||
if (months.length > 1) ...[
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10)),
|
||||
pw.Text(
|
||||
'${report.fillCount} · ${report.totalGallons.toStringAsFixed(3)} gal · '
|
||||
'${_currencyFormat.format(report.totalCost)} · ${_currencyFormat.format(report.avgPricePerGallon)}',
|
||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildVehicleSection(FuelReport report) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('By vehicle', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
|
||||
for (final row in report.rows)
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderLight, width: 0.5))),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Expanded(
|
||||
flex: 2,
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text(row.vehicle.vin, style: pw.TextStyle(fontSize: 9, color: _textFaint)),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Text(
|
||||
'${row.entryCount} fill${row.entryCount == 1 ? '' : 's'}',
|
||||
textAlign: pw.TextAlign.right,
|
||||
style: pw.TextStyle(fontSize: 11, color: _textMuted),
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Text(
|
||||
_currencyFormat.format(row.totalCost),
|
||||
textAlign: pw.TextAlign.right,
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Text(
|
||||
'${row.totalGallons.toStringAsFixed(3)} gal',
|
||||
textAlign: pw.TextAlign.right,
|
||||
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Item numbers (when both [includeLineItems] and [includeReceiptImages]
|
||||
/// are on) are assigned once, in report order — vehicle by vehicle,
|
||||
/// oldest-entry-first within each — and reused by both the "Line Items"
|
||||
/// table and the "Receipt Images" section below it, so a photo and its
|
||||
/// line item carry the same number despite living in separate sections.
|
||||
List<pw.Widget> _buildLineItemsAndImagesSections(
|
||||
FuelReport report, {
|
||||
required bool includeLineItems,
|
||||
required bool includeReceiptImages,
|
||||
required Map<String, Uint8List> receiptImages,
|
||||
}) {
|
||||
final showItemNumbers = includeLineItems && includeReceiptImages;
|
||||
final itemNumbers = <String, int>{};
|
||||
var nextNumber = 1;
|
||||
for (final row in report.rows) {
|
||||
for (final entry in row.entries) {
|
||||
itemNumbers[entry.id] = nextNumber++;
|
||||
}
|
||||
}
|
||||
|
||||
final widgets = <pw.Widget>[];
|
||||
|
||||
if (includeLineItems) {
|
||||
widgets.add(pw.Text('Line Items', style: _sectionHeadingStyle));
|
||||
for (final row in report.rows) {
|
||||
widgets.add(pw.SizedBox(height: 14));
|
||||
widgets.add(_buildVehicleSubheading(row));
|
||||
widgets.add(pw.SizedBox(height: 6));
|
||||
widgets.add(_buildLineItemsTable(row, showItemNumbers: showItemNumbers, itemNumbers: itemNumbers));
|
||||
}
|
||||
}
|
||||
|
||||
if (includeReceiptImages) {
|
||||
final rowsWithImages = report.rows.where((row) => row.entries.any((e) => receiptImages.containsKey(e.id)));
|
||||
if (rowsWithImages.isNotEmpty) {
|
||||
if (includeLineItems) widgets.add(pw.SizedBox(height: 26));
|
||||
widgets.add(pw.Text('Receipt Images', style: _sectionHeadingStyle));
|
||||
for (final row in rowsWithImages) {
|
||||
widgets.add(pw.SizedBox(height: 14));
|
||||
widgets.add(_buildVehicleSubheading(row));
|
||||
widgets.add(pw.SizedBox(height: 8));
|
||||
widgets.add(_buildReceiptImagesWrap(
|
||||
row,
|
||||
receiptImages,
|
||||
showItemNumbers: showItemNumbers,
|
||||
itemNumbers: itemNumbers,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
pw.Widget _buildVehicleSubheading(VehicleReportRow row) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text('VIN: ${row.vehicle.vin}', style: pw.TextStyle(fontSize: 9, color: _textFaint)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildLineItemsTable(
|
||||
VehicleReportRow row, {
|
||||
required bool showItemNumbers,
|
||||
required Map<String, int> itemNumbers,
|
||||
}) {
|
||||
return pw.TableHelper.fromTextArray(
|
||||
headers: [
|
||||
if (showItemNumbers) '#',
|
||||
'Date',
|
||||
'Gallons',
|
||||
'Price/Gal',
|
||||
'Total Cost',
|
||||
],
|
||||
data: [
|
||||
for (final entry in row.entries)
|
||||
[
|
||||
if (showItemNumbers) itemNumbers[entry.id].toString(),
|
||||
_dateTimeFormat.format(entry.date),
|
||||
entry.gallons.toStringAsFixed(3),
|
||||
_currencyFormat.format(entry.pricePerGallon),
|
||||
_currencyFormat.format(entry.totalCost),
|
||||
],
|
||||
],
|
||||
cellAlignments: showItemNumbers
|
||||
? const {
|
||||
0: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
4: pw.Alignment.centerRight,
|
||||
}
|
||||
: const {
|
||||
1: pw.Alignment.centerRight,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
},
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
|
||||
cellStyle: const pw.TextStyle(fontSize: 10),
|
||||
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
border: _tableBorder,
|
||||
headerDecoration: _tableHeaderDecoration,
|
||||
rowDecoration: _tableEvenRowDecoration,
|
||||
oddRowDecoration: _tableOddRowDecoration,
|
||||
);
|
||||
}
|
||||
|
||||
/// 140×140 — comfortably legible (the previous design's inline thumbnails,
|
||||
/// at ~34×44, were too small to make out) while still fitting several per
|
||||
/// row on a letter-width page.
|
||||
const _receiptImageSize = 140.0;
|
||||
|
||||
pw.Widget _buildReceiptImagesWrap(
|
||||
VehicleReportRow row,
|
||||
Map<String, Uint8List> receiptImages, {
|
||||
required bool showItemNumbers,
|
||||
required Map<String, int> itemNumbers,
|
||||
}) {
|
||||
return pw.Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (final entry in row.entries)
|
||||
if (receiptImages[entry.id] case final bytes?)
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.Container(
|
||||
width: _receiptImageSize,
|
||||
height: _receiptImageSize,
|
||||
decoration: pw.BoxDecoration(
|
||||
color: _cardBackground,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
|
||||
border: pw.Border.all(color: _borderLight, width: 0.5),
|
||||
),
|
||||
child: pw.ClipRRect(
|
||||
horizontalRadius: 6,
|
||||
verticalRadius: 6,
|
||||
child: pw.Image(pw.MemoryImage(bytes), fit: pw.BoxFit.cover),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
showItemNumbers
|
||||
? '#${itemNumbers[entry.id]} · ${_dayMonthFormat.format(entry.date)}'
|
||||
: _dayMonthFormat.format(entry.date),
|
||||
style: pw.TextStyle(fontSize: 9, color: _textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// `fuel-report-<start>-<end>.pdf`, e.g. `fuel-report-20260101-20260331.pdf`.
|
||||
String fuelReportFileName(FuelReport report) {
|
||||
final fmt = DateFormat('yyyyMMdd');
|
||||
|
|
|
|||
|
|
@ -2,18 +2,24 @@
|
|||
/// sticker, dashboard plate, title, etc).
|
||||
///
|
||||
/// Real VINs never contain the letters I, O, or Q (they're excluded from
|
||||
/// the standard specifically so they can't be confused with 1 and 0), so
|
||||
/// requiring that charset both matches genuine VINs and rules out a lot of
|
||||
/// incidental 17-character noise elsewhere on the sticker (barcodes text,
|
||||
/// weight ratings, date codes, etc).
|
||||
/// the standard specifically so they can't be confused with 1, 0, and 9 —
|
||||
/// which is exactly the kind of confusion OCR itself is prone to: a
|
||||
/// genuine VIN's "0" is sometimes read back as "O"). So rather than reject
|
||||
/// a candidate for containing an I or O, [_normalize] treats it as a
|
||||
/// misread digit and corrects it — Q is left alone, since it doesn't
|
||||
/// closely resemble any digit and so is more likely to mean the candidate
|
||||
/// isn't a VIN at all (e.g. noise from elsewhere on the sticker).
|
||||
class VinParser {
|
||||
// V\.?\s*I\.?\s*N tolerates the "V.I.N" (period between each letter)
|
||||
// styling common on manufacturer compliance/data plates, not just the
|
||||
// plain "VIN" a door-jamb sticker or title usually uses.
|
||||
static final _labeledVinPattern =
|
||||
RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
|
||||
RegExp(r'V\.?\s*I\.?\s*N[:.\s]*([A-PR-Z0-9]{17})\b', caseSensitive: false);
|
||||
|
||||
// \b on both sides matters: since digits and letters are both "word"
|
||||
// characters, this only matches a maximal run of exactly 17 eligible
|
||||
// characters — not a 17-character slice out of an 18+ character run.
|
||||
static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
|
||||
static final _bareVinPattern = RegExp(r'\b([A-PR-Z0-9]{17})\b', caseSensitive: false);
|
||||
|
||||
/// Returns the VIN in uppercase, or null if nothing matching the VIN
|
||||
/// charset/length was found. A labeled "VIN: ..." match is preferred
|
||||
|
|
@ -21,11 +27,24 @@ class VinParser {
|
|||
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
|
||||
static String? parse(String text) {
|
||||
final labeled = _labeledVinPattern.firstMatch(text);
|
||||
if (labeled != null) return labeled.group(1)!.toUpperCase();
|
||||
if (labeled != null) return _normalize(labeled.group(1)!);
|
||||
|
||||
final bare = _bareVinPattern.firstMatch(text);
|
||||
if (bare != null) return bare.group(1)!.toUpperCase();
|
||||
if (bare != null) return _normalize(bare.group(1)!);
|
||||
|
||||
// Last resort: OCR sometimes splits a VIN across a stray space (e.g.
|
||||
// an etched/curved surface gets read as two separate text regions) —
|
||||
// retry once against the text with all whitespace removed. Tried last,
|
||||
// after both whitespace-preserving attempts, since collapsing
|
||||
// whitespace elsewhere in a busy label risks gluing unrelated text
|
||||
// together into a spurious 17-character run.
|
||||
final stripped = text.replaceAll(RegExp(r'\s+'), '');
|
||||
final strippedMatch = _bareVinPattern.firstMatch(stripped);
|
||||
if (strippedMatch != null) return _normalize(strippedMatch.group(1)!);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _normalize(String candidate) =>
|
||||
candidate.toUpperCase().replaceAll('O', '0').replaceAll('I', '1');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue