A whole bunch of changes

This commit is contained in:
Courtney Arnold 2026-08-15 17:52:25 -05:00
parent 901ec678a3
commit 956231048e
122 changed files with 5442 additions and 914 deletions

View file

@ -101,4 +101,28 @@ void main() {
expect(find.text('Vehicle already exists'), findsNothing);
});
testWidgets(
'tapping the VIN scan icon shows "VIN From" with an info button explaining where to find one',
(tester) async {
await pumpAddVehicleScreen(tester);
await tester.tap(find.byTooltip('Scan VIN from a photo'));
await tester.pumpAndSettle();
expect(find.text('VIN From'), findsOneWidget);
expect(find.text('Take Photo'), findsOneWidget);
expect(find.text('Choose from Gallery'), findsOneWidget);
await tester.tap(find.byIcon(Icons.info_outline));
await tester.pumpAndSettle();
expect(find.text('Where to find the VIN'), findsOneWidget);
expect(find.byType(Image), findsNWidgets(2), reason: 'door jamb + windshield examples');
await tester.tap(find.text('Close'));
await tester.pumpAndSettle();
expect(find.text('Where to find the VIN'), findsNothing);
});
}

View file

@ -0,0 +1,157 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
import 'package:fuel_tax_tracker/models/vehicle.dart';
import 'package:fuel_tax_tracker/screens/edit_fuel_entry_screen.dart';
import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart';
import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
import 'package:fuel_tax_tracker/services/database_service.dart';
/// Covers editing a fuel entry's logged data (date/gallons/price/total) —
/// not its receipt photo, which [EditFuelEntryScreen] deliberately shows
/// read-only. The database-level overwrite semantics ([DatabaseService]
/// group) are what [AppState.updateFuelEntry] relies on: it just calls
/// [DatabaseService.saveFuelEntry] again with the same id, same as any
/// other upsert in this app (see [DatabaseService.saveVehicle]).
void main() {
final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', updatedAt: DateTime.utc(2026, 1, 1));
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 3, 5, 14, 30),
gallons: 10.5,
pricePerGallon: 3.499,
totalCost: 36.74,
updatedAt: DateTime.utc(2026, 3, 5),
);
group('DatabaseService.saveFuelEntry overwrite semantics', () {
late Directory tempDir;
late DatabaseService database;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('edit_fuel_entry_test_');
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
database = DatabaseService();
await database.init();
});
tearDown(() async {
await database.rawDb.close();
await tempDir.delete(recursive: true);
});
test('saving again with the same id overwrites gallons/price/total/date, marking it dirty',
() async {
await database.saveVehicle(vehicle);
await database.saveFuelEntry(entry);
// Clear the dirty flag the way a completed sync would, so the
// overwrite below can prove it gets re-marked dirty.
await database.rawDb.update('fuel_entries', {'dirty': 0}, where: 'id = ?', whereArgs: ['e1']);
await database.saveFuelEntry(FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 3, 6, 9, 0),
gallons: 11.2,
pricePerGallon: 3.599,
totalCost: 40.31,
updatedAt: DateTime.utc(2026, 3, 6),
));
final rows = await database.getFuelEntries();
expect(rows, hasLength(1), reason: 'overwritten, not duplicated');
final saved = rows.single;
expect(saved.gallons, 11.2);
expect(saved.pricePerGallon, 3.599);
expect(saved.totalCost, 40.31);
expect(saved.date, DateTime(2026, 3, 6, 9, 0));
final rawRow = (await database.rawDb.query('fuel_entries', where: 'id = ?', whereArgs: ['e1'])).single;
expect(rawRow['dirty'], 1);
});
});
group('EditFuelEntryScreen', () {
Future<void> pumpVehicleDetail(WidgetTester tester, AppState appState) async {
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(home: VehicleDetailScreen(vehicleId: vehicle.id)),
),
);
}
testWidgets(
'tapping an entry row opens its receipt detail page, and its edit icon opens '
'EditFuelEntryScreen pre-filled', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await pumpVehicleDetail(tester, appState);
await tester.tap(find.text('10.500 gal • \$36.74'));
await tester.pumpAndSettle();
expect(find.byType(ReceiptDetailScreen), findsOneWidget);
await tester.tap(find.byTooltip('Edit fuel entry'));
await tester.pumpAndSettle();
expect(find.byType(EditFuelEntryScreen), findsOneWidget);
expect(find.widgetWithText(TextFormField, '10.500'), findsOneWidget);
expect(find.widgetWithText(TextFormField, '3.499'), findsOneWidget);
expect(find.widgetWithText(TextFormField, '36.74'), findsOneWidget);
});
testWidgets('clearing a required field shows a validation error instead of saving',
(tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(home: EditFuelEntryScreen(entry: entry)),
),
);
await tester.enterText(find.widgetWithText(TextFormField, '10.500'), '');
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Save Changes'));
await tester.pumpAndSettle();
expect(find.text('Required'), findsOneWidget);
expect(find.byType(EditFuelEntryScreen), findsOneWidget, reason: 'did not navigate away');
});
});
}
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
final String tempPath;
_FakePathProviderPlatform(this.tempPath);
@override
Future<String?> getApplicationDocumentsPath() async => tempPath;
@override
Future<String?> getTemporaryPath() async => tempPath;
}

View file

