diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index df2009b..c63541b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -64,5 +64,13 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png
new file mode 100644
index 0000000..a6ab1bf
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png
new file mode 100644
index 0000000..cd370fb
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png
new file mode 100644
index 0000000..b3b4db8
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png
new file mode 100644
index 0000000..b261793
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png
new file mode 100644
index 0000000..a6ab1bf
Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-hdpi/splash.png b/android/app/src/main/res/drawable-night-hdpi/splash.png
new file mode 100644
index 0000000..cd370fb
Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png
new file mode 100644
index 0000000..b3b4db8
Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-mdpi/splash.png b/android/app/src/main/res/drawable-night-mdpi/splash.png
new file mode 100644
index 0000000..b261793
Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png
new file mode 100644
index 0000000..649db16
Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ
diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png
new file mode 100644
index 0000000..77c3f18
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xhdpi/splash.png b/android/app/src/main/res/drawable-night-xhdpi/splash.png
new file mode 100644
index 0000000..a5169e6
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png
new file mode 100644
index 0000000..c406a4e
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxhdpi/splash.png
new file mode 100644
index 0000000..e9835d6
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png
new file mode 100644
index 0000000..7aad901
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png
new file mode 100644
index 0000000..63ef10c
Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png
new file mode 100644
index 0000000..649db16
Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ
diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml
new file mode 100644
index 0000000..3cc4948
--- /dev/null
+++ b/android/app/src/main/res/drawable-night/launch_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png
new file mode 100644
index 0000000..8e24958
Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
index f74085f..3cc4948 100644
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,12 +1,9 @@
-
-
-
-
-
+ -
+
+
+ -
+
+
diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png
new file mode 100644
index 0000000..77c3f18
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png
new file mode 100644
index 0000000..a5169e6
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png
new file mode 100644
index 0000000..c406a4e
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png
new file mode 100644
index 0000000..e9835d6
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png
new file mode 100644
index 0000000..7aad901
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png
new file mode 100644
index 0000000..63ef10c
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ
diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png
new file mode 100644
index 0000000..8e24958
Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index 304732f..3cc4948 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,12 +1,9 @@
-
-
-
-
-
+ -
+
+
+ -
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index db77bb4..65ca23d 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 17987b7..25fe56c 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 09d4391..41a03e6 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index d5f1c8d..358883b 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 4d6372e..97050a2 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
new file mode 100644
index 0000000..34cb63f
--- /dev/null
+++ b/android/app/src/main/res/values-night-v31/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
index 06952be..dbc9ea9 100644
--- a/android/app/src/main/res/values-night/styles.xml
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -5,6 +5,10 @@
- @drawable/launch_background
+ - false
+ - false
+ - false
+ - shortEdges
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index cb1ef88..0d1fa8f 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -5,6 +5,10 @@
- @drawable/launch_background
+ - false
+ - false
+ - false
+ - shortEdges
- CFBundleURLTypes
-
-
- CFBundleTypeRole
- Editor
- CFBundleURLSchemes
-
- com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID
-
-
-
-
- CFBundleTypeRole
- Editor
- CFBundleURLSchemes
-
- mofueltaxback-dropbox
-
-
-
- CFBundleTypeRole
- Editor
- CFBundleURLSchemes
-
- mofueltaxback-onedrive
-
-
-
-
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ mofueltaxback-dropbox
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ mofueltaxback-onedrive
+
+
+
+ UIStatusBarHidden
+
+
diff --git a/lib/main.dart b/lib/main.dart
index 819fb45..f1afe70 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
-import 'screens/home_screen.dart';
+import 'screens/main_shell.dart';
import 'services/app_state.dart';
import 'theme/app_theme.dart';
@@ -16,11 +16,14 @@ class FuelTaxTrackerApp extends StatelessWidget {
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppState()..init(),
- child: MaterialApp(
- title: 'Fuel Tax Tracker',
- theme: AppTheme.light,
- darkTheme: AppTheme.dark,
- home: const AppRoot(),
+ child: Consumer(
+ builder: (context, appState, _) => MaterialApp(
+ title: 'Fuel Tax Tracker',
+ theme: AppTheme.light,
+ darkTheme: AppTheme.dark,
+ themeMode: appState.themeMode,
+ home: const AppRoot(),
+ ),
),
);
}
@@ -64,7 +67,7 @@ class AppRoot extends StatelessWidget {
),
);
}
- return const HomeScreen();
+ return const MainShell();
},
);
}
diff --git a/lib/screens/add_edit_vehicle_screen.dart b/lib/screens/add_edit_vehicle_screen.dart
index 853e35a..5b08f8d 100644
--- a/lib/screens/add_edit_vehicle_screen.dart
+++ b/lib/screens/add_edit_vehicle_screen.dart
@@ -12,11 +12,19 @@ import '../widgets/image_source_sheet.dart';
import 'vehicle_detail_screen.dart';
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
-/// omit it to create a new one.
+/// omit it to create a new one. [promptToCreateForReceipt] is set when this
+/// screen was reached because the user tried to log a receipt with no
+/// vehicle to attach it to yet — it shows an explanatory dialog on arrival
+/// so that's clear, rather than silently landing on the add-vehicle form.
class AddEditVehicleScreen extends StatefulWidget {
final Vehicle? vehicle;
+ final bool promptToCreateForReceipt;
- const AddEditVehicleScreen({super.key, this.vehicle});
+ const AddEditVehicleScreen({
+ super.key,
+ this.vehicle,
+ this.promptToCreateForReceipt = false,
+ });
@override
State createState() => _AddEditVehicleScreenState();
@@ -39,6 +47,24 @@ class _AddEditVehicleScreenState extends State {
super.initState();
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
+
+ if (widget.promptToCreateForReceipt) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ content: const Text('Create a vehicle to attach receipt'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('OK'),
+ ),
+ ],
+ ),
+ );
+ });
+ }
}
@override
@@ -48,8 +74,46 @@ class _AddEditVehicleScreenState extends State {
super.dispose();
}
+ void _showVinLocationHelp() {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Where to find the VIN'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ "It's usually on a sticker in the driver's side door jamb, or on a "
+ 'small plate at the base of the windshield.',
+ ),
+ const SizedBox(height: 16),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: Image.asset('assets/vin_help/door_jamb.jpg'),
+ ),
+ const SizedBox(height: 12),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: Image.asset('assets/vin_help/windshield.jpg'),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
+ ],
+ ),
+ );
+ }
+
Future _scanVin() async {
- final source = await chooseImageSource(context);
+ final source = await chooseImageSource(
+ context,
+ heading: 'VIN From',
+ onInfoTap: _showVinLocationHelp,
+ );
if (source == null || !mounted) return;
final picker = ImagePicker();
@@ -89,13 +153,35 @@ class _AddEditVehicleScreenState extends State {
await _checkForExistingVin();
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(
- content: Text("Couldn't read a VIN from that photo. Please enter it manually."),
+ SnackBar(
+ content: const Text("Couldn't read a VIN from that photo. Please enter it manually."),
+ duration: const Duration(seconds: 8),
+ action: recognizedText.trim().isEmpty
+ ? null
+ // Lets you see exactly what the on-device OCR read, rather
+ // than only knowing "no VIN was found in it" — the
+ // difference between "OCR misread a character" and "OCR
+ // read it fine but the VIN pattern itself needs a fix"
+ // isn't visible any other way.
+ : SnackBarAction(label: 'Show Text', onPressed: () => _showRecognizedText(recognizedText)),
),
);
}
}
+ void _showRecognizedText(String text) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Recognized Text'),
+ content: SingleChildScrollView(child: SelectableText(text)),
+ actions: [
+ TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
+ ],
+ ),
+ );
+ }
+
/// Field-level counterpart to [_checkForExistingVin]'s dialog: catches
/// "Required" as before, plus flags the field itself as invalid when the
/// VIN belongs to another active vehicle — excluding this vehicle's own
@@ -160,24 +246,30 @@ class _AddEditVehicleScreenState extends State {
_error = null;
});
+ final vin = _vinController.text.trim();
final nickname = _nicknameController.text.trim();
final appState = context.read();
try {
if (_isEditing) {
await appState.updateVehicle(widget.vehicle!.copyWith(
- vin: _vinController.text.trim(),
+ vin: vin,
nickname: nickname.isEmpty ? null : nickname,
clearNickname: nickname.isEmpty,
));
} else {
- await appState.addVehicle(
- vin: _vinController.text.trim(),
- nickname: nickname.isEmpty ? null : nickname,
- );
+ await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname);
}
- if (mounted) Navigator.of(context).pop();
+ if (mounted) {
+ // Reached from "log a receipt with no vehicle yet" — once the
+ // vehicle exists, continue straight into the receipt capture flow
+ // the user was actually trying to do, rather than just landing
+ // back on an (again) empty Receipts tab. The caller does the
+ // continuing; this just hands back which vehicle to do it for.
+ final continueToReceipt = !_isEditing && widget.promptToCreateForReceipt;
+ Navigator.of(context).pop(continueToReceipt ? appState.vehicleByVin(vin)?.id : null);
+ }
} on DuplicateVinException catch (e) {
setState(() => _error = e.toString());
} finally {
diff --git a/lib/screens/data_settings_screen.dart b/lib/screens/data_settings_screen.dart
new file mode 100644
index 0000000..2654640
--- /dev/null
+++ b/lib/screens/data_settings_screen.dart
@@ -0,0 +1,550 @@
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+
+import '../services/app_state.dart';
+import '../services/cloud/cloud_storage_provider.dart';
+import 'cloud_folder_browser_screen.dart';
+
+/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage
+/// connection, the two photo-handling switches, and an Advanced section
+/// (stale sync lock timeout, plus the destructive purge actions — grouped
+/// there rather than given their own top-level "Danger Zone" so they sit
+/// behind the same disclosure as other rarely-touched settings).
+class DataSettingsScreen extends StatefulWidget {
+ const DataSettingsScreen({super.key});
+
+ @override
+ State createState() => _DataSettingsScreenState();
+}
+
+class _DataSettingsScreenState extends State {
+ static final _dateFormat = DateFormat.yMMMd();
+
+ bool _busy = false;
+ String? _error;
+
+ DateTime? _purgeRangeStart;
+ DateTime? _purgeRangeEnd;
+ String? _purgeRangeError;
+
+ Future _connect(CloudProviderId id) async {
+ setState(() {
+ _busy = true;
+ _error = null;
+ });
+ try {
+ await context.read().connectProvider(id);
+ } catch (e) {
+ setState(() => _error = 'Could not sign in: $e');
+ } finally {
+ if (mounted) setState(() => _busy = false);
+ }
+ }
+
+ Future _connectManual(CloudProviderId id, String providerName) async {
+ final credentials = await showDialog<_WebDavCredentials>(
+ context: context,
+ builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
+ );
+ if (credentials == null || !mounted) return;
+
+ setState(() {
+ _busy = true;
+ _error = null;
+ });
+ try {
+ await context.read().connectProviderWithCredentials(
+ id,
+ serverUrl: credentials.serverUrl,
+ username: credentials.username,
+ password: credentials.password,
+ );
+ } catch (e) {
+ setState(() => _error = 'Could not connect: $e');
+ } finally {
+ if (mounted) setState(() => _busy = false);
+ }
+ }
+
+ Future _disconnect() async {
+ setState(() => _busy = true);
+ try {
+ await context.read().disconnectCloud();
+ } finally {
+ if (mounted) setState(() => _busy = false);
+ }
+ }
+
+ void _chooseFolder() {
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
+ );
+ }
+
+ Future _syncNow() async {
+ setState(() => _busy = true);
+ try {
+ await context.read().syncNow();
+ } finally {
+ if (mounted) setState(() => _busy = false);
+ }
+ }
+
+ /// Shared warning-dialog shell for both purge actions — [message] is the
+ /// caller's job to make specific and unambiguous, since this is the only
+ /// thing standing between the user and an unrecoverable delete.
+ Future _confirmPurge({required String title, required String message}) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: Text(title),
+ content: Text(message),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ style: TextButton.styleFrom(
+ foregroundColor: Theme.of(dialogContext).colorScheme.error,
+ ),
+ onPressed: () => Navigator.of(dialogContext).pop(true),
+ child: const Text('Delete'),
+ ),
+ ],
+ ),
+ );
+ return confirmed ?? false;
+ }
+
+ /// Extra context appended to a purge warning when a cloud account is
+ /// connected — a purely local delete would otherwise just get silently
+ /// re-imported from the remote copy on the very next sync, which isn't
+ /// obvious from the app's normal behavior.
+ String _cloudSyncCaveat(AppState appState) {
+ if (!appState.isCloudConnected) return '';
+ return '\n\nThis device is connected to ${appState.activeProvider!.displayName}. The '
+ 'deletion will be pushed there on the next sync, same as any other delete.';
+ }
+
+ Future _purgeAllData() async {
+ final appState = context.read();
+ final confirmed = await _confirmPurge(
+ title: 'Purge all data?',
+ message: 'This permanently deletes every vehicle, fuel entry, and receipt photo on '
+ "this device — starting completely fresh. This can't be undone."
+ '${_cloudSyncCaveat(appState)}',
+ );
+ if (!confirmed || !mounted) return;
+
+ setState(() => _busy = true);
+ try {
+ await appState.purgeAllData();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('All data purged.')),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _busy = false);
+ }
+ }
+
+ Future _pickPurgeRangeDate({required bool isStart}) async {
+ final now = DateTime.now();
+ final picked = await showDatePicker(
+ context: context,
+ initialDate: (isStart ? _purgeRangeStart : _purgeRangeEnd) ?? now,
+ firstDate: DateTime(2000),
+ lastDate: now,
+ );
+ if (picked == null) return;
+ setState(() {
+ _purgeRangeError = null;
+ if (isStart) {
+ _purgeRangeStart = picked;
+ } else {
+ _purgeRangeEnd = picked;
+ }
+ });
+ }
+
+ Future _purgeDataInRange() async {
+ final start = _purgeRangeStart;
+ final end = _purgeRangeEnd;
+ if (start == null || end == null) return;
+ if (end.isBefore(start)) {
+ setState(() => _purgeRangeError = 'End date must be on or after the start date.');
+ return;
+ }
+
+ final appState = context.read();
+ final confirmed = await _confirmPurge(
+ title: 'Purge data in range?',
+ message: 'This permanently deletes every fuel entry and receipt photo dated from '
+ '${_dateFormat.format(start)} to ${_dateFormat.format(end)}, across all '
+ "vehicles. Vehicles themselves aren't affected. This can't be undone."
+ '${_cloudSyncCaveat(appState)}',
+ );
+ if (!confirmed || !mounted) return;
+
+ setState(() => _busy = true);
+ try {
+ await appState.purgeFuelEntriesInRange(start, end);
+ if (mounted) {
+ setState(() {
+ _purgeRangeStart = null;
+ _purgeRangeEnd = null;
+ _purgeRangeError = null;
+ });
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Fuel entries in range purged.')),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _busy = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final appState = context.watch();
+
+ return Scaffold(
+ appBar: AppBar(title: const Text('Data')),
+ body: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
+ const SizedBox(height: 8),
+ if (!appState.isCloudConnected) ...[
+ Text(
+ 'Connect a cloud storage account to share vehicles and fuel '
+ 'receipts with other people, and to keep a backup off this device.',
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ const SizedBox(height: 12),
+ if (_error != null) ...[
+ Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
+ const SizedBox(height: 8),
+ ],
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ for (final provider in appState.availableProviders)
+ FilledButton.icon(
+ onPressed: _busy
+ ? null
+ : () => provider is ManualCredentialCloudStorageProvider
+ ? _connectManual(provider.id, provider.displayName)
+ : _connect(provider.id),
+ icon: const Icon(Icons.login),
+ label: Text('Connect ${provider.displayName}'),
+ ),
+ ],
+ ),
+ ] else ...[
+ Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
+ const SizedBox(height: 4),
+ Text(
+ appState.cloudFolderPath == null
+ ? 'No folder selected yet.'
+ : 'Folder: ${appState.cloudFolderPath}',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 12),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ OutlinedButton.icon(
+ onPressed: _busy ? null : _chooseFolder,
+ icon: const Icon(Icons.folder_open),
+ label: Text(appState.cloudFolderPath == null
+ ? 'Choose Folder'
+ : 'Change Folder'),
+ ),
+ OutlinedButton.icon(
+ onPressed: (_busy || appState.cloudFolderPath == null)
+ ? null
+ : _syncNow,
+ icon: appState.isSyncing
+ ? const SizedBox(
+ height: 16,
+ width: 16,
+ child: CircularProgressIndicator(strokeWidth: 2))
+ : const Icon(Icons.sync),
+ label: const Text('Sync Now'),
+ ),
+ TextButton.icon(
+ onPressed: _busy ? null : _disconnect,
+ icon: const Icon(Icons.logout),
+ label: const Text('Disconnect'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ if (appState.lastSyncedAt != null)
+ Text(
+ 'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ if (appState.lastSyncError != null)
+ Padding(
+ padding: const EdgeInsets.only(top: 4),
+ child: Text(
+ 'Last sync failed: ${appState.lastSyncError}',
+ style: TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Card(
+ child: SwitchListTile(
+ title: const Text('Keep photos on this phone after syncing'),
+ subtitle: const Text(
+ 'Otherwise, a receipt photo is removed from this device once '
+ "it's safely uploaded to the cloud.",
+ ),
+ value: appState.keepReceiptPhotosLocally,
+ onChanged: (value) => context.read().setKeepReceiptPhotosLocally(value),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Card(
+ child: SwitchListTile(
+ title: const Text('Keep max quality images'),
+ subtitle: const Text(
+ 'Otherwise, receipt photos are downscaled to a reasonable size '
+ 'before storing — smaller cloud storage and faster syncs, with '
+ 'no loss of legibility for a printed receipt.',
+ ),
+ value: appState.keepMaxQualityReceiptPhotos,
+ onChanged: (value) =>
+ context.read().setKeepMaxQualityReceiptPhotos(value),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Card(
+ child: ExpansionTile(
+ title: const Text('Advanced'),
+ childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
+ children: [
+ Align(
+ alignment: Alignment.centerLeft,
+ child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ "If another device disconnects mid-sync without releasing its lock, "
+ "this is how long to wait before treating it as abandoned and clearing "
+ "it so sync can continue.",
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ Slider(
+ value: appState.staleLockMinutes.toDouble(),
+ min: minStaleLockMinutes.toDouble(),
+ max: maxStaleLockMinutes.toDouble(),
+ divisions: maxStaleLockMinutes - minStaleLockMinutes,
+ label: '${appState.staleLockMinutes} min',
+ onChanged: (value) =>
+ context.read().setStaleLockMinutes(value.round()),
+ ),
+ Text('${appState.staleLockMinutes} minute(s)'),
+ const Divider(height: 32),
+ Text(
+ 'Danger Zone',
+ style: Theme.of(context)
+ .textTheme
+ .titleMedium
+ ?.copyWith(color: Theme.of(context).colorScheme.error),
+ ),
+ const SizedBox(height: 12),
+ Text('Purge All Data', style: Theme.of(context).textTheme.titleSmall),
+ const SizedBox(height: 4),
+ Text(
+ 'Deletes every vehicle, fuel entry, and receipt photo on this device '
+ 'and starts completely fresh.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton.icon(
+ onPressed: _busy ? null : _purgeAllData,
+ icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
+ label: Text(
+ 'Purge All Data',
+ style: TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ style: OutlinedButton.styleFrom(
+ side: BorderSide(color: Theme.of(context).colorScheme.error),
+ ),
+ ),
+ ),
+ const Divider(height: 32),
+ Text('Purge Data by Date Range', style: Theme.of(context).textTheme.titleSmall),
+ const SizedBox(height: 4),
+ Text(
+ 'Deletes fuel entries and receipt photos dated within a range, across '
+ 'all vehicles. Vehicles themselves are kept.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ Expanded(
+ child: OutlinedButton(
+ onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: true),
+ child: Text(
+ _purgeRangeStart == null
+ ? 'Start date'
+ : _dateFormat.format(_purgeRangeStart!),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: OutlinedButton(
+ onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: false),
+ child: Text(
+ _purgeRangeEnd == null ? 'End date' : _dateFormat.format(_purgeRangeEnd!),
+ ),
+ ),
+ ),
+ ],
+ ),
+ if (_purgeRangeError != null) ...[
+ const SizedBox(height: 8),
+ Text(
+ _purgeRangeError!,
+ style: TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ ],
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton.icon(
+ onPressed: (_busy || _purgeRangeStart == null || _purgeRangeEnd == null)
+ ? null
+ : _purgeDataInRange,
+ icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
+ label: Text(
+ 'Purge Range',
+ style: TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ style: OutlinedButton.styleFrom(
+ side: BorderSide(color: Theme.of(context).colorScheme.error),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _WebDavCredentials {
+ final String serverUrl;
+ final String username;
+ final String password;
+ _WebDavCredentials({required this.serverUrl, required this.username, required this.password});
+}
+
+/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
+/// needs, since (unlike the OAuth providers) there's no browser flow to
+/// gather these instead.
+class _WebDavCredentialsDialog extends StatefulWidget {
+ final String providerName;
+ const _WebDavCredentialsDialog({required this.providerName});
+
+ @override
+ State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
+}
+
+class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
+ final _formKey = GlobalKey();
+ final _serverController = TextEditingController();
+ final _usernameController = TextEditingController();
+ final _passwordController = TextEditingController();
+
+ @override
+ void dispose() {
+ _serverController.dispose();
+ _usernameController.dispose();
+ _passwordController.dispose();
+ super.dispose();
+ }
+
+ void _submit() {
+ if (!_formKey.currentState!.validate()) return;
+ Navigator.of(context).pop(_WebDavCredentials(
+ serverUrl: _serverController.text.trim(),
+ username: _usernameController.text.trim(),
+ password: _passwordController.text,
+ ));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: Text('Connect ${widget.providerName}'),
+ content: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextFormField(
+ controller: _serverController,
+ decoration: const InputDecoration(
+ labelText: 'Server URL',
+ hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
+ ),
+ keyboardType: TextInputType.url,
+ validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _usernameController,
+ decoration: const InputDecoration(labelText: 'Username'),
+ validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _passwordController,
+ decoration: const InputDecoration(labelText: 'Password'),
+ obscureText: true,
+ validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
+ onFieldSubmitted: (_) => _submit(),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: _submit,
+ child: const Text('Connect'),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/screens/edit_fuel_entry_screen.dart b/lib/screens/edit_fuel_entry_screen.dart
new file mode 100644
index 0000000..c331158
--- /dev/null
+++ b/lib/screens/edit_fuel_entry_screen.dart
@@ -0,0 +1,186 @@
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+
+import '../models/fuel_entry.dart';
+import '../services/app_state.dart';
+import '../widgets/receipt_thumbnail.dart';
+
+/// Lets the user correct the *data* logged about a receipt (date, gallons,
+/// price/gal, total cost) — not the receipt photo itself, which is shown
+/// read-only up top (tap it to view full-screen, same as everywhere else
+/// it's shown) and can't be replaced from here.
+class EditFuelEntryScreen extends StatefulWidget {
+ final FuelEntry entry;
+
+ const EditFuelEntryScreen({super.key, required this.entry});
+
+ @override
+ State createState() => _EditFuelEntryScreenState();
+}
+
+class _EditFuelEntryScreenState extends State {
+ final _formKey = GlobalKey();
+ late final TextEditingController _gallonsController;
+ late final TextEditingController _priceController;
+ late final TextEditingController _totalController;
+ late DateTime _date;
+ bool _saving = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _date = widget.entry.date;
+ _gallonsController = TextEditingController(text: widget.entry.gallons.toStringAsFixed(3));
+ _priceController = TextEditingController(text: widget.entry.pricePerGallon.toStringAsFixed(3));
+ _totalController = TextEditingController(text: widget.entry.totalCost.toStringAsFixed(2));
+ }
+
+ @override
+ void dispose() {
+ _gallonsController.dispose();
+ _priceController.dispose();
+ _totalController.dispose();
+ super.dispose();
+ }
+
+ void _calculateTotal() {
+ final gallons = double.tryParse(_gallonsController.text);
+ final price = double.tryParse(_priceController.text);
+ if (gallons != null && price != null) {
+ setState(() {
+ _totalController.text = (gallons * price).toStringAsFixed(2);
+ });
+ }
+ }
+
+ Future _pickDate() async {
+ // initialDate must fall within [firstDate, lastDate] or the picker
+ // throws — widen the bounds to cover _date in case it's already
+ // outside the normal 5-years-back-to-today window.
+ final today = DateTime.now();
+ final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
+ ? _date
+ : today.subtract(const Duration(days: 365 * 5));
+ final lastDate = _date.isAfter(today) ? _date : today;
+
+ final pickedDate = await showDatePicker(
+ context: context,
+ initialDate: _date,
+ firstDate: firstDate,
+ lastDate: lastDate,
+ );
+ if (pickedDate == null || !mounted) return;
+
+ final pickedTime = await showTimePicker(
+ context: context,
+ initialTime: TimeOfDay.fromDateTime(_date),
+ );
+ if (pickedTime == null) return;
+
+ setState(() {
+ _date = DateTime(
+ pickedDate.year,
+ pickedDate.month,
+ pickedDate.day,
+ pickedTime.hour,
+ pickedTime.minute,
+ );
+ });
+ }
+
+ Future _save() async {
+ if (!_formKey.currentState!.validate()) return;
+
+ setState(() => _saving = true);
+ try {
+ await context.read().updateFuelEntry(
+ entryId: widget.entry.id,
+ date: _date,
+ gallons: double.parse(_gallonsController.text),
+ pricePerGallon: double.parse(_priceController.text),
+ totalCost: double.parse(_totalController.text),
+ );
+ if (mounted) Navigator.of(context).pop();
+ } finally {
+ if (mounted) setState(() => _saving = false);
+ }
+ }
+
+ String? _requiredDecimal(String? value) {
+ if (value == null || value.trim().isEmpty) return 'Required';
+ if (double.tryParse(value) == null) return 'Enter a valid number';
+ return null;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final dateFormat = DateFormat.yMMMd().add_jm();
+
+ return Scaffold(
+ appBar: AppBar(title: const Text('Edit Fuel Entry')),
+ body: Form(
+ key: _formKey,
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ Center(
+ child: ReceiptThumbnail(entry: widget.entry, size: 180),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Tap the photo to view it full-screen. The photo itself can\'t '
+ "be changed here — only the data below can.",
+ style: Theme.of(context).textTheme.bodySmall,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 16),
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Date & time'),
+ subtitle: Text(dateFormat.format(_date)),
+ trailing: const Icon(Icons.edit_calendar_outlined),
+ onTap: _pickDate,
+ ),
+ const SizedBox(height: 8),
+ TextFormField(
+ controller: _gallonsController,
+ decoration: const InputDecoration(labelText: 'Gallons', suffixText: 'gal'),
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ validator: _requiredDecimal,
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _priceController,
+ decoration: const InputDecoration(labelText: 'Price per gallon', prefixText: '\$'),
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ validator: _requiredDecimal,
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _totalController,
+ decoration: InputDecoration(
+ labelText: 'Total cost',
+ prefixText: '\$',
+ suffixIcon: IconButton(
+ icon: const Icon(Icons.calculate_outlined),
+ tooltip: 'Calculate from gallons × price',
+ onPressed: _calculateTotal,
+ ),
+ ),
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ validator: _requiredDecimal,
+ ),
+ const SizedBox(height: 24),
+ FilledButton(
+ onPressed: _saving ? null : _save,
+ child: _saving
+ ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
+ : const Text('Save Changes'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index d6ae292..39302c3 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -4,10 +4,10 @@ import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import 'add_edit_vehicle_screen.dart';
-import 'report_screen.dart';
-import 'settings_screen.dart';
import 'vehicle_detail_screen.dart';
+/// The "Vehicles" tab body — Settings and Reports are reached via the
+/// bottom nav now (see [MainShell]), not from icons here.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@@ -18,20 +18,14 @@ class HomeScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
- title: const Text('My Vehicles'),
+ title: const Text('Vehicles'),
+ actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
- icon: const Icon(Icons.summarize_outlined),
- tooltip: 'Fuel Report',
+ icon: const Icon(Icons.add),
+ tooltip: 'Add Vehicle',
onPressed: () => Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const ReportScreen()),
- ),
- ),
- IconButton(
- icon: const Icon(Icons.settings_outlined),
- tooltip: 'Settings',
- onPressed: () => Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const SettingsScreen()),
+ MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
),
),
],
@@ -48,13 +42,6 @@ class HomeScreen extends StatelessWidget {
return _VehicleCard(vehicle: vehicle, totalGallons: gallons);
},
),
- floatingActionButton: FloatingActionButton.extended(
- onPressed: () => Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
- ),
- icon: const Icon(Icons.add),
- label: const Text('Add Vehicle'),
- ),
);
}
}
@@ -72,19 +59,44 @@ class _VehicleCard extends StatelessWidget {
return Card(
clipBehavior: Clip.antiAlias,
- child: ListTile(
- contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- leading: const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
- title: Text(vehicle.displayLabel),
- // Only repeat the VIN here when the title is already showing the
- // nickname instead — otherwise the title (falling back to the VIN
- // when there's no nickname) would show it twice.
- subtitle: Text(hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine),
- isThreeLine: hasNickname,
- trailing: const Icon(Icons.chevron_right),
+ child: InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ // Row cross-axis defaults to center, so the chevron sits
+ // vertically centered against the title+subtitle block even when
+ // the nickname line makes that block taller — ListTile's
+ // isThreeLine forces leading/trailing to the top instead, per
+ // Material spec, which is what pinned it to the top before.
+ child: Row(
+ children: [
+ const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(vehicle.displayLabel, style: Theme.of(context).textTheme.titleMedium),
+ // Only repeat the VIN here when the title is already
+ // showing the nickname instead — otherwise the title
+ // (falling back to the VIN when there's no nickname)
+ // would show it twice.
+ Text(
+ hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const Icon(Icons.chevron_right),
+ ],
+ ),
+ ),
),
);
}
diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart
new file mode 100644
index 0000000..f747479
--- /dev/null
+++ b/lib/screens/main_shell.dart
@@ -0,0 +1,91 @@
+import 'package:flutter/material.dart';
+
+import 'home_screen.dart';
+import 'receipts_screen.dart';
+import 'report_screen.dart';
+import 'settings_screen.dart';
+
+/// App root once loaded: a persistent bottom nav bar over the four main
+/// sections — Receipts (all vehicles), Vehicles (the vehicle list),
+/// Reports, and Settings. Each tab keeps its own [Scaffold]/[AppBar]; this
+/// shell only owns the [PageView]/[NavigationBar] and which tab is
+/// selected.
+///
+/// A right-to-left swipe on the body advances to the next tab (and
+/// left-to-right goes back) with the same sliding animation tapping a
+/// [NavigationDestination] uses — both go through [_goToTab], which is the
+/// only thing that moves [_pageController]. [AutomaticKeepAliveClientMixin]
+/// on the three stateful tab screens (Receipts/Report/Settings) is what
+/// keeps each tab's state — scroll position, toggles, in-progress form
+/// fields — alive when [PageView] builds/tears down pages outside its
+/// cache extent, the same guarantee the previous [IndexedStack]-based
+/// version gave "for free".
+class MainShell extends StatefulWidget {
+ const MainShell({super.key});
+
+ @override
+ State createState() => _MainShellState();
+}
+
+class _MainShellState extends State {
+ int _index = 0;
+ final _pageController = PageController();
+
+ static const _tabs = [
+ ReceiptsScreen(),
+ HomeScreen(),
+ ReportScreen(),
+ SettingsScreen(),
+ ];
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ void _goToTab(int index) {
+ _pageController.animateToPage(
+ index,
+ duration: const Duration(milliseconds: 280),
+ curve: Curves.easeOutCubic,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: PageView(
+ controller: _pageController,
+ onPageChanged: (index) => setState(() => _index = index),
+ children: _tabs,
+ ),
+ bottomNavigationBar: NavigationBar(
+ selectedIndex: _index,
+ onDestinationSelected: _goToTab,
+ destinations: const [
+ NavigationDestination(
+ icon: Icon(Icons.receipt_long_outlined),
+ selectedIcon: Icon(Icons.receipt_long),
+ label: 'Receipts',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.directions_car_outlined),
+ selectedIcon: Icon(Icons.directions_car),
+ label: 'Vehicles',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.summarize_outlined),
+ selectedIcon: Icon(Icons.summarize),
+ label: 'Reports',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.settings_outlined),
+ selectedIcon: Icon(Icons.settings),
+ label: 'Settings',
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/screens/receipt_detail_screen.dart b/lib/screens/receipt_detail_screen.dart
new file mode 100644
index 0000000..929c91e
--- /dev/null
+++ b/lib/screens/receipt_detail_screen.dart
@@ -0,0 +1,128 @@
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+
+import '../models/fuel_entry.dart';
+import '../services/app_state.dart';
+import '../widgets/receipt_thumbnail.dart';
+import 'edit_fuel_entry_screen.dart';
+
+/// The single place to view a receipt's photo and its logged data
+/// together, with edit/delete for the entry — reached by tapping a receipt
+/// row on either the Receipts tab or a vehicle's detail page. Looks up
+/// [entryId] fresh from [AppState] on every build (rather than taking a
+/// [FuelEntry] directly) so it reflects an edit made via
+/// [EditFuelEntryScreen] as soon as that screen is popped back to this one.
+class ReceiptDetailScreen extends StatelessWidget {
+ final String entryId;
+
+ const ReceiptDetailScreen({super.key, required this.entryId});
+
+ Future _deleteEntry(BuildContext context, FuelEntry entry) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Delete fuel entry?'),
+ content: const Text('This will remove the entry and its receipt photo.'),
+ actions: [
+ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: const Text('Delete', style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ if (confirmed == true && context.mounted) {
+ await context.read().deleteFuelEntry(entry.id);
+ if (context.mounted) Navigator.of(context).pop();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final appState = context.watch();
+ final entry = appState.fuelEntryById(entryId);
+
+ if (entry == null) {
+ return const Scaffold(body: Center(child: Text('Receipt not found')));
+ }
+
+ final vehicle = appState.vehicleById(entry.vehicleId);
+ final dateFormat = DateFormat.yMMMd().add_jm();
+ final currencyFormat = NumberFormat.simpleCurrency();
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Receipt'),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.edit_outlined),
+ tooltip: 'Edit fuel entry',
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => EditFuelEntryScreen(entry: entry)),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.delete_outline),
+ tooltip: 'Delete fuel entry',
+ onPressed: () => _deleteEntry(context, entry),
+ ),
+ ],
+ ),
+ body: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ Center(child: ReceiptThumbnail(entry: entry, size: 240)),
+ const SizedBox(height: 8),
+ Text(
+ 'Tap the photo to view it full-screen.',
+ style: Theme.of(context).textTheme.bodySmall,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ if (vehicle != null) ...[
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: const Icon(Icons.directions_car_outlined),
+ title: Text(vehicle.displayLabel),
+ subtitle: Text('VIN: ${vehicle.vin}'),
+ ),
+ const Divider(height: 24),
+ ],
+ _DetailRow(label: 'Date & time', value: dateFormat.format(entry.date)),
+ _DetailRow(label: 'Gallons', value: '${entry.gallons.toStringAsFixed(3)} gal'),
+ _DetailRow(label: 'Price per gallon', value: currencyFormat.format(entry.pricePerGallon)),
+ _DetailRow(label: 'Total cost', value: currencyFormat.format(entry.totalCost)),
+ ],
+ ),
+ );
+ }
+}
+
+class _DetailRow extends StatelessWidget {
+ final String label;
+ final String value;
+
+ const _DetailRow({required this.label, required this.value});
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ label,
+ style: Theme.of(context)
+ .textTheme
+ .bodyMedium
+ ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ Text(value, style: Theme.of(context).textTheme.titleMedium),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/screens/receipts_screen.dart b/lib/screens/receipts_screen.dart
new file mode 100644
index 0000000..d2a866f
--- /dev/null
+++ b/lib/screens/receipts_screen.dart
@@ -0,0 +1,332 @@
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+
+import '../models/fuel_entry.dart';
+import '../models/vehicle.dart';
+import '../services/app_state.dart';
+import '../theme/app_theme.dart';
+import '../widgets/hero_banner.dart';
+import '../widgets/receipt_capture.dart';
+import 'add_edit_vehicle_screen.dart';
+import 'receipt_detail_screen.dart';
+
+/// The three summary periods selectable below the hero totals — the
+/// totals and the "Recent Activity" list below are both filtered to
+/// whichever one is currently selected.
+enum _DateRange {
+ currentMonth('Current Month'),
+ last3Months('Last 3 Months'),
+ yearToDate('Year to Date');
+
+ final String label;
+ const _DateRange(this.label);
+
+ /// Inclusive start of this range, relative to [now]. There's no
+ /// explicit end — an entry just needs to not be *before* this to count,
+ /// since receipts are never dated in the future in practice.
+ DateTime startDate(DateTime now) {
+ switch (this) {
+ case _DateRange.currentMonth:
+ return DateTime(now.year, now.month, 1);
+ case _DateRange.last3Months:
+ // Current month plus the two before it, from the 1st of the
+ // earliest — DateTime normalizes a zero/negative month across a
+ // year boundary correctly (e.g. month 0 becomes December prior year).
+ return DateTime(now.year, now.month - 2, 1);
+ case _DateRange.yearToDate:
+ return DateTime(now.year, 1, 1);
+ }
+ }
+}
+
+/// The "Receipts" tab: every fuel entry across every vehicle, newest
+/// first, styled after the theme mockups' receipt-focused layout — a
+/// hero total, a date-range filter, then a flat, divided list of receipts
+/// (date + amount + which vehicle) for that range, each tapping straight
+/// through to that receipt's photo rather than to the vehicle it belongs to.
+class ReceiptsScreen extends StatefulWidget {
+ const ReceiptsScreen({super.key});
+
+ @override
+ State createState() => _ReceiptsScreenState();
+}
+
+class _ReceiptsScreenState extends State with AutomaticKeepAliveClientMixin {
+ _DateRange _range = _DateRange.yearToDate;
+
+ // Keeps this tab's state (the selected date range) alive when MainShell's
+ // PageView builds/tears down tabs outside its cache extent as the user
+ // swipes between them.
+ @override
+ bool get wantKeepAlive => true;
+
+ Future _addReceipt(BuildContext context) async {
+ final vehicles = context.read().vehicles;
+ if (vehicles.isEmpty) {
+ final newVehicleId = await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const AddEditVehicleScreen(promptToCreateForReceipt: true),
+ ),
+ );
+ // Creating the vehicle was only ever a means to an end here — continue
+ // straight into the receipt capture the user actually came here for,
+ // rather than dropping them back on a Receipts tab that's still empty.
+ if (newVehicleId != null && context.mounted) {
+ await captureReceiptForVehicle(context, newVehicleId);
+ }
+ return;
+ }
+ if (vehicles.length == 1) {
+ await captureReceiptForVehicle(context, vehicles.first.id);
+ return;
+ }
+
+ final chosen = await showModalBottomSheet(
+ context: context,
+ builder: (context) => SafeArea(
+ child: ListView(
+ shrinkWrap: true,
+ children: [
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Text('Which vehicle is this receipt for?'),
+ ),
+ for (final vehicle in vehicles)
+ ListTile(
+ leading: const Icon(Icons.directions_car_outlined),
+ title: Text(vehicle.displayLabel),
+ onTap: () => Navigator.of(context).pop(vehicle),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (chosen != null && context.mounted) {
+ await captureReceiptForVehicle(context, chosen.id);
+ }
+ }
+
+ void _openReceipt(BuildContext context, FuelEntry entry) {
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => ReceiptDetailScreen(entryId: entry.id)),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ final appState = context.watch();
+ final rangeStart = _range.startDate(DateTime.now());
+ final entries = appState.fuelEntries.where((e) => !e.date.isBefore(rangeStart)).toList()
+ ..sort((a, b) => b.date.compareTo(a.date));
+ final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
+ final totalGallons = entries.fold(0.0, (sum, e) => sum + e.gallons);
+ final dateFormat = DateFormat.yMMMd();
+ final currencyFormat = NumberFormat.simpleCurrency();
+ final gallonsFormat = NumberFormat('#,##0.000');
+ // The hero stat is a big headline number, not a precise figure — round
+ // to whole gallons there rather than showing the same 3-decimal
+ // precision the per-entry rows use.
+ final heroGallonsFormat = NumberFormat('#,##0');
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Receipts'),
+ actionsPadding: const EdgeInsets.only(right: 20),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.add),
+ tooltip: 'Log Fuel Receipt',
+ onPressed: () => _addReceipt(context),
+ ),
+ ],
+ ),
+ body: ListView(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
+ children: [
+ _HeroTotal(
+ totalCost: totalCost,
+ totalGallons: totalGallons,
+ currencyFormat: currencyFormat,
+ gallonsFormat: heroGallonsFormat,
+ selectedRange: _range,
+ onRangeSelected: (range) => setState(() => _range = range),
+ ),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(2, 14, 2, 8),
+ child: Text(
+ 'Recent Activity',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ ),
+ if (entries.isEmpty)
+ const Padding(
+ padding: EdgeInsets.symmetric(vertical: 24),
+ child: Text('No fuel entries in this range.'),
+ )
+ else
+ for (var i = 0; i < entries.length; i++) ...[
+ if (i > 0) const Divider(height: 1),
+ _ReceiptRow(
+ entry: entries[i],
+ vehicle: appState.vehicleById(entries[i].vehicleId),
+ dateFormat: dateFormat,
+ currencyFormat: currencyFormat,
+ gallonsFormat: gallonsFormat,
+ onTap: () => _openReceipt(context, entries[i]),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+/// Sits inside the hero banner (on the gradient, not the plain scaffold
+/// background), so selected/unselected use the hero's own white/translucent
+/// text colors rather than the surface-based colorScheme ones.
+class _DateRangeSelector extends StatelessWidget {
+ final _DateRange selected;
+ final ValueChanged<_DateRange> onSelected;
+
+ const _DateRangeSelector({required this.selected, required this.onSelected});
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ for (final range in _DateRange.values)
+ GestureDetector(
+ onTap: () => onSelected(range),
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Text(
+ range.label,
+ style: TextStyle(
+ color: range == selected
+ ? AppTheme.heroText
+ : AppTheme.heroTextSecondary(context),
+ fontWeight: range == selected ? FontWeight.w800 : FontWeight.w600,
+ fontSize: 12.5,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _HeroTotal extends StatelessWidget {
+ final double totalCost;
+ final double totalGallons;
+ final NumberFormat currencyFormat;
+ final NumberFormat gallonsFormat;
+ final _DateRange selectedRange;
+ final ValueChanged<_DateRange> onRangeSelected;
+
+ const _HeroTotal({
+ required this.totalCost,
+ required this.totalGallons,
+ required this.currencyFormat,
+ required this.gallonsFormat,
+ required this.selectedRange,
+ required this.onRangeSelected,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return HeroBanner(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ HeroStatsRow(
+ gallonsValue: gallonsFormat.format(totalGallons),
+ costValue: currencyFormat.format(totalCost),
+ ),
+ const SizedBox(height: 7),
+ _DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected),
+ ],
+ ),
+ );
+ }
+}
+
+class _ReceiptRow extends StatelessWidget {
+ final FuelEntry entry;
+ final Vehicle? vehicle;
+ final DateFormat dateFormat;
+ final NumberFormat currencyFormat;
+ final NumberFormat gallonsFormat;
+ final VoidCallback onTap;
+
+ const _ReceiptRow({
+ required this.entry,
+ required this.vehicle,
+ required this.dateFormat,
+ required this.currencyFormat,
+ required this.gallonsFormat,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ dateFormat.format(entry.date),
+ style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
+ ),
+ const SizedBox(height: 3),
+ Text(
+ '${gallonsFormat.format(entry.gallons)} gal',
+ style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5),
+ ),
+ ],
+ ),
+ ),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ currencyFormat.format(entry.totalCost),
+ style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
+ ),
+ const SizedBox(height: 4),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
+ decoration: BoxDecoration(
+ color: colorScheme.primary.withValues(alpha: 0.10),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ vehicle?.displayLabel ?? 'Unknown vehicle',
+ style: TextStyle(
+ color: colorScheme.primary,
+ fontSize: 10.5,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart
index c3a76b4..743fed7 100644
--- a/lib/screens/report_screen.dart
+++ b/lib/screens/report_screen.dart
@@ -1,12 +1,24 @@
+import 'dart:typed_data';
+
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:printing/printing.dart';
import 'package:provider/provider.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../services/app_state.dart';
import '../services/fuel_report.dart';
+import '../services/fuel_report_images.dart';
import '../services/fuel_report_pdf.dart';
+/// Missouri's Motor Fuel Refund Claim form isn't something this app can
+/// file for the user — it's a state form, submitted to the state — so the
+/// Reports tab just links out to where to get it rather than trying to
+/// reproduce or embed it.
+final _refundFormUrl = Uri.parse(
+ 'https://dor.mo.gov/forms/?formName=Motor+Fuel+Refund+Claim&category=&year=99',
+);
+
class ReportScreen extends StatefulWidget {
const ReportScreen({super.key});
@@ -14,13 +26,30 @@ class ReportScreen extends StatefulWidget {
State createState() => _ReportScreenState();
}
-class _ReportScreenState extends State {
+class _ReportScreenState extends State with AutomaticKeepAliveClientMixin {
static final _dateFormat = DateFormat.yMMMd();
DateTime? _startDate;
DateTime? _endDate;
+ bool _includeReceiptImages = false;
+ bool _includeLineItems = false;
bool _busy = false;
+ /// Which vehicles (by id) to include in the report — checkboxes next to
+ /// each vehicle below the date range. [_seenVehicleIds] tracks every
+ /// vehicle a checkbox has ever been shown for, so a vehicle newly
+ /// appearing in range (first time it's seen) defaults to selected without
+ /// clobbering a selection the user already made for one seen before (e.g.
+ /// after picking a different range and back).
+ final Set _selectedVehicleIds = {};
+ final Set _seenVehicleIds = {};
+
+ // Keeps this tab's state (chosen date range, include-image/line-item
+ // toggles) alive when MainShell's PageView builds/tears down tabs
+ // outside its cache extent as the user swipes between them.
+ @override
+ bool get wantKeepAlive => true;
+
Future _pickDate({required bool isStart}) async {
final now = DateTime.now();
final picked = await showDatePicker(
@@ -39,10 +68,39 @@ class _ReportScreenState extends State {
});
}
+ Future _buildPdfBytes(FuelReport report) async {
+ var receiptImages = const {};
+ if (_includeReceiptImages) {
+ final loaded = await loadReceiptImageBytes(
+ report: report,
+ activeProvider: context.read().activeProvider,
+ );
+ receiptImages = loaded.bytesByEntryId;
+ if (loaded.failedEntries.isNotEmpty && mounted) {
+ final count = loaded.failedEntries.length;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ "$count receipt photo${count == 1 ? '' : 's'} couldn't be included — offline, "
+ 'not signed in to the provider that has it, or the remote file is gone.',
+ ),
+ duration: const Duration(seconds: 6),
+ ),
+ );
+ }
+ }
+ return buildFuelReportPdf(
+ report,
+ includeLineItems: _includeLineItems,
+ includeReceiptImages: _includeReceiptImages,
+ receiptImages: receiptImages,
+ );
+ }
+
Future _share(FuelReport report) async {
setState(() => _busy = true);
try {
- final bytes = await buildFuelReportPdf(report);
+ final bytes = await _buildPdfBytes(report);
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
} finally {
if (mounted) setState(() => _busy = false);
@@ -52,32 +110,55 @@ class _ReportScreenState extends State {
Future _print(FuelReport report) async {
setState(() => _busy = true);
try {
- final bytes = await buildFuelReportPdf(report);
+ final bytes = await _buildPdfBytes(report);
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
} finally {
if (mounted) setState(() => _busy = false);
}
}
+ Future _openRefundFormLink() async {
+ final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication);
+ if (!launched && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text("Couldn't open the link.")),
+ );
+ }
+ }
+
@override
Widget build(BuildContext context) {
+ super.build(context);
final appState = context.watch();
String? rangeError;
- FuelReport? report;
+ FuelReport? rawReport;
if (_startDate != null && _endDate != null) {
if (_endDate!.isBefore(_startDate!)) {
rangeError = 'End date must be on or after the start date.';
} else {
- report = buildFuelReport(
+ rawReport = buildFuelReport(
vehicles: appState.vehicles,
fuelEntries: appState.fuelEntries,
startDate: _startDate!,
endDate: _endDate!,
);
+ for (final row in rawReport.rows) {
+ if (_seenVehicleIds.add(row.vehicle.id)) {
+ _selectedVehicleIds.add(row.vehicle.id);
+ }
+ }
}
}
+ final report = rawReport == null
+ ? null
+ : FuelReport(
+ startDate: rawReport.startDate,
+ endDate: rawReport.endDate,
+ rows: rawReport.rows.where((r) => _selectedVehicleIds.contains(r.vehicle.id)).toList(),
+ );
+
return Scaffold(
appBar: AppBar(title: const Text('Fuel Report')),
body: ListView(
@@ -104,69 +185,202 @@ class _ReportScreenState extends State {
const SizedBox(height: 8),
Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
- const SizedBox(height: 16),
- if (report == null)
+ const SizedBox(height: 8),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Include Receipt Images'),
+ subtitle: const Text(
+ 'Every receipt photo in the date range, grouped by vehicle and '
+ 'sorted by date — packed as many to a page as will fit.',
+ ),
+ value: _includeReceiptImages,
+ onChanged: (value) => setState(() => _includeReceiptImages = value),
+ ),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Include Receipt Line Items'),
+ subtitle: const Text(
+ 'Every receipt as its own line (date, gallons, price/gal, '
+ 'cost), grouped by vehicle and sorted by date.',
+ ),
+ value: _includeLineItems,
+ onChanged: (value) => setState(() => _includeLineItems = value),
+ ),
+ if (_includeReceiptImages && _includeLineItems)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Text(
+ 'Each line item and its receipt photo will be tagged with a '
+ 'matching number.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ const SizedBox(height: 8),
+ if (rawReport == 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)
+ else if (rawReport.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)),
- ],
- ),
- ),
+ for (final row in rawReport.rows)
+ _VehicleSelectionCard(
+ row: row,
+ selected: _selectedVehicleIds.contains(row.vehicle.id),
+ onTap: () => setState(() {
+ if (_selectedVehicleIds.contains(row.vehicle.id)) {
+ _selectedVehicleIds.remove(row.vehicle.id);
+ } else {
+ _selectedVehicleIds.add(row.vehicle.id);
+ }
+ }),
),
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),
+ if (report!.rows.isEmpty)
+ const Padding(
+ padding: EdgeInsets.symmetric(vertical: 24),
+ child: Text('No vehicles selected — tap at least one above to build a report.'),
+ )
+ else ...[
+ 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'),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ],
+ const SizedBox(height: 24),
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Need the refund claim form?', style: Theme.of(context).textTheme.titleMedium),
+ const SizedBox(height: 8),
+ Text(
+ "This app tracks your fuel purchases, but the actual refund claim is "
+ "filed with the state. Get the official form from Missouri's "
+ 'Department of Revenue website.',
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ const SizedBox(height: 12),
+ InkWell(
+ onTap: _openRefundFormLink,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.open_in_new, size: 18, color: Theme.of(context).colorScheme.primary),
+ const SizedBox(width: 6),
+ Flexible(
+ child: Text(
+ 'Get the Motor Fuel Refund Claim form',
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.primary,
+ fontWeight: FontWeight.w600,
+ decoration: TextDecoration.underline,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
),
),
- 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'),
- ),
- ),
- ],
- ),
- ],
+ ),
],
),
);
}
}
+
+/// A per-vehicle row in the vehicle-selection list — tapping the whole
+/// card toggles [selected], indicated purely by color rather than a
+/// checkbox: [selected] uses the same primary/on-primary theming as the
+/// "Share" [FilledButton] below it, [!selected] looks like a plain
+/// unselected [Card] (default surface color, normal text colors).
+class _VehicleSelectionCard extends StatelessWidget {
+ final VehicleReportRow row;
+ final bool selected;
+ final VoidCallback onTap;
+
+ const _VehicleSelectionCard({required this.row, required this.selected, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface;
+ final foregroundVariant = selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant;
+
+ return Card(
+ color: selected ? colorScheme.primary : null,
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ row.vehicle.displayLabel,
+ style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 16),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ 'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
+ 'entr${row.entryCount == 1 ? 'y' : 'ies'}',
+ style: TextStyle(color: foregroundVariant),
+ ),
+ ],
+ ),
+ ),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text('${row.totalGallons.toStringAsFixed(3)} gal', style: TextStyle(color: foreground)),
+ Text(
+ NumberFormat.simpleCurrency().format(row.totalCost),
+ style: TextStyle(color: foreground),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 5a559df..d694928 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -1,85 +1,16 @@
import 'package:flutter/material.dart';
-import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
-import '../services/cloud/cloud_storage_provider.dart';
-import 'cloud_folder_browser_screen.dart';
+import 'data_settings_screen.dart';
+import 'ui_settings_screen.dart';
-class SettingsScreen extends StatefulWidget {
+/// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen]
+/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
+/// advanced/purge) — plus a read-only summary of what's stored locally.
+class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
- @override
- State createState() => _SettingsScreenState();
-}
-
-class _SettingsScreenState extends State {
- bool _busy = false;
- String? _error;
-
- Future _connect(CloudProviderId id) async {
- setState(() {
- _busy = true;
- _error = null;
- });
- try {
- await context.read().connectProvider(id);
- } catch (e) {
- setState(() => _error = 'Could not sign in: $e');
- } finally {
- if (mounted) setState(() => _busy = false);
- }
- }
-
- Future _connectManual(CloudProviderId id, String providerName) async {
- final credentials = await showDialog<_WebDavCredentials>(
- context: context,
- builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
- );
- if (credentials == null || !mounted) return;
-
- setState(() {
- _busy = true;
- _error = null;
- });
- try {
- await context.read().connectProviderWithCredentials(
- id,
- serverUrl: credentials.serverUrl,
- username: credentials.username,
- password: credentials.password,
- );
- } catch (e) {
- setState(() => _error = 'Could not connect: $e');
- } finally {
- if (mounted) setState(() => _busy = false);
- }
- }
-
- Future _disconnect() async {
- setState(() => _busy = true);
- try {
- await context.read().disconnectCloud();
- } finally {
- if (mounted) setState(() => _busy = false);
- }
- }
-
- void _chooseFolder() {
- Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
- );
- }
-
- Future _syncNow() async {
- setState(() => _busy = true);
- try {
- await context.read().syncNow();
- } finally {
- if (mounted) setState(() => _busy = false);
- }
- }
-
@override
Widget build(BuildContext context) {
final appState = context.watch();
@@ -90,153 +21,26 @@ class _SettingsScreenState extends State {
padding: const EdgeInsets.all(16),
children: [
Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
- const SizedBox(height: 8),
- if (!appState.isCloudConnected) ...[
- Text(
- 'Connect a cloud storage account to share vehicles and fuel '
- 'receipts with other people, and to keep a backup off this device.',
- style: Theme.of(context).textTheme.bodyMedium,
- ),
- const SizedBox(height: 12),
- if (_error != null) ...[
- Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
- const SizedBox(height: 8),
- ],
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- for (final provider in appState.availableProviders)
- FilledButton.icon(
- onPressed: _busy
- ? null
- : () => provider is ManualCredentialCloudStorageProvider
- ? _connectManual(provider.id, provider.displayName)
- : _connect(provider.id),
- icon: const Icon(Icons.login),
- label: Text('Connect ${provider.displayName}'),
- ),
- ],
- ),
- ] else ...[
- Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
- const SizedBox(height: 4),
- Text(
- appState.cloudFolderPath == null
- ? 'No folder selected yet.'
- : 'Folder: ${appState.cloudFolderPath}',
- style: Theme.of(context).textTheme.bodySmall,
- ),
- const SizedBox(height: 12),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- OutlinedButton.icon(
- onPressed: _busy ? null : _chooseFolder,
- icon: const Icon(Icons.folder_open),
- label: Text(appState.cloudFolderPath == null
- ? 'Choose Folder'
- : 'Change Folder'),
- ),
- OutlinedButton.icon(
- onPressed: (_busy || appState.cloudFolderPath == null)
- ? null
- : _syncNow,
- icon: appState.isSyncing
- ? const SizedBox(
- height: 16,
- width: 16,
- child: CircularProgressIndicator(strokeWidth: 2))
- : const Icon(Icons.sync),
- label: const Text('Sync Now'),
- ),
- TextButton.icon(
- onPressed: _busy ? null : _disconnect,
- icon: const Icon(Icons.logout),
- label: const Text('Disconnect'),
- ),
- ],
- ),
- const SizedBox(height: 8),
- if (appState.lastSyncedAt != null)
- Text(
- 'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
- style: Theme.of(context).textTheme.bodySmall,
- ),
- if (appState.lastSyncError != null)
- Padding(
- padding: const EdgeInsets.only(top: 4),
- child: Text(
- 'Last sync failed: ${appState.lastSyncError}',
- style: TextStyle(color: Theme.of(context).colorScheme.error),
- ),
- ),
- ],
- ],
+ child: ListTile(
+ leading: const Icon(Icons.palette_outlined),
+ title: const Text('UI'),
+ subtitle: const Text('Appearance'),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const UiSettingsScreen()),
),
),
),
const SizedBox(height: 16),
Card(
- child: SwitchListTile(
- title: const Text('Keep photos on this phone after syncing'),
- subtitle: const Text(
- 'Otherwise, a receipt photo is removed from this device once '
- "it's safely uploaded to the cloud.",
+ child: ListTile(
+ leading: const Icon(Icons.storage_outlined),
+ title: const Text('Data'),
+ subtitle: const Text('Cloud storage, photo syncing, advanced'),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
),
- value: appState.keepReceiptPhotosLocally,
- onChanged: (value) => context.read().setKeepReceiptPhotosLocally(value),
- ),
- ),
- const SizedBox(height: 16),
- Card(
- child: SwitchListTile(
- title: const Text('Keep max quality images'),
- subtitle: const Text(
- 'Otherwise, receipt photos are downscaled to a reasonable size '
- 'before storing — smaller cloud storage and faster syncs, with '
- 'no loss of legibility for a printed receipt.',
- ),
- value: appState.keepMaxQualityReceiptPhotos,
- onChanged: (value) =>
- context.read().setKeepMaxQualityReceiptPhotos(value),
- ),
- ),
- const SizedBox(height: 16),
- Card(
- child: ExpansionTile(
- title: const Text('Advanced'),
- childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
- children: [
- Align(
- alignment: Alignment.centerLeft,
- child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
- ),
- const SizedBox(height: 4),
- Text(
- "If another device disconnects mid-sync without releasing its lock, "
- "this is how long to wait before treating it as abandoned and clearing "
- "it so sync can continue.",
- style: Theme.of(context).textTheme.bodySmall,
- ),
- Slider(
- value: appState.staleLockMinutes.toDouble(),
- min: minStaleLockMinutes.toDouble(),
- max: maxStaleLockMinutes.toDouble(),
- divisions: maxStaleLockMinutes - minStaleLockMinutes,
- label: '${appState.staleLockMinutes} min',
- onChanged: (value) =>
- context.read().setStaleLockMinutes(value.round()),
- ),
- Text('${appState.staleLockMinutes} minute(s)'),
- ],
),
),
const SizedBox(height: 16),
@@ -260,93 +64,3 @@ class _SettingsScreenState extends State {
);
}
}
-
-class _WebDavCredentials {
- final String serverUrl;
- final String username;
- final String password;
- _WebDavCredentials({required this.serverUrl, required this.username, required this.password});
-}
-
-/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
-/// needs, since (unlike the OAuth providers) there's no browser flow to
-/// gather these instead.
-class _WebDavCredentialsDialog extends StatefulWidget {
- final String providerName;
- const _WebDavCredentialsDialog({required this.providerName});
-
- @override
- State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
-}
-
-class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
- final _formKey = GlobalKey();
- final _serverController = TextEditingController();
- final _usernameController = TextEditingController();
- final _passwordController = TextEditingController();
-
- @override
- void dispose() {
- _serverController.dispose();
- _usernameController.dispose();
- _passwordController.dispose();
- super.dispose();
- }
-
- void _submit() {
- if (!_formKey.currentState!.validate()) return;
- Navigator.of(context).pop(_WebDavCredentials(
- serverUrl: _serverController.text.trim(),
- username: _usernameController.text.trim(),
- password: _passwordController.text,
- ));
- }
-
- @override
- Widget build(BuildContext context) {
- return AlertDialog(
- title: Text('Connect ${widget.providerName}'),
- content: Form(
- key: _formKey,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- TextFormField(
- controller: _serverController,
- decoration: const InputDecoration(
- labelText: 'Server URL',
- hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
- ),
- keyboardType: TextInputType.url,
- validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
- ),
- const SizedBox(height: 12),
- TextFormField(
- controller: _usernameController,
- decoration: const InputDecoration(labelText: 'Username'),
- validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
- ),
- const SizedBox(height: 12),
- TextFormField(
- controller: _passwordController,
- decoration: const InputDecoration(labelText: 'Password'),
- obscureText: true,
- validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
- onFieldSubmitted: (_) => _submit(),
- ),
- ],
- ),
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(context).pop(),
- child: const Text('Cancel'),
- ),
- FilledButton(
- onPressed: _submit,
- child: const Text('Connect'),
- ),
- ],
- );
- }
-}
diff --git a/lib/screens/ui_settings_screen.dart b/lib/screens/ui_settings_screen.dart
new file mode 100644
index 0000000..e183c25
--- /dev/null
+++ b/lib/screens/ui_settings_screen.dart
@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../services/app_state.dart';
+
+/// "UI" settings submenu, reached from [SettingsScreen] — currently just
+/// Appearance (theme), but its own screen so more display-related settings
+/// have somewhere to go without crowding the Settings landing page.
+class UiSettingsScreen extends StatelessWidget {
+ const UiSettingsScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final appState = context.watch();
+
+ return Scaffold(
+ appBar: AppBar(title: const Text('UI')),
+ body: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Appearance', style: Theme.of(context).textTheme.titleMedium),
+ const SizedBox(height: 12),
+ SegmentedButton(
+ segments: const [
+ ButtonSegment(value: ThemeMode.system, label: Text('System')),
+ ButtonSegment(value: ThemeMode.light, label: Text('Light')),
+ ButtonSegment(value: ThemeMode.dark, label: Text('Dark')),
+ ],
+ selected: {appState.themeMode},
+ onSelectionChanged: (selection) =>
+ context.read().setThemeMode(selection.first),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/screens/vehicle_detail_screen.dart b/lib/screens/vehicle_detail_screen.dart
index 1b2f0d1..90ae960 100644
--- a/lib/screens/vehicle_detail_screen.dart
+++ b/lib/screens/vehicle_detail_screen.dart
@@ -1,174 +1,66 @@
-import 'dart:io';
-
import 'package:flutter/material.dart';
-import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
-import '../models/fuel_entry.dart';
import '../services/app_state.dart';
-import '../services/ocr_service.dart';
-import '../services/receipt_parser.dart';
-import '../widgets/image_source_sheet.dart';
+import '../widgets/hero_banner.dart';
+import '../widgets/receipt_capture.dart';
+import '../widgets/receipt_thumbnail.dart';
import 'add_edit_vehicle_screen.dart';
-import 'confirm_fuel_entry_screen.dart';
-import 'receipt_image_screen.dart';
+import 'receipt_detail_screen.dart';
-/// Long edge and JPEG quality a receipt photo is downscaled to unless
-/// "keep max quality" is on — a receipt is a photo of small printed text,
-/// not something that benefits from a multi-megapixel original, and this
-/// keeps typical files in the low hundreds of KB instead of several MB.
-const receiptImageMaxDimension = 1600.0;
-const receiptImageQuality = 70;
-
-class VehicleDetailScreen extends StatelessWidget {
+/// A swipeable [PageView] over every vehicle (in the same order as the
+/// Vehicles tab's list), starting on [vehicleId] — sliding left/right moves
+/// to the next/previous vehicle, the same "modern navigation" swipe
+/// [MainShell] uses for the bottom-nav tabs. Each page is a full,
+/// independent [_VehicleDetailPage] (own AppBar/FAB), so this widget only
+/// owns the [PageController].
+class VehicleDetailScreen extends StatefulWidget {
final String vehicleId;
const VehicleDetailScreen({super.key, required this.vehicleId});
- Future _captureReceipt(BuildContext context) async {
- final source = await chooseImageSource(context);
- if (source == null || !context.mounted) return;
+ @override
+ State createState() => _VehicleDetailScreenState();
+}
- final keepMaxQuality = context.read().keepMaxQualityReceiptPhotos;
- final picker = ImagePicker();
- XFile? photo;
- try {
- photo = await picker.pickImage(
- source: source,
- imageQuality: keepMaxQuality ? null : receiptImageQuality,
- maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
- maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
- );
- } catch (e) {
- if (context.mounted) {
- final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Could not open $sourceLabel: $e')),
- );
- }
- return;
+class _VehicleDetailScreenState extends State {
+ late final PageController _pageController;
+
+ @override
+ void initState() {
+ super.initState();
+ final vehicles = context.read().vehicles;
+ final initialIndex = vehicles.indexWhere((v) => v.id == widget.vehicleId);
+ _pageController = PageController(initialPage: initialIndex < 0 ? 0 : initialIndex);
+ }
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final vehicles = context.watch().vehicles;
+
+ if (vehicles.isEmpty) {
+ return const Scaffold(body: Center(child: Text('Vehicle not found')));
}
- if (photo == null) return;
- final imageFile = File(photo.path);
-
- if (!context.mounted) return;
- showDialog(
- context: context,
- barrierDismissible: false,
- builder: (_) => const Center(child: CircularProgressIndicator()),
+ return PageView.builder(
+ controller: _pageController,
+ itemCount: vehicles.length,
+ itemBuilder: (context, index) => _VehicleDetailPage(vehicleId: vehicles[index].id),
);
-
- final ocrService = OcrService();
- String recognizedText = '';
- try {
- recognizedText = await ocrService.recognizeText(imageFile);
- } catch (_) {
- recognizedText = '';
- } finally {
- ocrService.dispose();
- }
-
- final parsed = ReceiptParser.parse(recognizedText);
-
- if (!context.mounted) return;
- Navigator.of(context).pop(); // close the OCR loading spinner
-
- final state = parsed.state;
- if (state != null && state != 'MO') {
- final proceed = await _confirmNonMissouriPurchase(context, state);
- if (!proceed || !context.mounted) return;
- }
-
- if (context.mounted) {
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => ConfirmFuelEntryScreen(
- vehicleId: vehicleId,
- imageFile: imageFile,
- parsed: parsed,
- ),
- ),
- );
- }
}
+}
- /// Missouri's fuel tax refund only applies to fuel bought in Missouri —
- /// warn if the receipt's address is somewhere else, and let the user
- /// decide whether to log it anyway (e.g. it might still be worth
- /// tracking for other reasons even if it won't qualify for a refund).
- Future _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
- final stateName = usStateNames[stateCode] ?? stateCode;
- final proceed = await showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Non-Missouri Purchase'),
- content: Text(
- 'This receipt looks like it\'s from a gas station in $stateName. '
- 'The Missouri fuel tax refund only applies to fuel purchased in '
- 'Missouri.\n\nDo you still want to add this entry?',
- ),
- actions: [
- TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
- FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
- ],
- ),
- );
- return proceed ?? false;
- }
+class _VehicleDetailPage extends StatelessWidget {
+ final String vehicleId;
- Future _deleteEntry(BuildContext context, FuelEntry entry) async {
- final confirmed = await showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Delete fuel entry?'),
- content: const Text('This will remove the entry and its receipt photo.'),
- actions: [
- TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
- TextButton(
- onPressed: () => Navigator.of(context).pop(true),
- child: const Text('Delete', style: TextStyle(color: Colors.red)),
- ),
- ],
- ),
- );
- if (confirmed == true && context.mounted) {
- await context.read().deleteFuelEntry(entry.id);
- }
- }
-
- Widget _buildReceiptLeading(BuildContext context, FuelEntry entry) {
- if (entry.receiptImagePath != null) {
- return GestureDetector(
- onTap: () => Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath),
- ),
- ),
- child: ClipRRect(
- borderRadius: BorderRadius.circular(6),
- child: Image.file(
- File(entry.receiptImagePath!),
- width: 48,
- height: 48,
- fit: BoxFit.cover,
- ),
- ),
- );
- }
- if (entry.isReceiptUploadedToDrive) {
- return GestureDetector(
- onTap: () => Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId),
- ),
- ),
- child: const CircleAvatar(child: Icon(Icons.cloud_outlined)),
- );
- }
- return const CircleAvatar(child: Icon(Icons.receipt_long));
- }
+ const _VehicleDetailPage({required this.vehicleId});
@override
Widget build(BuildContext context) {
@@ -181,8 +73,12 @@ class VehicleDetailScreen extends StatelessWidget {
final entries = appState.entriesForVehicle(vehicleId);
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
+ final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
final dateFormat = DateFormat.yMMMd().add_jm();
final currencyFormat = NumberFormat.simpleCurrency();
+ // The hero stat is a big headline number, not a precise figure — round
+ // to whole gallons there, matching the Receipts tab's hero banner.
+ final heroGallonsFormat = NumberFormat('#,##0');
return Scaffold(
appBar: AppBar(
@@ -199,42 +95,74 @@ class VehicleDetailScreen extends StatelessWidget {
),
body: Column(
children: [
- Container(
- width: double.infinity,
- padding: const EdgeInsets.all(16),
- color: Theme.of(context).colorScheme.surfaceContainerHighest,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('Total gallons logged', style: Theme.of(context).textTheme.labelLarge),
- Text(
- '${totalGallons.toStringAsFixed(3)} gal',
- style: Theme.of(context).textTheme.headlineMedium,
- ),
- ],
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
+ child: HeroBanner(
+ child: HeroStatsRow(
+ gallonsValue: heroGallonsFormat.format(totalGallons),
+ costValue: currencyFormat.format(totalCost),
+ ),
),
),
Expanded(
child: entries.isEmpty
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
: ListView.separated(
+ // Keyed by vehicle so each vehicle's scroll position is
+ // remembered independently as the user swipes between
+ // pages — PageView.builder can dispose an off-screen
+ // page's element, which would otherwise reset it.
+ key: PageStorageKey('vehicle-entries-$vehicleId'),
padding: const EdgeInsets.all(12),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final entry = entries[index];
+ void openDetail() => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ReceiptDetailScreen(entryId: entry.id),
+ ),
+ );
return Card(
clipBehavior: Clip.antiAlias,
- child: ListTile(
- leading: _buildReceiptLeading(context, entry),
- title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
- subtitle: Text(
- '${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
- ),
- isThreeLine: true,
- trailing: IconButton(
- icon: const Icon(Icons.delete_outline),
- onPressed: () => _deleteEntry(context, entry),
+ child: InkWell(
+ onTap: openDetail,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ // Row cross-axis defaults to center, so the
+ // thumbnail sits vertically centered against the
+ // title+subtitle block even though it's three
+ // lines tall — ListTile's isThreeLine would
+ // instead force leading/trailing to the top, per
+ // Material spec (the same issue the vehicle list
+ // cards had).
+ child: Row(
+ children: [
+ ReceiptThumbnail(entry: entry, onTap: openDetail),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ '${entry.gallons.toStringAsFixed(3)} gal • '
+ '${currencyFormat.format(entry.totalCost)}',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ Text(
+ '${currencyFormat.format(entry.pricePerGallon)}/gal\n'
+ '${dateFormat.format(entry.date)}',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const Icon(Icons.chevron_right),
+ ],
+ ),
),
),
);
@@ -244,7 +172,7 @@ class VehicleDetailScreen extends StatelessWidget {
],
),
floatingActionButton: FloatingActionButton.extended(
- onPressed: () => _captureReceipt(context),
+ onPressed: () => captureReceiptForVehicle(context, vehicleId),
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('Log Fuel Receipt'),
),
diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart
index f0d3bd9..c318276 100644
--- a/lib/services/app_state.dart
+++ b/lib/services/app_state.dart
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart' show ThemeMode;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
@@ -22,6 +23,7 @@ const _prefsKeyCloudFolderPath = 'cloud_folder_path';
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
+const _prefsKeyThemeMode = 'theme_mode';
const defaultStaleLockMinutes = 10;
const minStaleLockMinutes = 1;
@@ -85,6 +87,8 @@ class AppState extends ChangeNotifier {
/// the original camera/gallery image is kept as-is.
bool keepMaxQualityReceiptPhotos = false;
+ ThemeMode themeMode = ThemeMode.system;
+
/// Set if [init] fails. The UI shows this (with a retry option) instead
/// of spinning forever — an unhandled exception here previously left
/// `isLoading` stuck at true with no feedback at all.
@@ -109,6 +113,10 @@ class AppState extends ChangeNotifier {
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
keepMaxQualityReceiptPhotos =
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
+ themeMode = ThemeMode.values.firstWhere(
+ (mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
+ orElse: () => ThemeMode.system,
+ );
} catch (e) {
initError = e;
isLoading = false;
@@ -289,6 +297,13 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
+ Future setThemeMode(ThemeMode mode) async {
+ themeMode = mode;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_prefsKeyThemeMode, mode.name);
+ notifyListeners();
+ }
+
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
/// Settings "Advanced" section, which restricts the picker to that range
/// anyway; this is a defensive backstop for any other caller.
@@ -373,6 +388,34 @@ class AppState extends ChangeNotifier {
return entry;
}
+ /// Corrects the logged values (date, gallons, price/gal, total cost) for
+ /// an existing fuel entry — the receipt photo itself isn't editable here,
+ /// only the data recorded about it (e.g. fixing a misread OCR value).
+ /// [entryId]'s receipt photo reference (local path and/or cloud file id)
+ /// carries over untouched.
+ Future updateFuelEntry({
+ required String entryId,
+ required DateTime date,
+ required double gallons,
+ required double pricePerGallon,
+ required double totalCost,
+ }) async {
+ final existing = fuelEntries.firstWhere((e) => e.id == entryId);
+ final updated = FuelEntry(
+ id: existing.id,
+ vehicleId: existing.vehicleId,
+ date: date,
+ gallons: gallons,
+ pricePerGallon: pricePerGallon,
+ totalCost: totalCost,
+ receiptImagePath: existing.receiptImagePath,
+ receiptDriveFileId: existing.receiptDriveFileId,
+ updatedAt: DateTime.now().toUtc(),
+ );
+ await database.saveFuelEntry(updated);
+ await _persist();
+ }
+
Future deleteFuelEntry(String entryId) async {
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
await database.deleteReceiptImageFile(entry.receiptImagePath);
@@ -380,6 +423,30 @@ class AppState extends ChangeNotifier {
await _persist();
}
+ /// Wipes every vehicle, fuel entry, and local receipt photo — "starting
+ /// completely fresh". Uses the same soft-delete tombstones as any other
+ /// delete rather than a hard SQL `DELETE`, so if a cloud sync is
+ /// connected, the deletion pushes on next sync instead of the old data
+ /// just getting silently re-imported from the remote copy. This is purely
+ /// local + whatever propagates through sync — it never reaches into the
+ /// cloud folder directly to delete anything sitting there.
+ Future purgeAllData() async {
+ await database.purgeAllData(DateTime.now().toUtc());
+ await _persist();
+ }
+
+ /// Wipes every fuel entry (and its local receipt photo) dated within
+ /// [start]–[end] inclusive, across all vehicles. Vehicles themselves are
+ /// left alone. Same soft-delete/sync-propagation reasoning as
+ /// [purgeAllData].
+ Future purgeFuelEntriesInRange(DateTime start, DateTime end) async {
+ final localPaths = await database.purgeFuelEntriesInRange(start, end, DateTime.now().toUtc());
+ for (final path in localPaths) {
+ await database.deleteReceiptImageFile(path);
+ }
+ await _persist();
+ }
+
List entriesForVehicle(String vehicleId) {
final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList();
list.sort((a, b) => b.date.compareTo(a.date));
@@ -401,6 +468,14 @@ class AppState extends ChangeNotifier {
}
}
+ FuelEntry? fuelEntryById(String id) {
+ try {
+ return fuelEntries.firstWhere((e) => e.id == id);
+ } catch (_) {
+ return null;
+ }
+ }
+
/// Exact match against an active vehicle's VIN — same comparison
/// [DatabaseService.vinExists] does, not case-insensitive, since VIN
/// isn't normalized to any particular case on manual entry (only OCR
diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart
index cbf1716..dfcac2c 100644
--- a/lib/services/database_service.dart
+++ b/lib/services/database_service.dart
@@ -267,6 +267,67 @@ class DatabaseService {
return copied.path;
}
+ /// Soft-deletes every active vehicle and fuel entry — as if the user had
+ /// deleted each one individually (see [softDeleteVehicle]/
+ /// [softDeleteFuelEntry] for why these aren't real `DELETE`s: the
+ /// tombstones are what let the deletion propagate to other synced devices
+ /// on the next sync, rather than the rows just being silently re-imported
+ /// from the cloud copy). Also empties the local receipts folder outright
+ /// rather than deleting file-by-file, to catch any orphaned file a
+ /// tracked path might have missed.
+ Future purgeAllData(DateTime deletedAt) async {
+ final ts = deletedAt.millisecondsSinceEpoch;
+ await _db.update(
+ 'fuel_entries',
+ {'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
+ where: 'deleted_at IS NULL',
+ );
+ await _db.update(
+ 'vehicles',
+ {'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
+ where: 'deleted_at IS NULL',
+ );
+
+ if (await receiptsDirectory.exists()) {
+ await receiptsDirectory.delete(recursive: true);
+ }
+ await receiptsDirectory.create(recursive: true);
+ }
+
+ /// Soft-deletes every active fuel entry (across all vehicles) whose
+ /// [FuelEntry.date] falls within [start]–[end] inclusive, and returns
+ /// their local receipt image paths so the caller can clean those files up
+ /// too. Vehicles are left untouched — a date range only makes sense
+ /// against the fuel entries logged against them, not the vehicles
+ /// themselves.
+ Future> purgeFuelEntriesInRange(
+ DateTime start,
+ DateTime end,
+ DateTime deletedAt,
+ ) async {
+ final rangeStart = DateTime(start.year, start.month, start.day).millisecondsSinceEpoch;
+ final rangeEndExclusive =
+ DateTime(end.year, end.month, end.day).add(const Duration(days: 1)).millisecondsSinceEpoch;
+ final ts = deletedAt.millisecondsSinceEpoch;
+
+ final rows = await _db.query(
+ 'fuel_entries',
+ where: 'deleted_at IS NULL AND date >= ? AND date < ?',
+ whereArgs: [rangeStart, rangeEndExclusive],
+ );
+ final localPaths =
+ rows.map((r) => r['receipt_image_path'] as String?).whereType().toList();
+
+ await _db.update(
+ 'fuel_entries',
+ {'deleted_at': ts, 'updated_at': ts, 'dirty': 1},
+ where: 'deleted_at IS NULL AND date >= ? AND date < ?',
+ whereArgs: [rangeStart, rangeEndExclusive],
+ );
+
+ return localPaths;
+ }
+
Future deleteReceiptImageFile(String? path) async {
if (path == null) return;
final file = File(path);
diff --git a/lib/services/fuel_report.dart b/lib/services/fuel_report.dart
index 4691b1d..ea4342d 100644
--- a/lib/services/fuel_report.dart
+++ b/lib/services/fuel_report.dart
@@ -1,19 +1,55 @@
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
-/// One vehicle's totals within a [FuelReport]'s date range.
+/// One vehicle's totals — and, for the line-items/receipt-images report
+/// sections, its individual entries — within a [FuelReport]'s date range.
+/// [entries] is sorted oldest-first, the natural order for a ledger of
+/// line items building up to a total.
class VehicleReportRow {
final Vehicle vehicle;
- final int entryCount;
+ final List entries;
final double totalGallons;
final double totalCost;
VehicleReportRow({
required this.vehicle,
- required this.entryCount,
+ required this.entries,
required this.totalGallons,
required this.totalCost,
});
+
+ int get entryCount => entries.length;
+}
+
+/// One entry paired with the vehicle it belongs to — [FuelEntry] only knows
+/// [FuelEntry.vehicleId], not the [Vehicle] itself, so anything that needs
+/// to show both together (like [FuelReport.allEntries], for a combined
+/// cross-vehicle list) needs this pairing.
+class ReportEntry {
+ final FuelEntry entry;
+ final Vehicle vehicle;
+
+ ReportEntry({required this.entry, required this.vehicle});
+}
+
+/// Every qualifying entry in a single calendar month, across every vehicle
+/// in the report — the "Gallons by month" breakdown is this, one per
+/// month present in [FuelReport.monthlyTotals].
+class MonthlyTotal {
+ /// The first of the month, e.g. `DateTime(2026, 8)` for August 2026.
+ final DateTime month;
+ final int fillCount;
+ final double totalGallons;
+ final double totalCost;
+
+ MonthlyTotal({
+ required this.month,
+ required this.fillCount,
+ required this.totalGallons,
+ required this.totalCost,
+ });
+
+ double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
}
/// Per-vehicle fuel totals for entries with a receipt, dated within
@@ -27,6 +63,47 @@ class FuelReport {
double get totalGallons => rows.fold(0.0, (sum, r) => sum + r.totalGallons);
double get totalCost => rows.fold(0.0, (sum, r) => sum + r.totalCost);
+ int get fillCount => rows.fold(0, (sum, r) => sum + r.entryCount);
+ double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
+
+ /// Every entry across every vehicle, oldest first — the natural order for
+ /// a combined "purchase detail" ledger that isn't grouped by vehicle.
+ List get allEntries {
+ final list = [
+ for (final row in rows)
+ for (final entry in row.entries) ReportEntry(entry: entry, vehicle: row.vehicle),
+ ];
+ list.sort((a, b) => a.entry.date.compareTo(b.entry.date));
+ return list;
+ }
+
+ /// [allEntries] grouped by calendar month, in chronological order — the
+ /// "Purchase detail" section's grouping, and the source data for
+ /// [monthlyTotals].
+ Map> get entriesByMonth {
+ final map = >{};
+ for (final reportEntry in allEntries) {
+ final month = DateTime(reportEntry.entry.date.year, reportEntry.entry.date.month);
+ (map[month] ??= []).add(reportEntry);
+ }
+ return map;
+ }
+
+ /// One [MonthlyTotal] per calendar month with qualifying entries, oldest
+ /// first — the "Gallons by month" chart/table's data.
+ List get monthlyTotals {
+ final byMonth = entriesByMonth;
+ final months = byMonth.keys.toList()..sort();
+ return [
+ for (final month in months)
+ MonthlyTotal(
+ month: month,
+ fillCount: byMonth[month]!.length,
+ totalGallons: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.gallons),
+ totalCost: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.totalCost),
+ ),
+ ];
+ }
}
/// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive,
@@ -68,11 +145,12 @@ FuelReport buildFuelReport({
// No matching (active) vehicle — e.g. deleted since — nothing sane to
// attribute these entries to.
if (vehicle == null) continue;
+ final sortedEntries = entry.value.toList()..sort((a, b) => a.date.compareTo(b.date));
rows.add(VehicleReportRow(
vehicle: vehicle,
- entryCount: entry.value.length,
- totalGallons: entry.value.fold(0.0, (sum, e) => sum + e.gallons),
- totalCost: entry.value.fold(0.0, (sum, e) => sum + e.totalCost),
+ entries: sortedEntries,
+ totalGallons: sortedEntries.fold(0.0, (sum, e) => sum + e.gallons),
+ totalCost: sortedEntries.fold(0.0, (sum, e) => sum + e.totalCost),
));
}
rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel));
diff --git a/lib/services/fuel_report_images.dart b/lib/services/fuel_report_images.dart
new file mode 100644
index 0000000..64186d3
--- /dev/null
+++ b/lib/services/fuel_report_images.dart
@@ -0,0 +1,70 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import '../models/fuel_entry.dart';
+import 'cloud/cloud_storage_provider.dart';
+import 'fuel_report.dart';
+
+/// Result of [loadReceiptImageBytes]: the photo bytes that were resolved,
+/// plus which entries couldn't be — e.g. because the receipt was uploaded
+/// through a *different* cloud provider than the one currently connected
+/// (its stored reference is meaningless to the active provider's API), or
+/// the entry is offline/the remote file's since been deleted. Surfacing
+/// [failedEntries] instead of only ever silently dropping them is what
+/// makes that distinguishable from "there was never a receipt at all".
+class ReceiptImageLoadResult {
+ final Map bytesByEntryId;
+ final List failedEntries;
+
+ ReceiptImageLoadResult({required this.bytesByEntryId, required this.failedEntries});
+}
+
+/// Reads (or downloads) the receipt photo for every entry in [report].
+///
+/// Prefers the local copy when one's still on-device; falls back to
+/// downloading via [activeProvider] for entries that have already been
+/// uploaded and cleared locally. An entry that fails to download (offline,
+/// revoked cloud access, since-deleted remote file, a reference belonging
+/// to a different provider than the one currently connected, ...) is left
+/// out of [ReceiptImageLoadResult.bytesByEntryId] and reported in
+/// [ReceiptImageLoadResult.failedEntries] instead — one missing receipt
+/// photo shouldn't block the rest of the document, but it also shouldn't
+/// vanish without a trace.
+Future loadReceiptImageBytes({
+ required FuelReport report,
+ required CloudStorageProvider? activeProvider,
+}) async {
+ final bytesByEntryId = {};
+ final failedEntries = [];
+ CloudStorageSession? session;
+ try {
+ for (final row in report.rows) {
+ for (final entry in row.entries) {
+ final localPath = entry.receiptImagePath;
+ if (localPath != null) {
+ final file = File(localPath);
+ if (await file.exists()) {
+ bytesByEntryId[entry.id] = Uint8List.fromList(await file.readAsBytes());
+ continue;
+ }
+ }
+
+ final driveFileId = entry.receiptDriveFileId;
+ if (driveFileId == null || activeProvider == null || !activeProvider.isSignedIn) {
+ failedEntries.add(entry);
+ continue;
+ }
+ try {
+ session ??= activeProvider.beginSession();
+ final bytes = await session.downloadFileBytes(driveFileId);
+ bytesByEntryId[entry.id] = Uint8List.fromList(bytes);
+ } catch (_) {
+ failedEntries.add(entry);
+ }
+ }
+ }
+ } finally {
+ session?.close();
+ }
+ return ReceiptImageLoadResult(bytesByEntryId: bytesByEntryId, failedEntries: failedEntries);
+}
diff --git a/lib/services/fuel_report_pdf.dart b/lib/services/fuel_report_pdf.dart
index 2490c00..bbdc65e 100644
--- a/lib/services/fuel_report_pdf.dart
+++ b/lib/services/fuel_report_pdf.dart
@@ -7,77 +7,596 @@ import 'package:pdf/widgets.dart' as pw;
import 'fuel_report.dart';
final _dateFormat = DateFormat.yMMMd();
+final _dateTimeFormat = DateFormat.yMMMd().add_jm();
+final _monthFormat = DateFormat('MMM');
+final _monthHeadingFormat = DateFormat.yMMMM();
+final _dayMonthFormat = DateFormat('d MMM');
+final _generatedFormat = DateFormat('d MMM yyyy');
final _currencyFormat = NumberFormat.simpleCurrency();
-/// Renders [report] as a single-page-friendly PDF: a per-vehicle table
-/// (VIN, nickname, entry count, gallons, total cost) plus a grand-total
-/// row, suitable for attaching to a fuel tax refund submission or handing
-/// to a printer.
-Future buildFuelReportPdf(FuelReport report) async {
+// Palette lifted from the report design mockup — kept in one place so
+// every section (banner, cards, tables, watermark) draws from the same
+// set of colors rather than each picking its own.
+final _bannerBackground = PdfColor.fromHex('#EAF3DE');
+final _bannerText = PdfColor.fromHex('#27500A');
+final _textPrimary = PdfColor.fromHex('#1F1E1C');
+final _textMuted = PdfColor.fromHex('#6B6A64');
+final _textFaint = PdfColor.fromHex('#94938C');
+final _borderLight = PdfColor.fromHex('#E5E3DB');
+final _borderStrong = PdfColor.fromHex('#CFCDC4');
+final _accentGreen = PdfColor.fromHex('#639922');
+final _accentGreenDark = PdfColor.fromHex('#3B6D11');
+final _cardBackground = PdfColor.fromHex('#F7F6F1');
+
+final _tableBorder = pw.TableBorder.all(color: _borderLight, width: 0.5);
+final _tableHeaderDecoration = pw.BoxDecoration(color: PdfColor.fromHex('#EFEEE7'));
+final _tableEvenRowDecoration = const pw.BoxDecoration(color: PdfColors.white);
+final _tableOddRowDecoration = pw.BoxDecoration(color: _cardBackground);
+final _sectionHeadingStyle = pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary);
+
+/// Renders [report] as a PDF, styled after the app's report design mockup:
+/// a branded letterhead banner, a headline total, three at-a-glance stat
+/// cards, a "gallons by month" bar chart + table, and a by-vehicle
+/// breakdown. When requested, a "Line Items" section (a table per vehicle)
+/// and/or a "Receipt Images" section (photos per vehicle, sized to
+/// actually be legible — not a small inline thumbnail) follow, each
+/// grouped by vehicle and sorted oldest-first within it. When both are on,
+/// each line item and its receipt photo are tagged with the same number
+/// (assigned once, continuing across vehicles) so the two can be matched
+/// up by eye despite living in separate sections.
+///
+/// [receiptImages] supplies the actual photo bytes, keyed by
+/// [FuelEntry.id] — see `loadReceiptImageBytes`; an entry with no entry in
+/// that map (couldn't be read locally or downloaded) is left out of the
+/// images section rather than shown as a broken image.
+Future buildFuelReportPdf(
+ FuelReport report, {
+ bool includeLineItems = false,
+ bool includeReceiptImages = false,
+ Map receiptImages = const {},
+}) async {
final doc = pw.Document();
+ final generatedAt = DateTime.now();
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.letter,
- header: (context) => pw.Column(
- crossAxisAlignment: pw.CrossAxisAlignment.start,
- children: [
- pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
- pw.Text(
- '${_dateFormat.format(report.startDate)} – ${_dateFormat.format(report.endDate)}',
- style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
- ),
- pw.SizedBox(height: 16),
- ],
+ margin: pw.EdgeInsets.zero,
+ header: (context) => _buildBanner(generatedAt),
+ footer: (context) => _buildFooter(report, context),
+ build: (context) => _buildBody(
+ report,
+ includeLineItems: includeLineItems,
+ includeReceiptImages: includeReceiptImages,
+ receiptImages: receiptImages,
),
- build: (context) => [
- if (report.rows.isEmpty)
- pw.Text('No vehicles had a receipted fuel entry in this date range.')
- else
- pw.TableHelper.fromTextArray(
- headers: ['Vehicle', 'VIN', 'Entries', 'Gallons', 'Total Cost'],
- data: [
- for (final row in report.rows)
- [
- row.vehicle.nickname?.trim().isNotEmpty == true ? row.vehicle.nickname! : '—',
- row.vehicle.vin,
- row.entryCount.toString(),
- row.totalGallons.toStringAsFixed(3),
- _currencyFormat.format(row.totalCost),
- ],
- ],
- cellAlignments: const {
- 2: pw.Alignment.centerRight,
- 3: pw.Alignment.centerRight,
- 4: pw.Alignment.centerRight,
- },
- headerAlignments: const {
- 2: pw.Alignment.centerRight,
- 3: pw.Alignment.centerRight,
- 4: pw.Alignment.centerRight,
- },
- headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
- cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
- ),
- pw.SizedBox(height: 12),
- pw.Divider(),
- pw.Row(
- mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
- children: [
- pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
- pw.Text(
- '${report.totalGallons.toStringAsFixed(3)} gal · ${_currencyFormat.format(report.totalCost)}',
- style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
- ),
- ],
- ),
- ],
),
);
return doc.save();
}
+/// A *flat* list of top-level widgets — deliberately not one big [pw.Column]
+/// wrapping everything. [pw.MultiPage] paginates by walking its `build:`
+/// list one top-level item at a time, resetting its "stuck" bookkeeping
+/// only once each one fully finishes; a [pw.Table]/[pw.Wrap] (both of which
+/// span pages on their own, e.g. a long line-items table or a big receipt
+/// photo grid) nested *inside* one enclosing spanning [pw.Column] instead
+/// of being its own top-level entry defeats that bookkeeping and throws
+/// `TooManyPagesException` on anything long enough to need several page
+/// breaks — even though the content itself is perfectly valid.
+List _buildBody(
+ FuelReport report, {
+ required bool includeLineItems,
+ required bool includeReceiptImages,
+ required Map receiptImages,
+}) {
+ pw.Widget pad(pw.Widget child) =>
+ pw.Padding(padding: const pw.EdgeInsets.symmetric(horizontal: 36), child: child);
+
+ if (report.rows.isEmpty) {
+ return [
+ pw.Padding(
+ padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
+ child: _buildTitleBlock(report),
+ ),
+ pad(pw.Text('No vehicles had a receipted fuel entry in this date range.')),
+ ];
+ }
+
+ final widgets = [
+ pw.Padding(
+ padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
+ child: _buildTitleBlock(report),
+ ),
+ pad(_buildHeroStat(report)),
+ pw.SizedBox(height: 22),
+ pad(_buildStatCardsRow(report)),
+ pw.SizedBox(height: 26),
+ pad(_buildMonthlySection(report)),
+ pw.SizedBox(height: 26),
+ pad(_buildVehicleSection(report)),
+ ];
+
+ if (includeLineItems || includeReceiptImages) {
+ widgets.add(pw.SizedBox(height: 26));
+ widgets.addAll(
+ _buildLineItemsAndImagesSections(
+ report,
+ includeLineItems: includeLineItems,
+ includeReceiptImages: includeReceiptImages,
+ receiptImages: receiptImages,
+ ).map(pad),
+ );
+ }
+
+ widgets.add(pw.SizedBox(height: 8));
+ return widgets;
+}
+
+pw.Widget _buildBanner(DateTime generatedAt) {
+ // The watermark lives here, inside the banner's own bounded height,
+ // rather than overlaid on the flowing body content below — a Stack
+ // wrapping unbounded/multi-page content doesn't paginate (MultiPage
+ // needs a SpanningWidget or a fixed-size child for that), so scoping it
+ // to this fixed-height strip is what keeps it safe across page breaks.
+ return pw.Container(
+ color: _bannerBackground,
+ child: pw.Stack(
+ children: [
+ pw.Positioned(
+ right: 12,
+ top: -14,
+ child: pw.SizedBox(width: 60, height: 60, child: _buildWatermark(alpha: 0.14)),
+ ),
+ pw.Padding(
+ padding: const pw.EdgeInsets.symmetric(horizontal: 36, vertical: 14),
+ child: pw.Row(
+ mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
+ children: [
+ pw.Text(
+ 'Show Me The Fuel Refund',
+ style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold, color: _bannerText),
+ ),
+ pw.Container(
+ padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: pw.BoxDecoration(
+ color: PdfColors.white,
+ borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
+ ),
+ child: pw.Text(
+ 'Generated ${_generatedFormat.format(generatedAt)}',
+ style: pw.TextStyle(fontSize: 10, color: _bannerText),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+}
+
+pw.Widget _buildFooter(FuelReport report, pw.Context context) {
+ return pw.Container(
+ padding: const pw.EdgeInsets.fromLTRB(36, 8, 36, 20),
+ decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderStrong, width: 0.5))),
+ margin: const pw.EdgeInsets.only(top: 6),
+ child: pw.Row(
+ mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
+ children: [
+ pw.Text(
+ '${report.fillCount} purchase${report.fillCount == 1 ? '' : 's'} · '
+ '${report.rows.length} vehicle${report.rows.length == 1 ? '' : 's'} · '
+ '${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}',
+ style: pw.TextStyle(fontSize: 9, color: _textFaint),
+ ),
+ pw.Text(
+ 'Page ${context.pageNumber} of ${context.pagesCount}',
+ style: pw.TextStyle(fontSize: 9, color: _textFaint),
+ ),
+ ],
+ ),
+ );
+}
+
+/// A faint brand-colored fuel-can silhouette — purely decorative,
+/// matching the report mockup's watermark shape.
+pw.Widget _buildWatermark({required double alpha}) {
+ const svg = '''
+
+''';
+ return pw.SvgImage(svg: svg, colorFilter: _accentGreen.withAlpha(alpha));
+}
+
+pw.Widget _buildTitleBlock(FuelReport report) {
+ final months = report.monthlyTotals.length;
+ final vehicles = report.rows.length;
+ final fills = report.fillCount;
+ final subtitle = StringBuffer('${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}');
+ if (report.rows.isNotEmpty) {
+ subtitle.write(
+ ' · $months month${months == 1 ? '' : 's'} · $vehicles vehicle${vehicles == 1 ? '' : 's'} · '
+ '$fills fill-up${fills == 1 ? '' : 's'}',
+ );
+ }
+
+ return pw.Padding(
+ padding: const pw.EdgeInsets.only(bottom: 20),
+ child: pw.Column(
+ crossAxisAlignment: pw.CrossAxisAlignment.start,
+ children: [
+ pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
+ pw.SizedBox(height: 3),
+ pw.Text(subtitle.toString(), style: pw.TextStyle(fontSize: 12, color: _textMuted)),
+ ],
+ ),
+ );
+}
+
+pw.Widget _buildHeroStat(FuelReport report) {
+ return pw.Column(
+ crossAxisAlignment: pw.CrossAxisAlignment.start,
+ children: [
+ pw.Text('Total fuel purchased', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
+ pw.SizedBox(height: 4),
+ pw.Row(
+ crossAxisAlignment: pw.CrossAxisAlignment.end,
+ children: [
+ pw.Text(
+ report.totalGallons.toStringAsFixed(3),
+ style: pw.TextStyle(fontSize: 34, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
+ ),
+ pw.SizedBox(width: 6),
+ pw.Padding(
+ padding: const pw.EdgeInsets.only(bottom: 5),
+ child: pw.Text('gal', style: pw.TextStyle(fontSize: 13, color: _accentGreenDark)),
+ ),
+ ],
+ ),
+ ],
+ );
+}
+
+pw.Widget _buildStatCardsRow(FuelReport report) {
+ pw.Widget statCard(String label, String value) => pw.Expanded(
+ child: pw.Container(
+ padding: const pw.EdgeInsets.all(10),
+ decoration: pw.BoxDecoration(
+ color: _cardBackground,
+ borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
+ ),
+ child: pw.Column(
+ crossAxisAlignment: pw.CrossAxisAlignment.start,
+ children: [
+ pw.Text(label, style: pw.TextStyle(fontSize: 10, color: _textMuted)),
+ pw.SizedBox(height: 3),
+ pw.Text(value, style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
+ ],
+ ),
+ ),
+ );
+
+ return pw.Row(
+ children: [
+ statCard('Spent at the pump', _currencyFormat.format(report.totalCost)),
+ pw.SizedBox(width: 10),
+ statCard('Avg price/gal', _currencyFormat.format(report.avgPricePerGallon)),
+ pw.SizedBox(width: 10),
+ statCard('Fill-ups logged', report.fillCount.toString()),
+ ],
+ );
+}
+
+pw.Widget _buildMonthlySection(FuelReport report) {
+ final months = report.monthlyTotals;
+ if (months.isEmpty) return pw.SizedBox();
+
+ const chartHeight = 64.0;
+ final maxGallons = months.fold(0.0, (max, m) => m.totalGallons > max ? m.totalGallons : max);
+
+ return pw.Column(
+ crossAxisAlignment: pw.CrossAxisAlignment.start,
+ children: [
+ pw.Text('Gallons by month', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
+ pw.SizedBox(height: 8),
+ if (months.length > 1) ...[
+ pw.SizedBox(
+ height: chartHeight,
+ child: pw.Row(
+ crossAxisAlignment: pw.CrossAxisAlignment.end,
+ children: [
+ for (final month in months)
+ pw.Expanded(
+ child: pw.Padding(
+ padding: const pw.EdgeInsets.symmetric(horizontal: 3),
+ child: pw.Container(
+ height: maxGallons == 0
+ ? 2
+ : (chartHeight * (month.totalGallons / maxGallons)).clamp(2, chartHeight),
+ decoration: pw.BoxDecoration(
+ color: _accentGreen,
+ borderRadius: const pw.BorderRadius.only(
+ topLeft: pw.Radius.circular(2),
+ topRight: pw.Radius.circular(2),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ pw.SizedBox(height: 4),
+ pw.Row(
+ children: [
+ for (final month in months)
+ pw.Expanded(
+ child: pw.Center(
+ child: pw.Text(_monthFormat.format(month.month), style: pw.TextStyle(fontSize: 9, color: _textFaint)),
+ ),
+ ),
+ ],
+ ),
+ pw.SizedBox(height: 10),
+ ],
+ pw.TableHelper.fromTextArray(
+ headers: ['Month', 'Fills', 'Gallons', 'Spent', 'Avg/gal'],
+ data: [
+ for (final month in months)
+ [
+ _monthHeadingFormat.format(month.month),
+ month.fillCount.toString(),
+ month.totalGallons.toStringAsFixed(3),
+ _currencyFormat.format(month.totalCost),
+ _currencyFormat.format(month.avgPricePerGallon),
+ ],
+ ],
+ cellAlignments: const {
+ 1: pw.Alignment.centerRight,
+ 2: pw.Alignment.centerRight,
+ 3: pw.Alignment.centerRight,
+ 4: pw.Alignment.centerRight,
+ },
+ headerAlignments: const {
+ 1: pw.Alignment.centerRight,
+ 2: pw.Alignment.centerRight,
+ 3: pw.Alignment.centerRight,
+ 4: pw.Alignment.centerRight,
+ },
+ headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
+ cellStyle: const pw.TextStyle(fontSize: 10),
+ cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
+ border: _tableBorder,
+ headerDecoration: _tableHeaderDecoration,
+ rowDecoration: _tableEvenRowDecoration,
+ oddRowDecoration: _tableOddRowDecoration,
+ ),
+ if (months.length > 1) ...[
+ pw.SizedBox(height: 6),
+ pw.Row(
+ mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
+ children: [
+ pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10)),
+ pw.Text(
+ '${report.fillCount} · ${report.totalGallons.toStringAsFixed(3)} gal · '
+ '${_currencyFormat.format(report.totalCost)} · ${_currencyFormat.format(report.avgPricePerGallon)}',
+ style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
+ ),
+ ],
+ ),
+ ],
+ ],
+ );
+}
+
+pw.Widget _buildVehicleSection(FuelReport report) {
+ return pw.Column(
+ crossAxisAlignment: pw.CrossAxisAlignment.start,
+ children: [
+ pw.Text('By vehicle', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
+ for (final row in report.rows)
+ pw.Container(
+ padding: const pw.EdgeInsets.symmetric(vertical: 10),
+ decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderLight, width: 0.5))),
+ child: pw.Row(
+ crossAxisAlignment: pw.CrossAxisAlignment.center,
+ children: [
+ pw.Expanded(
+ flex: 2,
+ child: pw.Column(
+ crossAxisAlignment: pw.CrossAxisAlignment.start,
+ children: [
+ pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
+ pw.Text(row.vehicle.vin, style: pw.TextStyle(fontSize: 9, color: _textFaint)),
+ ],
+ ),
+ ),
+ pw.Expanded(
+ child: pw.Text(
+ '${row.entryCount} fill${row.entryCount == 1 ? '' : 's'}',
+ textAlign: pw.TextAlign.right,
+ style: pw.TextStyle(fontSize: 11, color: _textMuted),
+ ),
+ ),
+ pw.Expanded(
+ child: pw.Text(
+ _currencyFormat.format(row.totalCost),
+ textAlign: pw.TextAlign.right,
+ style: const pw.TextStyle(fontSize: 11),
+ ),
+ ),
+ pw.Expanded(
+ child: pw.Text(
+ '${row.totalGallons.toStringAsFixed(3)} gal',
+ textAlign: pw.TextAlign.right,
+ style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+}
+
+/// Item numbers (when both [includeLineItems] and [includeReceiptImages]
+/// are on) are assigned once, in report order — vehicle by vehicle,
+/// oldest-entry-first within each — and reused by both the "Line Items"
+/// table and the "Receipt Images" section below it, so a photo and its
+/// line item carry the same number despite living in separate sections.
+List _buildLineItemsAndImagesSections(
+ FuelReport report, {
+ required bool includeLineItems,
+ required bool includeReceiptImages,
+ required Map receiptImages,
+}) {
+ final showItemNumbers = includeLineItems && includeReceiptImages;
+ final itemNumbers = {};
+ var nextNumber = 1;
+ for (final row in report.rows) {
+ for (final entry in row.entries) {
+ itemNumbers[entry.id] = nextNumber++;
+ }
+ }
+
+ final widgets = [];
+
+ if (includeLineItems) {
+ widgets.add(pw.Text('Line Items', style: _sectionHeadingStyle));
+ for (final row in report.rows) {
+ widgets.add(pw.SizedBox(height: 14));
+ widgets.add(_buildVehicleSubheading(row));
+ widgets.add(pw.SizedBox(height: 6));
+ widgets.add(_buildLineItemsTable(row, showItemNumbers: showItemNumbers, itemNumbers: itemNumbers));
+ }
+ }
+
+ if (includeReceiptImages) {
+ final rowsWithImages = report.rows.where((row) => row.entries.any((e) => receiptImages.containsKey(e.id)));
+ if (rowsWithImages.isNotEmpty) {
+ if (includeLineItems) widgets.add(pw.SizedBox(height: 26));
+ widgets.add(pw.Text('Receipt Images', style: _sectionHeadingStyle));
+ for (final row in rowsWithImages) {
+ widgets.add(pw.SizedBox(height: 14));
+ widgets.add(_buildVehicleSubheading(row));
+ widgets.add(pw.SizedBox(height: 8));
+ widgets.add(_buildReceiptImagesWrap(
+ row,
+ receiptImages,
+ showItemNumbers: showItemNumbers,
+ itemNumbers: itemNumbers,
+ ));
+ }
+ }
+ }
+
+ return widgets;
+}
+
+pw.Widget _buildVehicleSubheading(VehicleReportRow row) {
+ return pw.Column(
+ crossAxisAlignment: pw.CrossAxisAlignment.start,
+ children: [
+ pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
+ pw.Text('VIN: ${row.vehicle.vin}', style: pw.TextStyle(fontSize: 9, color: _textFaint)),
+ ],
+ );
+}
+
+pw.Widget _buildLineItemsTable(
+ VehicleReportRow row, {
+ required bool showItemNumbers,
+ required Map itemNumbers,
+}) {
+ return pw.TableHelper.fromTextArray(
+ headers: [
+ if (showItemNumbers) '#',
+ 'Date',
+ 'Gallons',
+ 'Price/Gal',
+ 'Total Cost',
+ ],
+ data: [
+ for (final entry in row.entries)
+ [
+ if (showItemNumbers) itemNumbers[entry.id].toString(),
+ _dateTimeFormat.format(entry.date),
+ entry.gallons.toStringAsFixed(3),
+ _currencyFormat.format(entry.pricePerGallon),
+ _currencyFormat.format(entry.totalCost),
+ ],
+ ],
+ cellAlignments: showItemNumbers
+ ? const {
+ 0: pw.Alignment.centerLeft,
+ 2: pw.Alignment.centerRight,
+ 3: pw.Alignment.centerRight,
+ 4: pw.Alignment.centerRight,
+ }
+ : const {
+ 1: pw.Alignment.centerRight,
+ 2: pw.Alignment.centerRight,
+ 3: pw.Alignment.centerRight,
+ },
+ headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
+ cellStyle: const pw.TextStyle(fontSize: 10),
+ cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
+ border: _tableBorder,
+ headerDecoration: _tableHeaderDecoration,
+ rowDecoration: _tableEvenRowDecoration,
+ oddRowDecoration: _tableOddRowDecoration,
+ );
+}
+
+/// 140×140 — comfortably legible (the previous design's inline thumbnails,
+/// at ~34×44, were too small to make out) while still fitting several per
+/// row on a letter-width page.
+const _receiptImageSize = 140.0;
+
+pw.Widget _buildReceiptImagesWrap(
+ VehicleReportRow row,
+ Map receiptImages, {
+ required bool showItemNumbers,
+ required Map itemNumbers,
+}) {
+ return pw.Wrap(
+ spacing: 10,
+ runSpacing: 10,
+ children: [
+ for (final entry in row.entries)
+ if (receiptImages[entry.id] case final bytes?)
+ pw.Column(
+ children: [
+ pw.Container(
+ width: _receiptImageSize,
+ height: _receiptImageSize,
+ decoration: pw.BoxDecoration(
+ color: _cardBackground,
+ borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
+ border: pw.Border.all(color: _borderLight, width: 0.5),
+ ),
+ child: pw.ClipRRect(
+ horizontalRadius: 6,
+ verticalRadius: 6,
+ child: pw.Image(pw.MemoryImage(bytes), fit: pw.BoxFit.cover),
+ ),
+ ),
+ pw.SizedBox(height: 4),
+ pw.Text(
+ showItemNumbers
+ ? '#${itemNumbers[entry.id]} · ${_dayMonthFormat.format(entry.date)}'
+ : _dayMonthFormat.format(entry.date),
+ style: pw.TextStyle(fontSize: 9, color: _textMuted),
+ ),
+ ],
+ ),
+ ],
+ );
+}
+
/// `fuel-report--.pdf`, e.g. `fuel-report-20260101-20260331.pdf`.
String fuelReportFileName(FuelReport report) {
final fmt = DateFormat('yyyyMMdd');
diff --git a/lib/services/vin_parser.dart b/lib/services/vin_parser.dart
index 6ca6949..ae40457 100644
--- a/lib/services/vin_parser.dart
+++ b/lib/services/vin_parser.dart
@@ -2,18 +2,24 @@
/// sticker, dashboard plate, title, etc).
///
/// Real VINs never contain the letters I, O, or Q (they're excluded from
-/// the standard specifically so they can't be confused with 1 and 0), so
-/// requiring that charset both matches genuine VINs and rules out a lot of
-/// incidental 17-character noise elsewhere on the sticker (barcodes text,
-/// weight ratings, date codes, etc).
+/// the standard specifically so they can't be confused with 1, 0, and 9 —
+/// which is exactly the kind of confusion OCR itself is prone to: a
+/// genuine VIN's "0" is sometimes read back as "O"). So rather than reject
+/// a candidate for containing an I or O, [_normalize] treats it as a
+/// misread digit and corrects it — Q is left alone, since it doesn't
+/// closely resemble any digit and so is more likely to mean the candidate
+/// isn't a VIN at all (e.g. noise from elsewhere on the sticker).
class VinParser {
+ // V\.?\s*I\.?\s*N tolerates the "V.I.N" (period between each letter)
+ // styling common on manufacturer compliance/data plates, not just the
+ // plain "VIN" a door-jamb sticker or title usually uses.
static final _labeledVinPattern =
- RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
+ RegExp(r'V\.?\s*I\.?\s*N[:.\s]*([A-PR-Z0-9]{17})\b', caseSensitive: false);
// \b on both sides matters: since digits and letters are both "word"
// characters, this only matches a maximal run of exactly 17 eligible
// characters — not a 17-character slice out of an 18+ character run.
- static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
+ static final _bareVinPattern = RegExp(r'\b([A-PR-Z0-9]{17})\b', caseSensitive: false);
/// Returns the VIN in uppercase, or null if nothing matching the VIN
/// charset/length was found. A labeled "VIN: ..." match is preferred
@@ -21,11 +27,24 @@ class VinParser {
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
static String? parse(String text) {
final labeled = _labeledVinPattern.firstMatch(text);
- if (labeled != null) return labeled.group(1)!.toUpperCase();
+ if (labeled != null) return _normalize(labeled.group(1)!);
final bare = _bareVinPattern.firstMatch(text);
- if (bare != null) return bare.group(1)!.toUpperCase();
+ if (bare != null) return _normalize(bare.group(1)!);
+
+ // Last resort: OCR sometimes splits a VIN across a stray space (e.g.
+ // an etched/curved surface gets read as two separate text regions) —
+ // retry once against the text with all whitespace removed. Tried last,
+ // after both whitespace-preserving attempts, since collapsing
+ // whitespace elsewhere in a busy label risks gluing unrelated text
+ // together into a spurious 17-character run.
+ final stripped = text.replaceAll(RegExp(r'\s+'), '');
+ final strippedMatch = _bareVinPattern.firstMatch(stripped);
+ if (strippedMatch != null) return _normalize(strippedMatch.group(1)!);
return null;
}
+
+ static String _normalize(String candidate) =>
+ candidate.toUpperCase().replaceAll('O', '0').replaceAll('I', '1');
}
diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart
index eb38561..bd1a5b0 100644
--- a/lib/theme/app_theme.dart
+++ b/lib/theme/app_theme.dart
@@ -31,6 +31,20 @@ class AppTheme {
static const _darkOutline = Color(0xFF7E877F); // --text-3
static const _darkOutlineVariant = Color(0xFF262926); // --line
+ // --- Hero card (used by ReceiptsScreen) ---
+ static const heroGradientLight = [Color(0xFF316F44), Color(0xFF1B4527)]; // --hero-from/to
+ static const heroGradientDark = [Color(0xFF2E6B41), Color(0xFF17331F)];
+ static const heroText = Colors.white; // --hero-text (same in both themes)
+ static const heroTextSecondaryLight = Color(0xC7FFFFFF); // --hero-text-2, 78% white
+ static const heroTextSecondaryDark = Color(0xB8FFFFFF); // --hero-text-2, 72% white
+
+ /// The hero card's gradient colors for whichever brightness is active.
+ static List heroGradient(BuildContext context) =>
+ Theme.of(context).brightness == Brightness.dark ? heroGradientDark : heroGradientLight;
+
+ static Color heroTextSecondary(BuildContext context) =>
+ Theme.of(context).brightness == Brightness.dark ? heroTextSecondaryDark : heroTextSecondaryLight;
+
static ThemeData get light {
final colorScheme = ColorScheme.fromSeed(
seedColor: _lightPrimary,
@@ -91,6 +105,7 @@ class AppTheme {
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
surfaceTintColor: Colors.transparent,
+ centerTitle: true,
titleTextStyle: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
diff --git a/lib/widgets/hero_banner.dart b/lib/widgets/hero_banner.dart
new file mode 100644
index 0000000..0a5d0c5
--- /dev/null
+++ b/lib/widgets/hero_banner.dart
@@ -0,0 +1,108 @@
+import 'package:flutter/material.dart';
+
+import '../theme/app_theme.dart';
+
+/// Green gradient hero banner background shared by every screen that shows
+/// a totals summary (Receipts, Vehicle Detail) — callers supply their own
+/// stat row / controls as [child], keeping the container/gradient styling
+/// itself in one place.
+class HeroBanner extends StatelessWidget {
+ final Widget child;
+
+ const HeroBanner({super.key, required this.child});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 10),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(15),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: AppTheme.heroGradient(context),
+ ),
+ ),
+ child: child,
+ );
+ }
+}
+
+/// A single label/value pair inside a [HeroBanner], e.g. "Total Gallons".
+class HeroStat extends StatelessWidget {
+ final String label;
+ final String value;
+ final bool alignEnd;
+
+ const HeroStat({super.key, required this.label, required this.value, required this.alignEnd});
+
+ @override
+ Widget build(BuildContext context) {
+ final textAlign = alignEnd ? TextAlign.end : TextAlign.start;
+ return Column(
+ crossAxisAlignment: alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ textAlign: textAlign,
+ style: TextStyle(
+ color: AppTheme.heroTextSecondary(context),
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ value,
+ textAlign: textAlign,
+ style: const TextStyle(
+ color: AppTheme.heroText,
+ fontSize: 28,
+ fontWeight: FontWeight.w800,
+ letterSpacing: -0.5,
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// The icon + two-stat row shared by every hero banner: gallons on the
+/// left, the hero icon in the middle, cost on the right.
+class HeroStatsRow extends StatelessWidget {
+ final String gallonsValue;
+ final String costValue;
+ final String gallonsLabel;
+ final String costLabel;
+
+ const HeroStatsRow({
+ super.key,
+ required this.gallonsValue,
+ required this.costValue,
+ this.gallonsLabel = 'Total Gallons',
+ this.costLabel = 'Total Fuel Cost',
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(child: HeroStat(label: gallonsLabel, value: gallonsValue, alignEnd: false)),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ // Tall enough to extend past the bottom of the label+value text
+ // next to it, not just match the label's height — width scales
+ // automatically from the source's own aspect ratio since only
+ // height is given.
+ child: Image.asset(
+ 'assets/icon/hero_icon.png',
+ height: 58,
+ fit: BoxFit.contain,
+ ),
+ ),
+ Expanded(child: HeroStat(label: costLabel, value: costValue, alignEnd: true)),
+ ],
+ );
+ }
+}
diff --git a/lib/widgets/image_source_sheet.dart b/lib/widgets/image_source_sheet.dart
index df5beba..e14d644 100644
--- a/lib/widgets/image_source_sheet.dart
+++ b/lib/widgets/image_source_sheet.dart
@@ -4,13 +4,46 @@ import 'package:image_picker/image_picker.dart';
/// Bottom sheet letting the user pick a photo from the camera or their
/// existing library. Shared by any screen that needs to snap or choose a
/// photo (receipt capture, VIN scanning) so the choice looks and behaves
-/// the same everywhere.
-Future chooseImageSource(BuildContext context) {
+/// the same everywhere. Pass [heading] to label what the photo is for
+/// (e.g. receipt capture passes "Receipt From") — omitted by callers where
+/// that's already obvious from context. [onInfoTap], if given, adds an
+/// info button next to [heading] (e.g. VIN scanning uses it to explain
+/// where to find a VIN); ignored if [heading] is null, since there'd be
+/// nowhere to put it.
+Future chooseImageSource(
+ BuildContext context, {
+ String? heading,
+ VoidCallback? onInfoTap,
+}) {
return showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Wrap(
children: [
+ if (heading != null)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 8, 4),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ heading,
+ style: Theme.of(context)
+ .textTheme
+ .labelLarge
+ ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ ),
+ if (onInfoTap != null)
+ IconButton(
+ icon: const Icon(Icons.info_outline),
+ iconSize: 20,
+ tooltip: 'Where do I find this?',
+ onPressed: onInfoTap,
+ ),
+ ],
+ ),
+ ),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Take Photo'),
diff --git a/lib/widgets/receipt_capture.dart b/lib/widgets/receipt_capture.dart
new file mode 100644
index 0000000..3eaef49
--- /dev/null
+++ b/lib/widgets/receipt_capture.dart
@@ -0,0 +1,115 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:image_picker/image_picker.dart';
+import 'package:provider/provider.dart';
+
+import '../screens/confirm_fuel_entry_screen.dart';
+import '../services/app_state.dart';
+import '../services/ocr_service.dart';
+import '../services/receipt_parser.dart';
+import 'image_source_sheet.dart';
+
+/// Long edge and JPEG quality a receipt photo is downscaled to unless
+/// "keep max quality" is on — a receipt is a photo of small printed text,
+/// not something that benefits from a multi-megapixel original, and this
+/// keeps typical files in the low hundreds of KB instead of several MB.
+const receiptImageMaxDimension = 1600.0;
+const receiptImageQuality = 70;
+
+/// Shared by [VehicleDetailScreen]'s "Log Fuel Receipt" button and the
+/// Receipts tab's "+" action: pick a photo, OCR it, and — unless the
+/// receipt looks like it's from outside Missouri, in which case the user
+/// gets a chance to back out first — hand off to [ConfirmFuelEntryScreen]
+/// for [vehicleId].
+Future captureReceiptForVehicle(BuildContext context, String vehicleId) async {
+ final source = await chooseImageSource(context, heading: 'Receipt From');
+ if (source == null || !context.mounted) return;
+
+ final keepMaxQuality = context.read().keepMaxQualityReceiptPhotos;
+ final picker = ImagePicker();
+ XFile? photo;
+ try {
+ photo = await picker.pickImage(
+ source: source,
+ imageQuality: keepMaxQuality ? null : receiptImageQuality,
+ maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
+ maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
+ );
+ } catch (e) {
+ if (context.mounted) {
+ final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Could not open $sourceLabel: $e')),
+ );
+ }
+ return;
+ }
+ if (photo == null) return;
+
+ final imageFile = File(photo.path);
+
+ if (!context.mounted) return;
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (_) => const Center(child: CircularProgressIndicator()),
+ );
+
+ final ocrService = OcrService();
+ String recognizedText = '';
+ try {
+ recognizedText = await ocrService.recognizeText(imageFile);
+ } catch (_) {
+ recognizedText = '';
+ } finally {
+ ocrService.dispose();
+ }
+
+ final parsed = ReceiptParser.parse(recognizedText);
+
+ if (!context.mounted) return;
+ Navigator.of(context).pop(); // close the OCR loading spinner
+
+ final state = parsed.state;
+ if (state != null && state != 'MO') {
+ final proceed = await _confirmNonMissouriPurchase(context, state);
+ if (!proceed || !context.mounted) return;
+ }
+
+ if (context.mounted) {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ConfirmFuelEntryScreen(
+ vehicleId: vehicleId,
+ imageFile: imageFile,
+ parsed: parsed,
+ ),
+ ),
+ );
+ }
+}
+
+/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
+/// warn if the receipt's address is somewhere else, and let the user
+/// decide whether to log it anyway (e.g. it might still be worth tracking
+/// for other reasons even if it won't qualify for a refund).
+Future _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
+ final stateName = usStateNames[stateCode] ?? stateCode;
+ final proceed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Non-Missouri Purchase'),
+ content: Text(
+ 'This receipt looks like it\'s from a gas station in $stateName. '
+ 'The Missouri fuel tax refund only applies to fuel purchased in '
+ 'Missouri.\n\nDo you still want to add this entry?',
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
+ FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
+ ],
+ ),
+ );
+ return proceed ?? false;
+}
diff --git a/lib/widgets/receipt_thumbnail.dart b/lib/widgets/receipt_thumbnail.dart
new file mode 100644
index 0000000..21e6112
--- /dev/null
+++ b/lib/widgets/receipt_thumbnail.dart
@@ -0,0 +1,61 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+
+import '../models/fuel_entry.dart';
+import '../screens/receipt_image_screen.dart';
+
+/// A fuel entry's receipt as a tappable thumbnail: the local photo if one's
+/// still on-device, a cloud icon (tap to download-and-view) if it's been
+/// uploaded and the local copy cleared, or a plain placeholder if there's
+/// no receipt at all. Shared by [VehicleDetailScreen]'s entry list, the
+/// cross-vehicle Receipts tab, [ReceiptDetailScreen] (at a larger [size],
+/// as its primary preview), and [EditFuelEntryScreen] so all four present a
+/// receipt the same way.
+///
+/// Tapping it opens the full-screen photo viewer by default; pass [onTap]
+/// to send it somewhere else instead (e.g. the list rows send it to
+/// [ReceiptDetailScreen] instead of straight to the photo).
+class ReceiptThumbnail extends StatelessWidget {
+ final FuelEntry entry;
+ final double size;
+ final VoidCallback? onTap;
+
+ const ReceiptThumbnail({super.key, required this.entry, this.size = 48, this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ if (entry.receiptImagePath != null) {
+ return GestureDetector(
+ onTap: onTap ??
+ () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath),
+ ),
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(6),
+ child: Image.file(
+ File(entry.receiptImagePath!),
+ width: size,
+ height: size,
+ fit: BoxFit.cover,
+ ),
+ ),
+ );
+ }
+ if (entry.isReceiptUploadedToDrive) {
+ return GestureDetector(
+ onTap: onTap ??
+ () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId),
+ ),
+ ),
+ child: CircleAvatar(radius: size / 2, child: Icon(Icons.cloud_outlined, size: size * 0.5)),
+ );
+ }
+ final placeholder = CircleAvatar(radius: size / 2, child: Icon(Icons.receipt_long, size: size * 0.5));
+ return onTap == null ? placeholder : GestureDetector(onTap: onTap, child: placeholder);
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index 7fb6d87..ff92767 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.7"
+ ansicolor:
+ dependency: transitive
+ description:
+ name: ansicolor
+ sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.3"
archive:
dependency: transitive
description:
@@ -65,6 +73,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ checked_yaml:
+ dependency: transitive
+ description:
+ name: checked_yaml
+ sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.4"
+ cli_util:
+ dependency: transitive
+ description:
+ name: cli_util
+ sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.2"
clock:
dependency: transitive
description:
@@ -121,6 +145,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
+ csslib:
+ dependency: transitive
+ description:
+ name: csslib
+ sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -222,6 +254,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_launcher_icons:
+ dependency: "direct dev"
+ description:
+ name: flutter_launcher_icons
+ sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -230,6 +270,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
+ flutter_native_splash:
+ dependency: "direct dev"
+ description:
+ name: flutter_native_splash
+ sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.8"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -400,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
+ html:
+ dependency: transitive
+ description:
+ name: html
+ sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.15.6"
http:
dependency: "direct main"
description:
@@ -520,6 +576,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.12.0"
leak_tracker:
dependency: transitive
description:
@@ -965,8 +1029,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
- url_launcher:
+ universal_io:
dependency: transitive
+ description:
+ name: universal_io
+ sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.1"
+ url_launcher:
+ dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
diff --git a/pubspec.yaml b/pubspec.yaml
index 0540960..8941af5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -91,6 +91,7 @@ dependencies:
flutter_secure_storage: ^10.0.0
pdf: ^3.12.0
printing: ^5.14.3
+ url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
@@ -116,6 +117,52 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^6.0.0
+ # Generates the Android/iOS launcher icon files from assets/icon/app_icon.png
+ # — run `dart run flutter_launcher_icons` after changing that source image.
+ flutter_launcher_icons: ^0.14.4
+
+ # Generates the native (pre-Dart-engine) splash screen shown at cold
+ # start — run `dart run flutter_native_splash:create` after changing
+ # assets/splash/*. Separate from flutter_launcher_icons: without this,
+ # Android falls back to its own default Splash Screen behavior, which
+ # wraps the launcher icon in a small icon-in-a-white-circle look we don't
+ # control at all.
+ flutter_native_splash: ^2.4.8
+
+flutter_launcher_icons:
+ android: true
+ ios: true
+ image_path: "assets/icon/app_icon.png"
+ # The source image is a full-bleed square with its own rounded corners
+ # already baked in via real alpha transparency (not a white margin), so
+ # each platform's own icon masking just clips already-transparent pixels
+ # — no double-rounding artifact. iOS icons must be fully opaque (the App
+ # Store rejects alpha, and the OS doesn't composite icon transparency the
+ # way Android does), so flatten alpha to a solid background there only.
+ remove_alpha_ios: true
+
+flutter_native_splash:
+ # Pre-Android-12 (and iOS): no OS-imposed circular mask, so this can be
+ # sized generously — the brand mark centered on a solid background that
+ # matches the app icon's own green, so there's no white border/seam.
+ color: "#24603A"
+ color_dark: "#101110"
+ image: assets/splash/splash_logo.png
+ image_dark: assets/splash/splash_logo.png
+
+ android_12:
+ # Android 12+ handles splash screens itself: a background color, an
+ # icon, and (optionally) a separate icon background color — it enforces
+ # its own circular mask no matter what we provide (clips the outer
+ # third), so splash_logo_android12.png is pre-fit to stay inside that
+ # safe circle rather than relying on this package to size it for us.
+ color: "#24603A"
+ color_dark: "#101110"
+ icon_background_color: "#24603A"
+ icon_background_color_dark: "#101110"
+ image: assets/splash/splash_logo_android12.png
+ image_dark: assets/splash/splash_logo_android12.png
+
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -127,10 +174,14 @@ flutter:
# the material Icons class.
uses-material-design: true
- # To add assets to your application, add an assets section, like this:
- # assets:
- # - images/a_dot_burr.jpeg
- # - images/a_dot_ham.jpeg
+ # The brand mark shown between the two stats in the Receipts tab's hero
+ # banner (see ReceiptsScreen) — distinct from assets/icon/app_icon.png,
+ # which flutter_launcher_icons reads at build time and never ships inside
+ # the app bundle itself.
+ assets:
+ - assets/icon/hero_icon.png
+ - assets/vin_help/door_jamb.jpg
+ - assets/vin_help/windshield.jpg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
diff --git a/test/add_edit_vehicle_screen_test.dart b/test/add_edit_vehicle_screen_test.dart
index 4d594f9..4d43509 100644
--- a/test/add_edit_vehicle_screen_test.dart
+++ b/test/add_edit_vehicle_screen_test.dart
@@ -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);
+ });
}
diff --git a/test/edit_fuel_entry_test.dart b/test/edit_fuel_entry_test.dart
new file mode 100644
index 0000000..322f08f
--- /dev/null
+++ b/test/edit_fuel_entry_test.dart
@@ -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 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 getApplicationDocumentsPath() async => tempPath;
+
+ @override
+ Future getTemporaryPath() async => tempPath;
+}
diff --git a/test/fuel_report_images_test.dart b/test/fuel_report_images_test.dart
new file mode 100644
index 0000000..3328a4c
--- /dev/null
+++ b/test/fuel_report_images_test.dart
@@ -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 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> bytesByFileId;
+ _FakeSession({required this.bytesByFileId});
+
+ @override
+ Future> 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> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
+ @override
+ Future findOrCreateFolder({required String parentId, required String name}) async => '';
+ @override
+ Future findFile({required String folderId, required String name}) async => null;
+ @override
+ Future uploadFile({
+ required String folderId,
+ required String name,
+ String? existingFileId,
+ required File localFile,
+ required String contentType,
+ }) async =>
+ CloudFileInfo(id: 'x', versionTag: null);
+ @override
+ Future deleteFile(String fileId) async {}
+ @override
+ Future createLockFile({required String folderId, required String name}) async => 'lock';
+ @override
+ Future> 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 attemptSilentSignIn() async => true;
+ @override
+ Future signIn() async => accountLabel!;
+ @override
+ Future signOut() async {}
+ @override
+ CloudStorageSession beginSession() => session;
+}
diff --git a/test/fuel_report_pdf_test.dart b/test/fuel_report_pdf_test.dart
new file mode 100644
index 0000000..68a4c49
--- /dev/null
+++ b/test/fuel_report_pdf_test.dart
@@ -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 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 = [];
+ 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');
+ });
+}
diff --git a/test/fuel_report_test.dart b/test/fuel_report_test.dart
index 6ffca48..d73df3d 100644
--- a/test/fuel_report_test.dart
+++ b/test/fuel_report_test.dart
@@ -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']);
+ });
}
diff --git a/test/image_source_sheet_test.dart b/test/image_source_sheet_test.dart
new file mode 100644
index 0000000..bf17367
--- /dev/null
+++ b/test/image_source_sheet_test.dart
@@ -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 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);
+ });
+}
diff --git a/test/main_shell_test.dart b/test/main_shell_test.dart
new file mode 100644
index 0000000..8edceae
--- /dev/null
+++ b/test/main_shell_test.dart
@@ -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(find.text('Last 3 Months'));
+ expect((selectedLabel.style?.fontWeight), FontWeight.w800, reason: 'still the active range');
+ });
+}
diff --git a/test/purge_data_test.dart b/test/purge_data_test.dart
new file mode 100644
index 0000000..0a99422
--- /dev/null
+++ b/test/purge_data_test.dart
@@ -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 getApplicationDocumentsPath() async => tempPath;
+
+ @override
+ Future getTemporaryPath() async => tempPath;
+}
diff --git a/test/receipt_detail_screen_test.dart b/test/receipt_detail_screen_test.dart
new file mode 100644
index 0000000..5e368fd
--- /dev/null
+++ b/test/receipt_detail_screen_test.dart
@@ -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 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);
+ });
+}
diff --git a/test/receipts_screen_test.dart b/test/receipts_screen_test.dart
new file mode 100644
index 0000000..cc650e4
--- /dev/null
+++ b/test/receipts_screen_test.dart
@@ -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(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.
+}
diff --git a/test/report_screen_test.dart b/test/report_screen_test.dart
new file mode 100644
index 0000000..dadaf81
--- /dev/null
+++ b/test/report_screen_test.dart
@@ -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(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);
+ });
+}
diff --git a/test/report_vehicle_selection_test.dart b/test/report_vehicle_selection_test.dart
new file mode 100644
index 0000000..372921e
--- /dev/null
+++ b/test/report_vehicle_selection_test.dart
@@ -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 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 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(
+ 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);
+ });
+}
diff --git a/test/settings_purge_test.dart b/test/settings_purge_test.dart
new file mode 100644
index 0000000..45f0a5f
--- /dev/null
+++ b/test/settings_purge_test.dart
@@ -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 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 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(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(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();
+ });
+}
diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart
new file mode 100644
index 0000000..7d63c96
--- /dev/null
+++ b/test/settings_screen_test.dart
@@ -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 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);
+ });
+}
diff --git a/test/theme_toggle_test.dart b/test/theme_toggle_test.dart
new file mode 100644
index 0000000..331551c
--- /dev/null
+++ b/test/theme_toggle_test.dart
@@ -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');
+ });
+}
diff --git a/test/vehicle_detail_screen_test.dart b/test/vehicle_detail_screen_test.dart
new file mode 100644
index 0000000..779647f
--- /dev/null
+++ b/test/vehicle_detail_screen_test.dart
@@ -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 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);
+ });
+}
diff --git a/test/vin_parser_test.dart b/test/vin_parser_test.dart
index a91a426..ee327ad 100644
--- a/test/vin_parser_test.dart
+++ b/test/vin_parser_test.dart
@@ -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');
+ });
});
}
diff --git a/test/widget_test.dart b/test/widget_test.dart
index 25f65ba..1c2235d 100644
--- a/test/widget_test.dart
+++ b/test/widget_test.dart
@@ -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);
});
}