A whole bunch of changes

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

View file

@ -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<AppState>(
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();
},
);
}

View file

@ -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<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
@ -39,6 +47,24 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
super.initState();
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
if (widget.promptToCreateForReceipt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showDialog<void>(
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<AddEditVehicleScreen> {
super.dispose();
}
void _showVinLocationHelp() {
showDialog<void>(
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<void> _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<AddEditVehicleScreen> {
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<void>(
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<AddEditVehicleScreen> {
_error = null;
});
final vin = _vinController.text.trim();
final nickname = _nicknameController.text.trim();
final appState = context.read<AppState>();
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 {

View file

@ -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<DataSettingsScreen> createState() => _DataSettingsScreenState();
}
class _DataSettingsScreenState extends State<DataSettingsScreen> {
static final _dateFormat = DateFormat.yMMMd();
bool _busy = false;
String? _error;
DateTime? _purgeRangeStart;
DateTime? _purgeRangeEnd;
String? _purgeRangeError;
Future<void> _connect(CloudProviderId id) async {
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProvider(id);
} catch (e) {
setState(() => _error = 'Could not sign in: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _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<AppState>().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<void> _disconnect() async {
setState(() => _busy = true);
try {
await context.read<AppState>().disconnectCloud();
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _chooseFolder() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
);
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await context.read<AppState>().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<bool> _confirmPurge({required String title, required String message}) async {
final confirmed = await showDialog<bool>(
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<void> _purgeAllData() async {
final appState = context.read<AppState>();
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<void> _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<void> _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<AppState>();
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<AppState>();
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<AppState>().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<AppState>().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<AppState>().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<FormState>();
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'),
),
],
);
}
}

View file

@ -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<EditFuelEntryScreen> createState() => _EditFuelEntryScreenState();
}
class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
await context.read<AppState>().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'),
),
],
),
),
);
}
}

View file

@ -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),
],
),
),
),
);
}

View file

@ -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<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
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',
),
],
),
);
}
}

View file

@ -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<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>(
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<AppState>().deleteFuelEntry(entry.id);
if (context.mounted) Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
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),
],
),
);
}
}

View file

@ -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<ReceiptsScreen> createState() => _ReceiptsScreenState();
}
class _ReceiptsScreenState extends State<ReceiptsScreen> 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<void> _addReceipt(BuildContext context) async {
final vehicles = context.read<AppState>().vehicles;
if (vehicles.isEmpty) {
final newVehicleId = await Navigator.of(context).push<String>(
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<Vehicle>(
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<AppState>();
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,
),
),
),
],
),
],
),
),
);
}
}

View file

@ -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<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends State<ReportScreen> {
class _ReportScreenState extends State<ReportScreen> 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<String> _selectedVehicleIds = {};
final Set<String> _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<void> _pickDate({required bool isStart}) async {
final now = DateTime.now();
final picked = await showDatePicker(
@ -39,10 +68,39 @@ class _ReportScreenState extends State<ReportScreen> {
});
}
Future<Uint8List> _buildPdfBytes(FuelReport report) async {
var receiptImages = const <String, Uint8List>{};
if (_includeReceiptImages) {
final loaded = await loadReceiptImageBytes(
report: report,
activeProvider: context.read<AppState>().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<void> _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<ReportScreen> {
Future<void> _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<void> _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<AppState>();
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<ReportScreen> {
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),
),
],
),
],
),
),
),
);
}
}

View file

@ -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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _busy = false;
String? _error;
Future<void> _connect(CloudProviderId id) async {
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProvider(id);
} catch (e) {
setState(() => _error = 'Could not sign in: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _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<AppState>().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<void> _disconnect() async {
setState(() => _busy = true);
try {
await context.read<AppState>().disconnectCloud();
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _chooseFolder() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
);
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await context.read<AppState>().syncNow();
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@ -90,153 +21,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<AppState>().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<AppState>().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<AppState>().setStaleLockMinutes(value.round()),
),
Text('${appState.staleLockMinutes} minute(s)'),
],
),
),
const SizedBox(height: 16),
@ -260,93 +64,3 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
}
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<FormState>();
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'),
),
],
);
}
}

View file

@ -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<AppState>();
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<ThemeMode>(
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<AppState>().setThemeMode(selection.first),
),
],
),
),
),
],
),
);
}
}

View file

@ -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<void> _captureReceipt(BuildContext context) async {
final source = await chooseImageSource(context);
if (source == null || !context.mounted) return;
@override
State<VehicleDetailScreen> createState() => _VehicleDetailScreenState();
}
final keepMaxQuality = context.read<AppState>().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<VehicleDetailScreen> {
late final PageController _pageController;
@override
void initState() {
super.initState();
final vehicles = context.read<AppState>().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<AppState>().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<bool> _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
final stateName = usStateNames[stateCode] ?? stateCode;
final proceed = await showDialog<bool>(
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<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>(
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<AppState>().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'),
),

View file

@ -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<void> 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<void> 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<void> 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<void> 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<void> 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<FuelEntry> 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

View file

@ -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<void> 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<List<String>> 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<String>().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<void> deleteReceiptImageFile(String? path) async {
if (path == null) return;
final file = File(path);

View file

@ -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<FuelEntry> 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<ReportEntry> 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<DateTime, List<ReportEntry>> get entriesByMonth {
final map = <DateTime, List<ReportEntry>>{};
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<MonthlyTotal> 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));

View file

@ -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<String, Uint8List> bytesByEntryId;
final List<FuelEntry> 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<ReceiptImageLoadResult> loadReceiptImageBytes({
required FuelReport report,
required CloudStorageProvider? activeProvider,
}) async {
final bytesByEntryId = <String, Uint8List>{};
final failedEntries = <FuelEntry>[];
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);
}

View file

@ -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<Uint8List> 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<Uint8List> buildFuelReportPdf(
FuelReport report, {
bool includeLineItems = false,
bool includeReceiptImages = false,
Map<String, Uint8List> 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<pw.Widget> _buildBody(
FuelReport report, {
required bool includeLineItems,
required bool includeReceiptImages,
required Map<String, Uint8List> 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.Widget>[
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 = '''
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M12,8 L76,8 L79,22 L73,33 L83,47 L78,58 L84,67 L86,89 L77,89 L77,70 L20,70 L20,30 Z" fill="#639922"/>
</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<pw.Widget> _buildLineItemsAndImagesSections(
FuelReport report, {
required bool includeLineItems,
required bool includeReceiptImages,
required Map<String, Uint8List> receiptImages,
}) {
final showItemNumbers = includeLineItems && includeReceiptImages;
final itemNumbers = <String, int>{};
var nextNumber = 1;
for (final row in report.rows) {
for (final entry in row.entries) {
itemNumbers[entry.id] = nextNumber++;
}
}
final widgets = <pw.Widget>[];
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<String, int> 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<String, Uint8List> receiptImages, {
required bool showItemNumbers,
required Map<String, int> 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-<start>-<end>.pdf`, e.g. `fuel-report-20260101-20260331.pdf`.
String fuelReportFileName(FuelReport report) {
final fmt = DateFormat('yyyyMMdd');

View file

@ -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');
}

View file

@ -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<Color> 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,

View file

@ -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)),
],
);
}
}

View file

@ -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<ImageSource?> 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<ImageSource?> chooseImageSource(
BuildContext context, {
String? heading,
VoidCallback? onInfoTap,
}) {
return showModalBottomSheet<ImageSource>(
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'),

View file

@ -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<void> captureReceiptForVehicle(BuildContext context, String vehicleId) async {
final source = await chooseImageSource(context, heading: 'Receipt From');
if (source == null || !context.mounted) return;
final keepMaxQuality = context.read<AppState>().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<bool> _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
final stateName = usStateNames[stateCode] ?? stateCode;
final proceed = await showDialog<bool>(
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;
}

View file

@ -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);
}
}