@ -0,0 +1,201 @@
import 'dart:io';
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/cloud/cloud_storage_provider.dart';
import 'package:fuel_tax_tracker/services/fuel_report.dart';
import 'package:fuel_tax_tracker/services/fuel_report_images.dart';
void main() {
final vehicle = Vehicle(
id: 'v1',
vin: '1FMPU18L1TLB51349',
updatedAt: DateTime.utc(2026, 1, 1),
);
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('fuel_report_images_test_');
});
tearDown(() async {
await tempDir.delete(recursive: true);
});
FuelReport reportWith(List<FuelEntry> entries) => FuelReport(
startDate: DateTime(2026, 1, 1),
endDate: DateTime(2026, 12, 31),
rows: [
VehicleReportRow(
vehicle: vehicle,
entries: entries,
totalGallons: entries.fold(0.0, (sum, e) => sum + e.gallons),
totalCost: entries.fold(0.0, (sum, e) => sum + e.totalCost),
),
],
);
FuelEntry entry({
required String id,
String? receiptImagePath,
String? receiptDriveFileId,
}) =>
FuelEntry(
id: id,
vehicleId: 'v1',
date: DateTime(2026, 3, 1),
gallons: 10,
pricePerGallon: 3.5,
totalCost: 35,
updatedAt: DateTime.utc(2026, 3, 1),
receiptImagePath: receiptImagePath,
receiptDriveFileId: receiptDriveFileId,
);
test('reads bytes from a local file when one exists', () async {
final file = File('${tempDir.path}/a.jpg')..writeAsBytesSync([1, 2, 3]);
final report = reportWith([entry(id: 'e1', receiptImagePath: file.path)]);
final result = await loadReceiptImageBytes(report: report, activeProvider: null);
expect(result.bytesByEntryId['e1'], [1, 2, 3]);
expect(result.failedEntries, isEmpty);
});
test('falls back to downloading via the cloud provider when there is no local file', () async {
final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'remote-1')]);
final provider = _FakeProvider(_FakeSession(bytesByFileId: {'remote-1': [4, 5, 6]}));
final result = await loadReceiptImageBytes(report: report, activeProvider: provider);
expect(result.bytesByEntryId['e1'], [4, 5, 6]);
expect(result.failedEntries, isEmpty);
});
test('reports (rather than silently drops) an entry with neither a local file nor a cloud reference',
() async {
final report = reportWith([entry(id: 'e1')]);
final result = await loadReceiptImageBytes(report: report, activeProvider: null);
expect(result.bytesByEntryId.containsKey('e1'), isFalse);
expect(result.failedEntries.map((e) => e.id), ['e1']);
});
test('reports (rather than throws) when the download fails', () async {
final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'missing')]);
final provider = _FakeProvider(_FakeSession(bytesByFileId: {}));
final result = await loadReceiptImageBytes(report: report, activeProvider: provider);
expect(result.bytesByEntryId.containsKey('e1'), isFalse);
expect(result.failedEntries.map((e) => e.id), ['e1']);
});
test('reports a cloud-only entry when there is no signed-in provider', () async {
final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'remote-1')]);
final result = await loadReceiptImageBytes(report: report, activeProvider: null);
expect(result.bytesByEntryId.containsKey('e1'), isFalse);
expect(result.failedEntries.map((e) => e.id), ['e1']);
});
test('a receipt uploaded via a different provider than the one currently connected fails cleanly',
() async {
// Mirrors the real scenario this was built for: a WebDAV-style path
// stored as receiptDriveFileId, but the currently active provider is
// something else entirely (here, the fake) — the ID is meaningless to
// it and the download call throws, same as any other failed download.
final report = reportWith([
entry(
id: 'e1',
receiptDriveFileId:
'/remote.php/dav/files/user/Show Me The Fuel Refund/receipts/VIN/e1.jpg',
),
]);
final provider = _FakeProvider(_FakeSession(bytesByFileId: {}));
final result = await loadReceiptImageBytes(report: report, activeProvider: provider);
expect(result.bytesByEntryId.containsKey('e1'), isFalse);
expect(result.failedEntries.map((e) => e.id), ['e1']);
});
test('mixed success and failure across multiple entries', () async {
final file = File('${tempDir.path}/a.jpg')..writeAsBytesSync([9]);
final report = reportWith([
entry(id: 'local', receiptImagePath: file.path),
entry(id: 'cloud-ok', receiptDriveFileId: 'remote-1'),
entry(id: 'cloud-missing', receiptDriveFileId: 'nope'),
]);
final provider = _FakeProvider(_FakeSession(bytesByFileId: {'remote-1': [1]}));
final result = await loadReceiptImageBytes(report: report, activeProvider: provider);
expect(result.bytesByEntryId.keys, {'local', 'cloud-ok'});
expect(result.failedEntries.map((e) => e.id), ['cloud-missing']);
});
}
class _FakeSession implements CloudStorageSession {
final Map<String, List<int>> bytesByFileId;
_FakeSession({required this.bytesByFileId});
@override
Future<List<int>> downloadFileBytes(String fileId) async {
final bytes = bytesByFileId[fileId];
if (bytes == null) throw StateError('not found: $fileId');
return bytes;
}
@override
bool get supportsSharedWithMe => false;
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async => '';
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async => null;
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async =>
CloudFileInfo(id: 'x', versionTag: null);
@override
Future<void> deleteFile(String fileId) async {}
@override
Future<String> createLockFile({required String folderId, required String name}) async => 'lock';
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async => [];
@override
void close() {}
}
class _FakeProvider implements CloudStorageProvider {
final _FakeSession session;
_FakeProvider(this.session);
@override
CloudProviderId get id => CloudProviderId.googleDrive;
@override
String get displayName => 'Fake Provider';
@override
bool get isSignedIn => true;
@override
String? get accountLabel => 'tester@example.com';
@override
Future<bool> attemptSilentSignIn() async => true;
@override
Future<String> signIn() async => accountLabel!;
@override
Future<void> signOut() async {}
@override
CloudStorageSession beginSession() => session;
}

View file

@ -0,0 +1,158 @@
import 'dart:typed_data';
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';
import 'package:fuel_tax_tracker/services/fuel_report_pdf.dart';
/// PDF content isn't practically assertable byte-for-byte, so this is a
/// smoke test: every include-images/include-line-items combination should
/// render without throwing and produce a well-formed (non-trivial, valid
/// header) document — the real coverage of what each section *contains*
/// lives in fuel_report_test.dart (data) and is exercised by hand.
void main() {
final vehicle = Vehicle(
id: 'v1',
vin: '1FMPU18L1TLB51349',
nickname: 'Truck',
updatedAt: DateTime.utc(2026, 1, 1),
);
final entryA = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 3, 5),
gallons: 10,
pricePerGallon: 3.5,
totalCost: 35,
updatedAt: DateTime.utc(2026, 3, 5),
receiptImagePath: '/local/a.jpg',
);
final entryB = FuelEntry(
id: 'e2',
vehicleId: 'v1',
date: DateTime(2026, 3, 12),
gallons: 12,
pricePerGallon: 3.6,
totalCost: 43.2,
updatedAt: DateTime.utc(2026, 3, 12),
receiptDriveFileId: 'remote-1',
);
final report = FuelReport(
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
rows: [
VehicleReportRow(
vehicle: vehicle,
entries: [entryA, entryB],
totalGallons: 22,
totalCost: 78.2,
),
],
);
// A 1x1 red pixel PNG — enough for the pdf package to embed as an image.
final onePixelPng = Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
]);
Future<void> expectValidPdf(Uint8List bytes) async {
expect(bytes.length, greaterThan(100));
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
}
test('summary only (both toggles off)', () async {
final bytes = await buildFuelReportPdf(report);
await expectValidPdf(bytes);
});
test('with line items, no images', () async {
final bytes = await buildFuelReportPdf(report, includeLineItems: true);
await expectValidPdf(bytes);
});
test('with images, no line items', () async {
final bytes = await buildFuelReportPdf(
report,
includeReceiptImages: true,
receiptImages: {'e1': onePixelPng, 'e2': onePixelPng},
);
await expectValidPdf(bytes);
});
test('with both — line items and images each get their own section, cross-referenced by number',
() async {
final bytes = await buildFuelReportPdf(
report,
includeLineItems: true,
includeReceiptImages: true,
receiptImages: {'e1': onePixelPng, 'e2': onePixelPng},
);
await expectValidPdf(bytes);
});
test('a report with enough entries to span multiple pages does not throw', () async {
// Regression test: the page banner/watermark used to be drawn by
// wrapping the *entire*, unbounded-height body in a Stack, which
// doesn't paginate — MultiPage threw "Widget won't fit into the page"
// for anything long enough to need a page break. Enough months/entries
// here to force at least one break.
final vehicle2 = Vehicle(id: 'v2', vin: '1GNWK5EG4DR302326', updatedAt: DateTime.utc(2026, 1, 1));
final manyEntries = <FuelEntry>[];
for (var month = 1; month <= 12; month++) {
for (var day = 2; day <= 26; day += 6) {
final id = 'm$month-d$day';
manyEntries.add(FuelEntry(
id: id,
vehicleId: month.isEven ? 'v1' : 'v2',
date: DateTime(2026, month, day),
gallons: 20,
pricePerGallon: 3.5,
totalCost: 70,
updatedAt: DateTime.utc(2026, month, day),
receiptDriveFileId: 'remote-$id',
));
}
}
final bigReport = buildFuelReport(
vehicles: [vehicle, vehicle2],
fuelEntries: manyEntries,
startDate: DateTime(2026, 1, 1),
endDate: DateTime(2026, 12, 31),
);
final bytes = await buildFuelReportPdf(
bigReport,
includeLineItems: true,
includeReceiptImages: true,
receiptImages: {for (final e in manyEntries) e.id: onePixelPng},
);
await expectValidPdf(bytes);
});
test('receiptImages missing an entry does not crash — that photo is just skipped', () async {
final bytes = await buildFuelReportPdf(
report,
includeReceiptImages: true,
receiptImages: {'e1': onePixelPng}, // e2 deliberately missing
);
await expectValidPdf(bytes);
});
test('empty report (no rows) with all sections on', () async {
final empty = FuelReport(startDate: DateTime(2026, 1, 1), endDate: DateTime(2026, 1, 31), rows: []);
final bytes = await buildFuelReportPdf(empty, includeLineItems: true, includeReceiptImages: true);
await expectValidPdf(bytes);
});
test('fuelReportFileName formats the date range', () {
expect(fuelReportFileName(report), 'fuel-report-20260301-20260331.pdf');
});
}

