Added report functionality and Dropbox connection
This commit is contained in:
parent
483fbeafb6
commit
c7494eda92
8 changed files with 614 additions and 2 deletions
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import 'add_edit_vehicle_screen.dart';
|
import 'add_edit_vehicle_screen.dart';
|
||||||
|
import 'report_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'vehicle_detail_screen.dart';
|
import 'vehicle_detail_screen.dart';
|
||||||
|
|
||||||
|
|
@ -19,6 +20,13 @@ class HomeScreen extends StatelessWidget {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('My Vehicles'),
|
title: const Text('My Vehicles'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.summarize_outlined),
|
||||||
|
tooltip: 'Fuel Report',
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const ReportScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
|
|
|
||||||
172
lib/screens/report_screen.dart
Normal file
172
lib/screens/report_screen.dart
Normal file
|
|
@ -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<ReportScreen> createState() => _ReportScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReportScreenState extends State<ReportScreen> {
|
||||||
|
static final _dateFormat = DateFormat.yMMMd();
|
||||||
|
|
||||||
|
DateTime? _startDate;
|
||||||
|
DateTime? _endDate;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _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<AppState>();
|
||||||
|
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,14 +24,14 @@ class CloudOAuthConfig {
|
||||||
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
|
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
|
||||||
|
|
||||||
/// The app's key (client ID for OAuth2 PKCE — no secret needed).
|
/// 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.
|
/// Custom URL scheme redirect registered in the Dropbox app console.
|
||||||
/// The scheme "mofueltaxback-dropbox" is already wired up in
|
/// The scheme "mofueltaxback-dropbox" is already wired up in
|
||||||
/// AndroidManifest.xml / Info.plist, so unless you have a reason to pick
|
/// AndroidManifest.xml / Info.plist, so unless you have a reason to pick
|
||||||
/// a different scheme, use exactly:
|
/// a different scheme, use exactly:
|
||||||
/// "mofueltaxback-dropbox://oauth2redirect"
|
/// "mofueltaxback-dropbox://oauth2redirect"
|
||||||
static const String? dropboxRedirectUri = null; // TODO: fill in
|
static const String? dropboxRedirectUri = 'mofueltaxback-dropbox://oauth2redirect';
|
||||||
|
|
||||||
// --- OneDrive (Microsoft Graph) ---
|
// --- OneDrive (Microsoft Graph) ---
|
||||||
//
|
//
|
||||||
|
|
|
||||||
81
lib/services/fuel_report.dart
Normal file
81
lib/services/fuel_report.dart
Normal 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);
|
||||||
|
}
|
||||||
85
lib/services/fuel_report_pdf.dart
Normal file
85
lib/services/fuel_report_pdf.dart
Normal 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';
|
||||||
|
}
|
||||||
80
pubspec.lock
80
pubspec.lock
|
|
@ -9,6 +9,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.7"
|
version: "1.0.7"
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -25,6 +33,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -392,6 +416,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
image_picker:
|
image_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -608,6 +640,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -656,6 +696,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -680,6 +736,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -696,6 +768,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
qr:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: qr
|
||||||
|
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
record_use:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ dependencies:
|
||||||
# OS-backed encrypted storage (Keystore/Keychain) for WebDAV credentials,
|
# OS-backed encrypted storage (Keystore/Keychain) for WebDAV credentials,
|
||||||
# so sign-in survives a cold app restart instead of living in memory only.
|
# so sign-in survives a cold app restart instead of living in memory only.
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
pdf: ^3.12.0
|
||||||
|
printing: ^5.14.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
184
test/fuel_report_test.dart
Normal file
184
test/fuel_report_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue