Added report functionality and Dropbox connection

This commit is contained in:
Courtney Arnold 2026-08-13 17:43:42 -05:00
parent 483fbeafb6
commit c7494eda92
8 changed files with 614 additions and 2 deletions

View file

@ -24,14 +24,14 @@ class CloudOAuthConfig {
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
/// The app's key (client ID for OAuth2 PKCE — no secret needed).
static const String? dropboxAppKey = null; // TODO: fill in
static const String? dropboxAppKey = 'id43qmk09hilpgk';
/// Custom URL scheme redirect registered in the Dropbox app console.
/// The scheme "mofueltaxback-dropbox" is already wired up in
/// AndroidManifest.xml / Info.plist, so unless you have a reason to pick
/// a different scheme, use exactly:
/// "mofueltaxback-dropbox://oauth2redirect"
static const String? dropboxRedirectUri = null; // TODO: fill in
static const String? dropboxRedirectUri = 'mofueltaxback-dropbox://oauth2redirect';
// --- OneDrive (Microsoft Graph) ---
//

View file

@ -0,0 +1,81 @@
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
/// One vehicle's totals within a [FuelReport]'s date range.
class VehicleReportRow {
final Vehicle vehicle;
final int entryCount;
final double totalGallons;
final double totalCost;
VehicleReportRow({
required this.vehicle,
required this.entryCount,
required this.totalGallons,
required this.totalCost,
});
}
/// 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);
}
/// 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;
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),
));
}
rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel));
return FuelReport(startDate: rangeStart, endDate: endDate, rows: rows);
}

View file

@ -0,0 +1,85 @@
import 'dart:typed_data';
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'fuel_report.dart';
final _dateFormat = DateFormat.yMMMd();
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 {
final doc = pw.Document();
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),
],
),
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();
}
/// `fuel-report-<start>-<end>.pdf`, e.g. `fuel-report-20260101-20260331.pdf`.
String fuelReportFileName(FuelReport report) {
final fmt = DateFormat('yyyyMMdd');
return 'fuel-report-${fmt.format(report.startDate)}-${fmt.format(report.endDate)}.pdf';
}