View file

@ -181,4 +181,101 @@ void main() {
expect(report.totalGallons, 18);
expect(report.totalCost, 63);
});
test('a row\'s entries are sorted oldest-first, regardless of insertion order', () {
final report = buildFuelReport(
vehicles: [truck],
fuelEntries: [
entry(id: 'newest', vehicleId: 'v1', date: DateTime(2026, 3, 20), receiptImagePath: '/c.jpg'),
entry(id: 'oldest', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'),
entry(id: 'middle', vehicleId: 'v1', date: DateTime(2026, 3, 12), receiptImagePath: '/b.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
expect(report.rows.single.entries.map((e) => e.id), ['oldest', 'middle', 'newest']);
});
test('fillCount and avgPricePerGallon are derived from the totals across all rows', () {
final report = buildFuelReport(
vehicles: [truck, car],
fuelEntries: [
entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 5), gallons: 10, totalCost: 40, receiptImagePath: '/a.jpg'),
entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 3, 6), gallons: 10, totalCost: 30, receiptImagePath: '/b.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
expect(report.fillCount, 2);
expect(report.avgPricePerGallon, 3.5); // $70 / 20 gal
});
test('avgPricePerGallon is 0, not NaN/infinity, for an empty report', () {
final report = buildFuelReport(
vehicles: [truck],
fuelEntries: const [],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
expect(report.avgPricePerGallon, 0);
});
test('allEntries pairs each entry with its vehicle and sorts oldest-first across vehicles', () {
final report = buildFuelReport(
vehicles: [truck, car],
fuelEntries: [
entry(id: 'truck-later', vehicleId: 'v1', date: DateTime(2026, 3, 20), receiptImagePath: '/a.jpg'),
entry(id: 'car-earlier', vehicleId: 'v2', date: DateTime(2026, 3, 5), receiptImagePath: '/b.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
final all = report.allEntries;
expect(all.map((re) => re.entry.id), ['car-earlier', 'truck-later']);
expect(all[0].vehicle.id, 'v2');
expect(all[1].vehicle.id, 'v1');
});
test('monthlyTotals buckets entries by calendar month across vehicles, oldest month first', () {
final report = buildFuelReport(
vehicles: [truck, car],
fuelEntries: [
entry(id: 'mar-1', vehicleId: 'v1', date: DateTime(2026, 3, 5), gallons: 10, totalCost: 35, receiptImagePath: '/a.jpg'),
entry(id: 'mar-2', vehicleId: 'v2', date: DateTime(2026, 3, 20), gallons: 10, totalCost: 35, receiptImagePath: '/b.jpg'),
entry(id: 'apr-1', vehicleId: 'v1', date: DateTime(2026, 4, 2), gallons: 10, totalCost: 40, receiptImagePath: '/c.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 4, 30),
);
final months = report.monthlyTotals;
expect(months, hasLength(2));
expect(months[0].month, DateTime(2026, 3));
expect(months[0].fillCount, 2);
expect(months[0].totalGallons, 20);
expect(months[0].totalCost, 70);
expect(months[0].avgPricePerGallon, 3.5);
expect(months[1].month, DateTime(2026, 4));
expect(months[1].fillCount, 1);
});
test('entriesByMonth is the grouping monthlyTotals is built from', () {
final report = buildFuelReport(
vehicles: [truck],
fuelEntries: [
entry(id: 'mar-1', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'),
entry(id: 'apr-1', vehicleId: 'v1', date: DateTime(2026, 4, 2), receiptImagePath: '/b.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 4, 30),
);
final byMonth = report.entriesByMonth;
expect(byMonth[DateTime(2026, 3)]!.map((re) => re.entry.id), ['mar-1']);
expect(byMonth[DateTime(2026, 4)]!.map((re) => re.entry.id), ['apr-1']);
});
}

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/widgets/image_source_sheet.dart';
/// Covers the optional [heading] and [onInfoTap] on [chooseImageSource]'s
/// bottom sheet — receipt capture passes "Receipt From" with no info
/// button (see receipt_capture.dart); VIN scanning passes "VIN From" plus
/// an info button that explains where to find one (see
/// add_edit_vehicle_screen.dart).
void main() {
Future<void> pumpSheet(
WidgetTester tester, {
String? heading,
VoidCallback? onInfoTap,
}) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => chooseImageSource(context, heading: heading, onInfoTap: onInfoTap),
child: const Text('Open'),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
testWidgets('shows the heading above Take Photo / Choose from Gallery when provided',
(tester) async {
await pumpSheet(tester, heading: 'Receipt From');
expect(find.text('Receipt From'), findsOneWidget);
expect(find.text('Take Photo'), findsOneWidget);
expect(find.text('Choose from Gallery'), findsOneWidget);
});
testWidgets('shows no heading when none is provided', (tester) async {
await pumpSheet(tester);
expect(find.text('Receipt From'), findsNothing);
expect(find.text('Take Photo'), findsOneWidget);
expect(find.text('Choose from Gallery'), findsOneWidget);
});
testWidgets('shows no info button when onInfoTap is omitted, even with a heading',
(tester) async {
await pumpSheet(tester, heading: 'Receipt From');
expect(find.byIcon(Icons.info_outline), findsNothing);
});
testWidgets('shows an info button next to the heading when onInfoTap is provided, and invokes it',
(tester) async {
var tapped = false;
await pumpSheet(tester, heading: 'VIN From', onInfoTap: () => tapped = true);
expect(find.text('VIN From'), findsOneWidget);
expect(find.byIcon(Icons.info_outline), findsOneWidget);
await tester.tap(find.byIcon(Icons.info_outline));
await tester.pumpAndSettle();
expect(tapped, isTrue);
});
}

105
test/main_shell_test.dart Normal file
View file

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/screens/main_shell.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Confirms the bottom nav actually switches between the four tabs and
/// that each one builds without crashing against a freshly-constructed,
/// not-yet-synced [AppState] — the same "no cloud connected, no vehicles
/// yet" state a real fresh install starts in.
void main() {
testWidgets('bottom nav switches between all four tabs', (tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: MainShell()),
),
);
// Receipts is the default tab.
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
await tester.tap(find.widgetWithText(NavigationDestination, 'Vehicles'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget);
await tester.tap(find.widgetWithText(NavigationDestination, 'Reports'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Fuel Report'), findsOneWidget);
await tester.tap(find.widgetWithText(NavigationDestination, 'Settings'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Settings'), findsOneWidget);
await tester.tap(find.widgetWithText(NavigationDestination, 'Receipts'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
});
testWidgets('Receipts tab shows an empty state with no fuel entries yet', (tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: MainShell()),
),
);
expect(find.textContaining('No fuel entries'), findsOneWidget);
});
testWidgets('a right-to-left swipe on the body advances to the next tab', (tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: MainShell()),
),
);
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000);
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget);
// And a left-to-right swipe goes back.
await tester.fling(find.byType(PageView), const Offset(400, 0), 1000);
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
});
testWidgets(
"swiping away from Receipts and back preserves its selected date range "
'(AutomaticKeepAliveClientMixin keeping the tab alive across the PageView, '
'not just the old IndexedStack)', (tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: MainShell()),
),
);
await tester.tap(find.text('Last 3 Months'));
await tester.pumpAndSettle();
// Swipe all the way to Settings and back.
await tester.tap(find.widgetWithText(NavigationDestination, 'Settings'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(NavigationDestination, 'Receipts'));
await tester.pumpAndSettle();
final selectedLabel = tester.widget<Text>(find.text('Last 3 Months'));
expect((selectedLabel.style?.fontWeight), FontWeight.w800, reason: 'still the active range');
});
}

174
test/purge_data_test.dart Normal file
View file

@ -0,0 +1,174 @@
import 'dart:io';
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/database_service.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// Both purge paths reuse the same soft-delete tombstones as a normal
/// per-row delete (see [DatabaseService.softDeleteVehicle]) rather than a
/// real SQL `DELETE`, specifically so the deletion is something a cloud
/// sync can push to other devices instead of the rows just getting
/// silently re-imported from the remote copy on the next sync.
void main() {
late Directory tempDir;
late DatabaseService database;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('purge_data_test_');
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
database = DatabaseService();
await database.init();
});
tearDown(() async {
await database.rawDb.close();
await tempDir.delete(recursive: true);
});
Vehicle vehicle({required String id, required String vin}) => Vehicle(
id: id,
vin: vin,
updatedAt: DateTime.utc(2026, 1, 1),
);
FuelEntry entry({
required String id,
required String vehicleId,
required DateTime date,
String? receiptImagePath,
}) =>
FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: 10,
pricePerGallon: 3.5,
totalCost: 35,
updatedAt: DateTime.utc(2026, 1, 1),
receiptImagePath: receiptImagePath,
);
group('purgeAllData', () {
test('soft-deletes every active vehicle and fuel entry, marking them dirty', () async {
await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1'));
await database.saveVehicle(vehicle(id: 'v2', vin: 'VIN2'));
await database.saveFuelEntry(entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 1)));
await database.saveFuelEntry(entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 4, 1)));
await database.purgeAllData(DateTime.utc(2026, 8, 13));
expect(await database.getVehicles(), isEmpty);
expect(await database.getFuelEntries(), isEmpty);
final vehicleRows = await database.rawDb.query('vehicles');
final entryRows = await database.rawDb.query('fuel_entries');
expect(vehicleRows, hasLength(2), reason: 'tombstoned, not hard-deleted');
expect(entryRows, hasLength(2));
for (final row in [...vehicleRows, ...entryRows]) {
expect(row['deleted_at'], isNotNull);
expect(row['dirty'], 1);
}
});
test('empties the local receipts folder, including untracked files', () async {
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
final storedPath =
await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 1));
// Simulate an orphaned file the DB never referenced.
final strayFile = File(p.join(database.receiptsDirectory.path, 'stray.jpg'))
..writeAsBytesSync([9]);
await database.purgeAllData(DateTime.utc(2026, 8, 13));
expect(await File(storedPath).exists(), isFalse);
expect(await strayFile.exists(), isFalse);
expect(await database.receiptsDirectory.exists(), isTrue,
reason: 'recreated empty, ready for future use');
});
test('leaves already-deleted rows alone', () async {
await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1'));
await database.softDeleteVehicle('v1', DateTime.utc(2026, 1, 2));
await database.purgeAllData(DateTime.utc(2026, 8, 13));
final rows = await database.rawDb.query('vehicles', where: 'id = ?', whereArgs: ['v1']);
expect(rows.single['deleted_at'], DateTime.utc(2026, 1, 2).millisecondsSinceEpoch);
});
});
group('purgeFuelEntriesInRange', () {
test('soft-deletes only entries dated within the inclusive range', () async {
await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1'));
await database.saveFuelEntry(
entry(id: 'before', vehicleId: 'v1', date: DateTime(2026, 2, 28)),
);
await database.saveFuelEntry(
entry(id: 'start-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 1)),
);
await database.saveFuelEntry(
entry(id: 'inside', vehicleId: 'v1', date: DateTime(2026, 3, 15)),
);
await database.saveFuelEntry(
entry(id: 'end-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 31)),
);
await database.saveFuelEntry(
entry(id: 'after', vehicleId: 'v1', date: DateTime(2026, 4, 1)),
);
await database.purgeFuelEntriesInRange(
DateTime(2026, 3, 1),
DateTime(2026, 3, 31),
DateTime.utc(2026, 8, 13),
);
final remaining = await database.getFuelEntries();
expect(remaining.map((e) => e.id).toSet(), {'before', 'after'});
expect(await database.getVehicles(), hasLength(1), reason: 'vehicles are untouched');
});
test('returns local receipt paths for the caller to delete, without touching untracked files',
() async {
await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1'));
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
final storedPath =
await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 15));
await database.saveFuelEntry(
entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 15), receiptImagePath: storedPath),
);
final paths = await database.purgeFuelEntriesInRange(
DateTime(2026, 3, 1),
DateTime(2026, 3, 31),
DateTime.utc(2026, 8, 13),
);
expect(paths, [storedPath]);
// purgeFuelEntriesInRange only reports paths — deleting the files
// themselves is the caller's job (AppState), mirroring
// softDeleteFuelEntriesForVehicle.
expect(await File(storedPath).exists(), isTrue);
});
});
}
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
final String tempPath;
_FakePathProviderPlatform(this.tempPath);
@override
Future<String?> getApplicationDocumentsPath() async => tempPath;
@override
Future<String?> getTemporaryPath() async => tempPath;
}

