diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0462665..d6ae292 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/vehicle.dart'; import '../services/app_state.dart'; import 'add_edit_vehicle_screen.dart'; +import 'report_screen.dart'; import 'settings_screen.dart'; import 'vehicle_detail_screen.dart'; @@ -19,6 +20,13 @@ class HomeScreen extends StatelessWidget { appBar: AppBar( title: const Text('My Vehicles'), actions: [ + IconButton( + icon: const Icon(Icons.summarize_outlined), + tooltip: 'Fuel Report', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ReportScreen()), + ), + ), IconButton( icon: const Icon(Icons.settings_outlined), tooltip: 'Settings', diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart new file mode 100644 index 0000000..c3a76b4 --- /dev/null +++ b/lib/screens/report_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:printing/printing.dart'; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; +import '../services/fuel_report.dart'; +import '../services/fuel_report_pdf.dart'; + +class ReportScreen extends StatefulWidget { + const ReportScreen({super.key}); + + @override + State createState() => _ReportScreenState(); +} + +class _ReportScreenState extends State { + static final _dateFormat = DateFormat.yMMMd(); + + DateTime? _startDate; + DateTime? _endDate; + bool _busy = false; + + Future _pickDate({required bool isStart}) async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: (isStart ? _startDate : _endDate) ?? now, + firstDate: DateTime(2000), + lastDate: now, + ); + if (picked == null) return; + setState(() { + if (isStart) { + _startDate = picked; + } else { + _endDate = picked; + } + }); + } + + Future _share(FuelReport report) async { + setState(() => _busy = true); + try { + final bytes = await buildFuelReportPdf(report); + await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _print(FuelReport report) async { + setState(() => _busy = true); + try { + final bytes = await buildFuelReportPdf(report); + await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + + String? rangeError; + FuelReport? report; + if (_startDate != null && _endDate != null) { + if (_endDate!.isBefore(_startDate!)) { + rangeError = 'End date must be on or after the start date.'; + } else { + report = buildFuelReport( + vehicles: appState.vehicles, + fuelEntries: appState.fuelEntries, + startDate: _startDate!, + endDate: _endDate!, + ); + } + } + + return Scaffold( + appBar: AppBar(title: const Text('Fuel Report')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _pickDate(isStart: true), + child: Text(_startDate == null ? 'Start date' : _dateFormat.format(_startDate!)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + onPressed: () => _pickDate(isStart: false), + child: Text(_endDate == null ? 'End date' : _dateFormat.format(_endDate!)), + ), + ), + ], + ), + if (rangeError != null) ...[ + const SizedBox(height: 8), + Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 16), + if (report == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text('Choose a start and end date to generate a report.'), + ) + else if (report.rows.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text('No vehicles had a receipted fuel entry in that date range.'), + ) + else ...[ + for (final row in report.rows) + Card( + child: ListTile( + title: Text(row.vehicle.displayLabel), + subtitle: Text( + 'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted ' + 'entr${row.entryCount == 1 ? 'y' : 'ies'}', + ), + isThreeLine: true, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('${row.totalGallons.toStringAsFixed(3)} gal'), + Text(NumberFormat.simpleCurrency().format(row.totalCost)), + ], + ), + ), + ), + const Divider(height: 32), + ListTile( + title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)), + trailing: Text( + '${report.totalGallons.toStringAsFixed(3)} gal · ' + '${NumberFormat.simpleCurrency().format(report.totalCost)}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _busy ? null : () => _share(report!), + icon: const Icon(Icons.share_outlined), + label: const Text('Share'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _print(report!), + icon: const Icon(Icons.print_outlined), + label: const Text('Print'), + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/lib/services/cloud_oauth_config.dart b/lib/services/cloud_oauth_config.dart index 811c963..e919d69 100644 --- a/lib/services/cloud_oauth_config.dart +++ b/lib/services/cloud_oauth_config.dart @@ -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) --- // diff --git a/lib/services/fuel_report.dart b/lib/services/fuel_report.dart new file mode 100644 index 0000000..4691b1d --- /dev/null +++ b/lib/services/fuel_report.dart @@ -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 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 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; + 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); +} diff --git a/lib/services/fuel_report_pdf.dart b/lib/services/fuel_report_pdf.dart new file mode 100644 index 0000000..2490c00 --- /dev/null +++ b/lib/services/fuel_report_pdf.dart @@ -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 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--.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'; +} diff --git a/pubspec.lock b/pubspec.lock index b945cb3..84b9923 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.7" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -25,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -392,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" image_picker: dependency: "direct main" description: @@ -608,6 +640,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -656,6 +696,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" petitparser: dependency: transitive description: @@ -680,6 +736,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + printing: + dependency: "direct main" + description: + name: printing + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" + url: "https://pub.dev" + source: hosted + version: "5.14.3" provider: dependency: "direct main" description: @@ -696,6 +768,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" record_use: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3209e7d..2d8e6e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -89,6 +89,8 @@ dependencies: # OS-backed encrypted storage (Keystore/Keychain) for WebDAV credentials, # so sign-in survives a cold app restart instead of living in memory only. flutter_secure_storage: ^10.0.0 + pdf: ^3.12.0 + printing: ^5.14.3 dev_dependencies: flutter_test: diff --git a/test/fuel_report_test.dart b/test/fuel_report_test.dart new file mode 100644 index 0000000..6ffca48 --- /dev/null +++ b/test/fuel_report_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/services/fuel_report.dart'; + +void main() { + final truck = Vehicle( + id: 'v1', + vin: '1FMPU18L1TLB51349', + nickname: 'Truck', + updatedAt: DateTime.utc(2026, 1, 1), + ); + final car = Vehicle( + id: 'v2', + vin: '1GNWK5EG4DR302326', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + FuelEntry entry({ + required String id, + required String vehicleId, + required DateTime date, + double gallons = 10, + double totalCost = 35, + String? receiptImagePath, + String? receiptDriveFileId, + }) => + FuelEntry( + id: id, + vehicleId: vehicleId, + date: date, + gallons: gallons, + pricePerGallon: totalCost / gallons, + totalCost: totalCost, + updatedAt: date, + receiptImagePath: receiptImagePath, + receiptDriveFileId: receiptDriveFileId, + ); + + test('sums gallons and cost per vehicle, only for entries with a receipt', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 5), + gallons: 10, + totalCost: 35, + receiptImagePath: '/local/a.jpg'), + entry( + id: 'e2', + vehicleId: 'v1', + date: DateTime(2026, 3, 20), + gallons: 12, + totalCost: 42, + receiptDriveFileId: 'remote-1'), + // No receipt at all — should be excluded even though it's in range. + entry(id: 'e3', vehicleId: 'v1', date: DateTime(2026, 3, 10), gallons: 99, totalCost: 999), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows, hasLength(1)); + expect(report.rows.single.vehicle.id, 'v1'); + expect(report.rows.single.entryCount, 2); + expect(report.rows.single.totalGallons, 22); + expect(report.rows.single.totalCost, 77); + }); + + test('date range is inclusive of both endpoints', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: [ + entry( + id: 'first-day', + vehicleId: 'v1', + date: DateTime(2026, 3, 1), + receiptImagePath: '/a.jpg'), + entry( + id: 'last-day', + vehicleId: 'v1', + date: DateTime(2026, 3, 31), + receiptImagePath: '/b.jpg'), + entry( + id: 'day-after', + vehicleId: 'v1', + date: DateTime(2026, 4, 1), + receiptImagePath: '/c.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows.single.entryCount, 2); + }); + + test('a receipt-photo time-of-day does not push it out of range', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: [ + entry( + id: 'late-in-day', + vehicleId: 'v1', + date: DateTime(2026, 3, 31, 23, 59), + receiptImagePath: '/a.jpg', + ), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows.single.entryCount, 1); + }); + + test('omits vehicles with no qualifying entries rather than a zero row', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows, hasLength(1)); + expect(report.rows.single.vehicle.id, 'v1'); + }); + + test('rows are sorted by vehicle display label', () { + final zVehicle = Vehicle( + id: 'v3', + vin: 'ZZZ00000000000000', + nickname: 'Zeta', + updatedAt: DateTime.utc(2026, 1, 1), + ); + final aVehicle = Vehicle( + id: 'v4', + vin: 'AAA00000000000000', + nickname: 'Alpha', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + final report = buildFuelReport( + vehicles: [zVehicle, aVehicle], + fuelEntries: [ + entry(id: 'e1', vehicleId: 'v3', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'), + entry(id: 'e2', vehicleId: 'v4', date: DateTime(2026, 3, 5), receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows.map((r) => r.vehicle.id), ['v4', 'v3']); + }); + + test('report totals sum across all vehicle rows', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 5), + gallons: 10, + totalCost: 35, + receiptImagePath: '/a.jpg'), + entry( + id: 'e2', + vehicleId: 'v2', + date: DateTime(2026, 3, 6), + gallons: 8, + totalCost: 28, + receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.totalGallons, 18); + expect(report.totalCost, 63); + }); +}