View file

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
import 'package:fuel_tax_tracker/models/vehicle.dart';
import 'package:fuel_tax_tracker/screens/edit_fuel_entry_screen.dart';
import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart';
import 'package:fuel_tax_tracker/screens/receipt_image_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers [ReceiptDetailScreen] itself — the single place to view a
/// receipt's photo and logged data together, with edit/delete for the
/// entry. Deliberately never taps through to an actual delete: that would
/// call [AppState.deleteFuelEntry], which needs an initialized database not
/// set up here (see settings_purge_test.dart for the same convention).
void main() {
final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', nickname: 'Red Truck', updatedAt: DateTime.utc(2026, 1, 1));
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 5, 14, 9, 30),
gallons: 10.523,
pricePerGallon: 3.499,
totalCost: 36.81,
updatedAt: DateTime.utc(2026, 5, 14),
receiptDriveFileId: 'remote-1',
);
Future<void> pumpDetail(WidgetTester tester, AppState appState) async {
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(home: ReceiptDetailScreen(entryId: 'e1')),
),
);
}
testWidgets('shows the vehicle, date, gallons, price, and total', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await pumpDetail(tester, appState);
expect(find.text('Red Truck'), findsOneWidget);
expect(find.text('VIN: 1FMPU18L1TLB51349'), findsOneWidget);
expect(find.text('10.523 gal'), findsOneWidget);
expect(find.text('\$3.50'), findsOneWidget, reason: 'price per gallon');
expect(find.text('\$36.81'), findsOneWidget, reason: 'total cost');
});
testWidgets('shows a fallback instead of crashing when the entry no longer exists',
(tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [];
await pumpDetail(tester, appState);
expect(find.text('Receipt not found'), findsOneWidget);
});
testWidgets('the edit icon opens EditFuelEntryScreen pre-filled', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await pumpDetail(tester, appState);
await tester.tap(find.byTooltip('Edit fuel entry'));
await tester.pumpAndSettle();
expect(find.byType(EditFuelEntryScreen), findsOneWidget);
expect(find.widgetWithText(TextFormField, '10.523'), findsOneWidget);
});
testWidgets('the delete icon shows a warning dialog; Cancel dismisses it without deleting',
(tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await pumpDetail(tester, appState);
await tester.tap(find.byTooltip('Delete fuel entry'));
await tester.pumpAndSettle();
expect(find.text('Delete fuel entry?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Delete fuel entry?'), findsNothing);
expect(appState.fuelEntries, hasLength(1), reason: 'not deleted');
});
testWidgets('tapping the photo opens the full-screen viewer', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await pumpDetail(tester, appState);
await tester.tap(find.byType(GestureDetector).first);
await tester.pumpAndSettle();
expect(find.byType(ReceiptImageScreen), findsOneWidget);
});
}

View file

@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
import 'package:fuel_tax_tracker/models/vehicle.dart';
import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart';
import 'package:fuel_tax_tracker/screens/receipts_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
void main() {
final vehicle = Vehicle(
id: 'v1',
vin: '1FMPU18L1TLB51349',
nickname: 'Red Truck',
updatedAt: DateTime.utc(2026, 1, 1),
);
testWidgets('shows an empty state with no fuel entries', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
expect(find.textContaining('No fuel entries'), findsOneWidget);
});
testWidgets('shows the hero total and a receipt row with date/amount/vehicle chip',
(tester) async {
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 5, 14),
gallons: 10.523,
pricePerGallon: 3.5,
totalCost: 32.48,
updatedAt: DateTime.utc(2026, 5, 14),
receiptDriveFileId: 'remote-1',
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
// Hero total sums totalCost across all entries — just the one here.
expect(find.text('\$32.48'), findsNWidgets(2)); // hero total + row amount
expect(find.text('Total Fuel Cost'), findsOneWidget, reason: 'no year prefix');
expect(find.text('Total Gallons'), findsOneWidget);
expect(find.text('11'), findsOneWidget, reason: 'hero total gallons, rounded to whole gallons');
expect(find.text('May 14, 2026'), findsOneWidget);
expect(find.text('10.523 gal'), findsOneWidget, reason: 'row subtitle');
expect(find.text('Red Truck'), findsOneWidget);
});
testWidgets('tapping a receipted row opens the receipt detail page', (tester) async {
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 5, 14),
gallons: 10,
pricePerGallon: 3.5,
totalCost: 35,
updatedAt: DateTime.utc(2026, 5, 14),
receiptDriveFileId: 'remote-1',
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
await tester.tap(find.text('May 14, 2026'));
await tester.pumpAndSettle();
expect(find.byType(ReceiptDetailScreen), findsOneWidget);
});
testWidgets('tapping a row with no receipt photo still opens the detail page', (tester) async {
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 5, 14),
gallons: 10,
pricePerGallon: 3.5,
totalCost: 35,
updatedAt: DateTime.utc(2026, 5, 14),
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
await tester.tap(find.text('May 14, 2026'));
await tester.pumpAndSettle();
expect(find.byType(ReceiptDetailScreen), findsOneWidget,
reason: 'the data is still viewable/editable even without a photo');
});
testWidgets('formats large gallon totals with thousands separators', (tester) async {
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 8, 5),
gallons: 1234.5678,
pricePerGallon: 3.5,
totalCost: 4320.99,
updatedAt: DateTime.utc(2026, 8, 5),
receiptDriveFileId: 'remote-1',
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
expect(find.text('1,235'), findsOneWidget, reason: 'hero total gallons, rounded to whole gallons');
expect(find.text('1,234.568 gal'), findsOneWidget, reason: 'row subtitle keeps full precision');
});
testWidgets('date range selector filters totals and the list, and recolors the active label',
(tester) async {
// Aug 5 2026: within Current Month, Last 3 Months, and Year to Date.
final recentEntry = FuelEntry(
id: 'recent',
vehicleId: 'v1',
date: DateTime(2026, 8, 5),
gallons: 10,
pricePerGallon: 3,
totalCost: 30,
updatedAt: DateTime.utc(2026, 8, 5),
receiptDriveFileId: 'r1',
);
// Jul 1 2026: within Last 3 Months and Year to Date, not Current Month.
final midEntry = FuelEntry(
id: 'mid',
vehicleId: 'v1',
date: DateTime(2026, 7, 1),
gallons: 20,
pricePerGallon: 3,
totalCost: 60,
updatedAt: DateTime.utc(2026, 7, 1),
receiptDriveFileId: 'r2',
);
// Mar 1 2026: within Year to Date only.
final oldEntry = FuelEntry(
id: 'old',
vehicleId: 'v1',
date: DateTime(2026, 3, 1),
gallons: 40,
pricePerGallon: 3,
totalCost: 120,
updatedAt: DateTime.utc(2026, 3, 1),
receiptDriveFileId: 'r3',
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [recentEntry, midEntry, oldEntry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
bool isBold(String label) => tester
.widgetList<Text>(find.text(label))
.any((t) => t.style?.fontWeight == FontWeight.w800);
// Defaults to Year to Date: all three visible.
expect(find.text('Aug 5, 2026'), findsOneWidget);
expect(find.text('Jul 1, 2026'), findsOneWidget);
expect(find.text('Mar 1, 2026'), findsOneWidget);
expect(isBold('Year to Date'), isTrue, reason: 'selected by default');
await tester.tap(find.text('Last 3 Months'));
await tester.pumpAndSettle();
expect(find.text('Aug 5, 2026'), findsOneWidget);
expect(find.text('Jul 1, 2026'), findsOneWidget);
expect(find.text('Mar 1, 2026'), findsNothing, reason: 'older than 3 months back');
expect(isBold('Last 3 Months'), isTrue);
expect(isBold('Year to Date'), isFalse, reason: 'no longer selected');
await tester.tap(find.text('Current Month'));
await tester.pumpAndSettle();
expect(find.text('Aug 5, 2026'), findsOneWidget);
expect(find.text('Jul 1, 2026'), findsNothing, reason: 'not in the current month');
expect(find.text('Mar 1, 2026'), findsNothing);
});
testWidgets(
'tapping + with no vehicles opens vehicle creation and prompts to create one for the receipt',
(tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsOneWidget);
expect(find.text('Create a vehicle to attach receipt'), findsOneWidget);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
expect(find.text('Create a vehicle to attach receipt'), findsNothing);
expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsOneWidget, reason: 'still on the form');
});
// Not covered here: that saving the new vehicle continues into receipt
// capture (rather than back to an empty Receipts tab). Exercising that
// needs AddEditVehicleScreen's real _save() to run, which touches a real
// database — and combining a real sqflite_common_ffi database with
// testWidgets (as opposed to a plain test()) reliably deadlocks in this
// project's test environment (confirmed directly: the identical
// `DatabaseService().init()` call returns immediately under test(), but
// times out under testWidgets()). Verified manually on-device instead.
}

View file

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/screens/report_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
void main() {
testWidgets('shows both toggles, off by default, with the cross-reference hint only when both are on',
(tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReportScreen()),
),
);
expect(find.text('Include Receipt Images'), findsOneWidget);
expect(find.text('Include Receipt Line Items'), findsOneWidget);
expect(
tester.widgetList<SwitchListTile>(find.byType(SwitchListTile)).map((s) => s.value),
[false, false],
);
expect(find.textContaining('matching number'), findsNothing);
await tester.tap(find.text('Include Receipt Images'));
await tester.pumpAndSettle();
expect(find.textContaining('matching number'), findsNothing, reason: 'only one toggle is on');
await tester.tap(find.text('Include Receipt Line Items'));
await tester.pumpAndSettle();
expect(find.textContaining('matching number'), findsOneWidget, reason: 'both toggles now on');
});
testWidgets(
'shows a link to the state refund claim form, regardless of whether a range is picked',
(tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReportScreen()),
),
);
expect(find.text('Need the refund claim form?'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('Get the Motor Fuel Refund Claim form'),
200,
scrollable: find.byType(Scrollable),
);
expect(find.text('Get the Motor Fuel Refund Claim form'), findsOneWidget);
});
}

View file

@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
import 'package:fuel_tax_tracker/models/vehicle.dart';
import 'package:fuel_tax_tracker/screens/report_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers the per-vehicle selection cards on the Reports tab: once a date
/// range is picked, every vehicle with a receipted entry in range starts
/// selected (colored like the "Share" button), tapping one to deselect it
/// drops it from the total (and, by extension, the generated report) and
/// reverts its coloring to a plain unselected card, and deselecting every
/// vehicle shows a distinct "nothing selected" message rather than "no
/// entries in range". There's deliberately no checkbox — selection state is
/// conveyed by color alone.
void main() {
final vehicleA = Vehicle(id: 'a', vin: 'VINAAAAAAAAAAAAAA', nickname: 'Truck', updatedAt: DateTime.utc(2026, 1, 1));
final vehicleB = Vehicle(id: 'b', vin: 'VINBBBBBBBBBBBBBB', nickname: 'Van', updatedAt: DateTime.utc(2026, 1, 1));
// showDatePicker's initial date (when none is set yet) defaults to
// "now" — dating these entries to "now" too, rather than a fixed date,
// means just accepting that default on both pickers (below) already
// covers them, without needing to drive the calendar UI to a specific day.
final now = DateTime.now();
FuelEntry entryFor(String vehicleId, String id, double gallons, double cost) => FuelEntry(
id: id,
vehicleId: vehicleId,
date: now,
gallons: gallons,
pricePerGallon: cost / gallons,
totalCost: cost,
updatedAt: now.toUtc(),
receiptDriveFileId: 'remote-$id',
);
Future<void> pickRange(WidgetTester tester) async {
await tester.tap(find.text('Start date'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.tap(find.text('End date'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
Future<AppState> pumpReport(WidgetTester tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA, vehicleB]
..fuelEntries = [
entryFor('a', 'e1', 10, 35),
entryFor('b', 'e2', 20, 70),
];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReportScreen()),
),
);
return appState;
}
Card cardFor(WidgetTester tester, String vehicleLabel) => tester.widget<Card>(
find.ancestor(of: find.text(vehicleLabel), matching: find.byType(Card)),
);
testWidgets('both vehicles start colored as selected once a range with entries for both is picked',
(tester) async {
await pumpReport(tester);
await pickRange(tester);
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
expect(cardFor(tester, 'Truck').color, colorScheme.primary);
expect(cardFor(tester, 'Van').color, colorScheme.primary);
expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'combined total');
});
testWidgets('tapping a selected vehicle deselects it (reverts its coloring) and drops it from the total',
(tester) async {
await pumpReport(tester);
await pickRange(tester);
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
await tester.tap(find.text('Truck'));
await tester.pumpAndSettle();
expect(find.text('20.000 gal · \$70.00'), findsOneWidget, reason: "only Van's total remains");
expect(cardFor(tester, 'Truck').color, isNot(colorScheme.primary), reason: 'deselected');
expect(cardFor(tester, 'Van').color, colorScheme.primary, reason: 'still selected');
});
testWidgets('tapping a deselected vehicle again reselects it (restores its coloring)',
(tester) async {
await pumpReport(tester);
await pickRange(tester);
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
await tester.tap(find.text('Truck'));
await tester.pumpAndSettle();
await tester.tap(find.text('Truck'));
await tester.pumpAndSettle();
expect(cardFor(tester, 'Truck').color, colorScheme.primary);
expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'both counted again');
});
testWidgets('deselecting every vehicle shows a distinct "nothing selected" message',
(tester) async {
await pumpReport(tester);
await pickRange(tester);
await tester.tap(find.text('Truck'));
await tester.pumpAndSettle();
await tester.tap(find.text('Van'));
await tester.pumpAndSettle();
expect(
find.text('No vehicles selected — tap at least one above to build a report.'),
findsOneWidget,
);
expect(find.text('Share'), findsNothing);
expect(find.text('Print'), findsNothing);
// The cards themselves stay visible so the user can tap one again.
expect(find.text('Truck'), findsOneWidget);
expect(find.text('Van'), findsOneWidget);
});
}

View file

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:fuel_tax_tracker/screens/data_settings_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers the "Danger Zone" purge UI's warning-dialog gating, now folded
/// into the Settings > Data > Advanced section — the actual delete behavior
/// (soft-delete tombstones, dirty flags, receipt file cleanup) is covered
/// against a real database in purge_data_test.dart. This deliberately never
/// taps a dialog's destructive "Delete" button: doing so would call through
/// to [AppState.purgeAllData]/[AppState.purgeFuelEntriesInRange], which need
/// an initialized database — not set up here, matching every other Settings
/// widget test (see theme_toggle_test.dart) that stops short of a full
/// [AppState.init].
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<void> pumpSettings(WidgetTester tester, AppState appState) async {
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: DataSettingsScreen()),
),
);
}
// The purge controls sit inside the collapsed "Advanced" ExpansionTile,
// below the fold — every test needs to expand it and scroll it into view
// before tapping anything inside it.
Future<void> scrollToDangerZone(WidgetTester tester) async {
await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable));
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Danger Zone'), 200, scrollable: find.byType(Scrollable));
await tester.pumpAndSettle();
}
testWidgets('Purge All Data shows a warning dialog; Cancel dismisses it without purging',
(tester) async {
final appState = AppState()..isLoading = false;
await pumpSettings(tester, appState);
await scrollToDangerZone(tester);
await tester.tap(find.widgetWithText(OutlinedButton, 'Purge All Data'));
await tester.pumpAndSettle();
expect(find.text('Purge all data?'), findsOneWidget);
expect(find.textContaining("can't be undone"), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Purge all data?'), findsNothing);
expect(appState.vehicles, isEmpty);
});
testWidgets('mentions the connected cloud provider in the warning when one is connected',
(tester) async {
final appState = AppState()..isLoading = false;
await pumpSettings(tester, appState);
await scrollToDangerZone(tester);
await tester.tap(find.widgetWithText(OutlinedButton, 'Purge All Data'));
await tester.pumpAndSettle();
expect(find.textContaining('connected to'), findsNothing,
reason: 'no cloud provider connected in this test');
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
});
testWidgets('Purge Range is disabled until both a start and end date are picked',
(tester) async {
final appState = AppState()..isLoading = false;
await pumpSettings(tester, appState);
await scrollToDangerZone(tester);
final purgeRangeButton =
tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'Purge Range'));
expect(purgeRangeButton.onPressed, isNull);
});
testWidgets('picking a start and end date enables Purge Range, which shows a warning dialog',
(tester) async {
final appState = AppState()..isLoading = false;
await pumpSettings(tester, appState);
await scrollToDangerZone(tester);
await tester.tap(find.text('Start date'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.tap(find.text('End date'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
final purgeRangeButton =
tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'Purge Range'));
expect(purgeRangeButton.onPressed, isNotNull);
await tester.tap(find.widgetWithText(OutlinedButton, 'Purge Range'));
await tester.pumpAndSettle();
expect(find.text('Purge data in range?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
});
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/screens/data_settings_screen.dart';
import 'package:fuel_tax_tracker/screens/settings_screen.dart';
import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers the Settings landing page's menu wiring: it groups settings into
/// two submenus — [UiSettingsScreen] (appearance) and [DataSettingsScreen]
/// (cloud storage, photo syncing/quality, advanced/purge) — plus keeps a
/// read-only summary on the landing page itself. The submenu screens'
/// actual contents are covered by theme_toggle_test.dart and
/// settings_purge_test.dart respectively.
void main() {
Future<void> pumpSettings(WidgetTester tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: SettingsScreen()),
),
);
}
testWidgets('shows UI and Data menu entries, plus the summary', (tester) async {
await pumpSettings(tester);
expect(find.text('UI'), findsOneWidget);
expect(find.text('Data'), findsOneWidget);
expect(find.text('Summary'), findsOneWidget);
// None of Data's contents leaked onto the landing page.
expect(find.text('Cloud Storage'), findsNothing);
expect(find.text('Advanced'), findsNothing);
});
testWidgets('tapping UI opens UiSettingsScreen', (tester) async {
await pumpSettings(tester);
await tester.tap(find.text('UI'));
await tester.pumpAndSettle();
expect(find.byType(UiSettingsScreen), findsOneWidget);
expect(find.text('Appearance'), findsOneWidget);
});
testWidgets('tapping Data opens DataSettingsScreen', (tester) async {
await pumpSettings(tester);
await tester.tap(find.text('Data'));
await tester.pumpAndSettle();
expect(find.byType(DataSettingsScreen), findsOneWidget);
expect(find.text('Cloud Storage'), findsOneWidget);
await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable));
expect(find.text('Advanced'), findsOneWidget);
});
}

View file

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers the light/dark/system theme toggle on the Settings > UI screen —
/// defaults to System, selecting a segment updates [AppState.themeMode]
/// and persists it to SharedPreferences (so it survives a cold start).
///
/// Deliberately doesn't round-trip through a second [AppState.init] to
/// verify persistence — that pulls in unrelated subsystems (connectivity
/// listening, cloud-restore) with no test-environment platform mocks,
/// which hangs rather than failing fast. Reading the pref back directly
/// verifies the same thing without that baggage.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('defaults to System and switches to Dark/Light on tap', (tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: UiSettingsScreen()),
),
);
expect(appState.themeMode, ThemeMode.system);
await tester.tap(find.text('Dark'));
await tester.pumpAndSettle();
expect(appState.themeMode, ThemeMode.dark);
await tester.tap(find.text('Light'));
await tester.pumpAndSettle();
expect(appState.themeMode, ThemeMode.light);
});
testWidgets('persists the choice to SharedPreferences', (tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: UiSettingsScreen()),
),
);
await tester.tap(find.text('Dark'));
await tester.pumpAndSettle();
final prefs = await SharedPreferences.getInstance();
// Mirrors the private `_prefsKeyThemeMode` key in app_state.dart —
// a black-box persistence check necessarily has to know the storage
// key, and this doubles as a guardrail against silently breaking
// already-persisted user settings by renaming it there.
expect(prefs.getString('theme_mode'), 'dark');
});
}

View file

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/models/vehicle.dart';
import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers the "modern navigation" swipe added to the single-vehicle
/// receipt page: it's a PageView over every vehicle, starting on whichever
/// one was tapped — sliding left/right moves to the next/previous vehicle,
/// same as swiping between MainShell's bottom-nav tabs.
void main() {
final vehicleA = Vehicle(id: 'a', vin: 'VINAAAAAAAAAAAAAA', nickname: 'Truck', updatedAt: DateTime.utc(2026, 1, 1));
final vehicleB = Vehicle(id: 'b', vin: 'VINBBBBBBBBBBBBBB', nickname: 'Van', updatedAt: DateTime.utc(2026, 1, 1));
final vehicleC = Vehicle(id: 'c', vin: 'VINCCCCCCCCCCCCCC', nickname: 'Sedan', updatedAt: DateTime.utc(2026, 1, 1));
Future<void> pumpDetail(WidgetTester tester, AppState appState, String vehicleId) async {
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(home: VehicleDetailScreen(vehicleId: vehicleId)),
),
);
}
testWidgets('starts on the requested vehicle, not necessarily the first in the list',
(tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA, vehicleB, vehicleC];
await pumpDetail(tester, appState, 'b');
expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget);
});
testWidgets('a right-to-left swipe advances to the next vehicle', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA, vehicleB, vehicleC];
await pumpDetail(tester, appState, 'a');
expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget);
await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000);
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget);
});
testWidgets('a left-to-right swipe goes back to the previous vehicle', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA, vehicleB, vehicleC];
await pumpDetail(tester, appState, 'c');
expect(find.widgetWithText(AppBar, 'Sedan'), findsOneWidget);
await tester.fling(find.byType(PageView), const Offset(400, 0), 1000);
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget);
});
testWidgets('swiping does not go past the last vehicle', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA, vehicleB];
await pumpDetail(tester, appState, 'b');
await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000);
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget, reason: 'stayed on the last page');
});
testWidgets('a single vehicle has nothing to swipe to and just shows itself', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA];
await pumpDetail(tester, appState, 'a');
expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget);
});
}

View file

@ -26,10 +26,40 @@ void main() {
expect(VinParser.parse('vin: 1hgcm82633a004352'), '1HGCM82633A004352');
});
test('does not match a run containing I, O, or Q (never valid in a real VIN)', () {
// Same length (17) but contains an "O" partway through, so no
// 17-character eligible-charset run exists anywhere in it.
expect(VinParser.parse('1HGCM82633AOO4352'), isNull);
test('normalizes an O to 0 — real VINs never contain one, so it reads as a misread zero', () {
// Real OCR output from a Kia/Hyundai data plate photo: "0" (zero)
// came back as "O" (letter) — this is exactly why I/O/Q are excluded
// from real VINs in the first place (too easily confused with
// 1/0/9), which cuts both ways for OCR reading one back off a plate.
expect(VinParser.parse('V.I.N 5XYZKDAG2BGO50982 TYPE:MPY'), '5XYZKDAG2BG050982');
});
test('normalizes an I to 1 the same way', () {
expect(VinParser.parse('VIN: 1HGCM82633AI04352'), '1HGCM82633A104352');
});
test('does not match a run containing Q — unlike I/O, not a plausible misread digit', () {
expect(VinParser.parse('1HGCM82633AQ04352'), isNull);
});
test('retries with whitespace stripped when OCR splits the VIN across a stray space', () {
// Real OCR output from an etched-glass VIN photo: a space landed
// mid-VIN (likely two separate detected text regions on the curved
// surface), so no single unbroken 17-character run exists until the
// whitespace is collapsed.
expect(VinParser.parse('5XYZKD AG2BG050982'), '5XYZKDAG2BG050982');
});
test('a whitespace-stripped retry is only a last resort, after a normal match is preferred',
() {
const text = '''
PART NO 4F2CZ 58899BA12345
VIN: 1HGCM82633A004352
''';
// The "VIN:"-labeled match (unaffected by the space in the unrelated
// part number above it) should win over anything the fallback might
// have found in the stripped text.
expect(VinParser.parse(text), '1HGCM82633A004352');
});
test('does not match a 16-character (too short) run', () {
@ -43,5 +73,34 @@ void main() {
test('returns null when nothing matches', () {
expect(VinParser.parse('THANK YOU FOR YOUR PURCHASE'), isNull);
});
test('matches a "V.I.N" label (period between each letter) — common on compliance/data plates',
() {
// Real text from a Kia/Hyundai-built data plate: "V.I.N" rather than
// "VIN:" — a manufacturer plate styling the labeled pattern didn't
// originally account for, so this fell through to relying on OCR
// reading the bare 17-char run cleanly.
const text = '''
THIS VEHICLE CONFORMS TO ALL APPLICABLE U.S.A. FEDERAL
MOTOR VEHICLE SAFETY AND THEFT PREVENTION STANDARDS
IN EFFECT ON THE DATE OF MANUFACTURE SHOWN ABOVE
V.I.N 5XYZKDAG2BG050982 TYPE:MPV
''';
expect(VinParser.parse(text), '5XYZKDAG2BG050982');
});
test('matches "V.I.N." with a trailing period too', () {
expect(VinParser.parse('V.I.N. 5XYZKDAG2BG050982'), '5XYZKDAG2BG050982');
});
test('a "V.I.N" labeled match is still preferred over another candidate elsewhere', () {
const text = '''
PART NO 4F2CZ58899BA12345
V.I.N 5XYZKDAG2BG050982
''';
expect(VinParser.parse(text), '5XYZKDAG2BG050982');
});
});
}

View file

@ -17,8 +17,8 @@ void main() {
),
);
expect(find.text('My Vehicles'), findsOneWidget);
expect(find.text('Add Vehicle'), findsOneWidget);
expect(find.text('Vehicles'), findsOneWidget);
expect(find.byTooltip('Add Vehicle'), findsOneWidget);
expect(find.textContaining('No vehicles yet'), findsOneWidget);
});
}