first commit

This commit is contained in:
Courtney Arnold 2026-08-08 08:22:14 -05:00
commit e630f2a3ec
80 changed files with 3889 additions and 0 deletions

48
lib/main.dart Normal file
View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/home_screen.dart';
import 'services/app_state.dart';
void main() {
runApp(const FuelTaxTrackerApp());
}
class FuelTaxTrackerApp extends StatelessWidget {
const FuelTaxTrackerApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppState()..init(),
child: MaterialApp(
title: 'Fuel Tax Tracker',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
useMaterial3: true,
),
home: const AppRoot(),
),
);
}
}
class AppRoot extends StatelessWidget {
const AppRoot({super.key});
@override
Widget build(BuildContext context) {
return Consumer<AppState>(
builder: (context, appState, _) {
if (appState.isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return const HomeScreen();
},
);
}
}

View file

@ -0,0 +1,39 @@
class FuelEntry {
final String id;
final String vehicleId;
final DateTime date;
final double gallons;
final double pricePerGallon;
final double totalCost;
final String? receiptImagePath;
FuelEntry({
required this.id,
required this.vehicleId,
required this.date,
required this.gallons,
required this.pricePerGallon,
required this.totalCost,
this.receiptImagePath,
});
Map<String, dynamic> toJson() => {
'id': id,
'vehicleId': vehicleId,
'date': date.toIso8601String(),
'gallons': gallons,
'pricePerGallon': pricePerGallon,
'totalCost': totalCost,
'receiptImagePath': receiptImagePath,
};
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
id: json['id'] as String,
vehicleId: json['vehicleId'] as String,
date: DateTime.parse(json['date'] as String),
gallons: (json['gallons'] as num).toDouble(),
pricePerGallon: (json['pricePerGallon'] as num).toDouble(),
totalCost: (json['totalCost'] as num).toDouble(),
receiptImagePath: json['receiptImagePath'] as String?,
);
}

48
lib/models/vehicle.dart Normal file
View file

@ -0,0 +1,48 @@
class Vehicle {
final String id;
final String make;
final String model;
final String color;
final String licensePlate;
Vehicle({
required this.id,
required this.make,
required this.model,
required this.color,
required this.licensePlate,
});
String get displayName => '$color $make $model ($licensePlate)';
Vehicle copyWith({
String? make,
String? model,
String? color,
String? licensePlate,
}) {
return Vehicle(
id: id,
make: make ?? this.make,
model: model ?? this.model,
color: color ?? this.color,
licensePlate: licensePlate ?? this.licensePlate,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'make': make,
'model': model,
'color': color,
'licensePlate': licensePlate,
};
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
id: json['id'] as String,
make: json['make'] as String,
model: json['model'] as String,
color: json['color'] as String,
licensePlate: json['licensePlate'] as String,
);
}

View file

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
/// omit it to create a new one.
class AddEditVehicleScreen extends StatefulWidget {
final Vehicle? vehicle;
const AddEditVehicleScreen({super.key, this.vehicle});
@override
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
}
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _makeController;
late final TextEditingController _modelController;
late final TextEditingController _colorController;
late final TextEditingController _plateController;
bool get _isEditing => widget.vehicle != null;
@override
void initState() {
super.initState();
_makeController = TextEditingController(text: widget.vehicle?.make ?? '');
_modelController = TextEditingController(text: widget.vehicle?.model ?? '');
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? '');
}
@override
void dispose() {
_makeController.dispose();
_modelController.dispose();
_colorController.dispose();
_plateController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final appState = context.read<AppState>();
if (_isEditing) {
await appState.updateVehicle(widget.vehicle!.copyWith(
make: _makeController.text.trim(),
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
));
} else {
await appState.addVehicle(
make: _makeController.text.trim(),
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
);
}
if (mounted) Navigator.of(context).pop();
}
Future<void> _confirmDelete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete vehicle?'),
content: Text(
'This will also delete all fuel entries and receipt photos logged for '
'${widget.vehicle!.displayName}. This cannot be undone.',
),
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 && mounted) {
await context.read<AppState>().deleteVehicle(widget.vehicle!.id);
if (mounted) {
Navigator.of(context)
..pop()
..pop();
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Edit Vehicle' : 'Add Vehicle'),
actions: [
if (_isEditing)
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete vehicle',
onPressed: _confirmDelete,
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _makeController,
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _modelController,
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _colorController,
decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _plateController,
decoration: const InputDecoration(labelText: 'License Plate'),
textCapitalization: TextCapitalization.characters,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _save,
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
),
],
),
),
);
}
}

View file

@ -0,0 +1,224 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/receipt_parser.dart';
import 'receipt_image_screen.dart';
/// Shown right after a receipt photo is captured and OCR'd. Pre-fills
/// whatever the parser found and lets the user review/correct every field
/// before it's written to the data file, since OCR on a printed receipt is
/// usually good but not perfect.
class ConfirmFuelEntryScreen extends StatefulWidget {
final String vehicleId;
final File imageFile;
final ParsedReceipt parsed;
const ConfirmFuelEntryScreen({
super.key,
required this.vehicleId,
required this.imageFile,
required this.parsed,
});
@override
State<ConfirmFuelEntryScreen> createState() => _ConfirmFuelEntryScreenState();
}
class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _gallonsController;
late final TextEditingController _priceController;
late final TextEditingController _totalController;
DateTime _date = DateTime.now();
bool _saving = false;
@override
void initState() {
super.initState();
_gallonsController = TextEditingController(
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
);
_priceController = TextEditingController(
text: widget.parsed.pricePerGallon?.toStringAsFixed(3) ?? '',
);
_totalController = TextEditingController(
text: widget.parsed.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 {
final pickedDate = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)),
lastDate: DateTime.now(),
);
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>().addFuelEntry(
vehicleId: widget.vehicleId,
date: _date,
gallons: double.parse(_gallonsController.text),
pricePerGallon: double.parse(_priceController.text),
totalCost: double.parse(_totalController.text),
receiptImage: widget.imageFile,
);
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();
final nothingParsed = widget.parsed.gallons == null &&
widget.parsed.pricePerGallon == null &&
widget.parsed.totalCost == null;
return Scaffold(
appBar: AppBar(title: const Text('Confirm Fuel Entry')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(widget.imageFile, height: 180, fit: BoxFit.cover, width: double.infinity),
),
),
const SizedBox(height: 8),
Text(
'Tap the photo to view it full-screen.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
if (nothingParsed) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Theme.of(context).colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
"Couldn't automatically read this receipt. Please enter the values manually.",
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
),
),
],
),
),
],
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 Entry'),
),
],
),
),
);
}
}

View file

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import 'add_edit_vehicle_screen.dart';
import 'settings_screen.dart';
import 'vehicle_detail_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final vehicles = appState.vehicles;
return Scaffold(
appBar: AppBar(
title: const Text('My Vehicles'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
],
),
body: vehicles.isEmpty
? const _EmptyState()
: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: vehicles.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final vehicle = vehicles[index];
final gallons = appState.totalGallonsForVehicle(vehicle.id);
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'),
),
);
}
}
class _VehicleCard extends StatelessWidget {
final Vehicle vehicle;
final double totalGallons;
const _VehicleCard({required this.vehicle, required this.totalGallons});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'),
),
title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'),
subtitle: Text(
'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged',
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_shipping_outlined, size: 64, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
Text(
'No vehicles yet. Add a vehicle to start logging fuel receipts.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}

View file

@ -0,0 +1,27 @@
import 'dart:io';
import 'package:flutter/material.dart';
/// Full-screen, pinch-zoomable view of a saved receipt photo.
class ReceiptImageScreen extends StatelessWidget {
final String imagePath;
const ReceiptImageScreen({super.key, required this.imagePath});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: const Text('Receipt'),
),
body: Center(
child: InteractiveViewer(
child: Image.file(File(imagePath)),
),
),
);
}
}

View file

@ -0,0 +1,105 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _changing = false;
String? _error;
Future<void> _chooseFolder() async {
setState(() => _error = null);
final selectedPath = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Choose a folder for receipts and data',
);
if (selectedPath == null || !mounted) return;
setState(() => _changing = true);
try {
await context.read<AppState>().changeSaveDirectory(selectedPath);
} catch (e) {
setState(() => _error = 'Could not switch to that folder: $e');
} finally {
if (mounted) setState(() => _changing = false);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final saveDir = appState.storage.saveDirectory.path;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Save Location', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Receipt photos and the data file are stored here:',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 4),
SelectableText(
saveDir,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
const SizedBox(height: 16),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
FilledButton.icon(
onPressed: _changing ? null : _chooseFolder,
icon: _changing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.folder_open),
label: const Text('Choose Folder'),
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Summary', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text('${appState.vehicles.length} vehicle(s)'),
Text('${appState.fuelEntries.length} fuel entries'),
Text('${appState.totalGallonsAllVehicles.toStringAsFixed(3)} total gallons logged'),
],
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,188 @@
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 'add_edit_vehicle_screen.dart';
import 'confirm_fuel_entry_screen.dart';
import 'receipt_image_screen.dart';
class VehicleDetailScreen extends StatelessWidget {
final String vehicleId;
const VehicleDetailScreen({super.key, required this.vehicleId});
Future<void> _captureReceipt(BuildContext context) async {
final picker = ImagePicker();
XFile? photo;
try {
photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open camera: $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) {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ConfirmFuelEntryScreen(
vehicleId: vehicleId,
imageFile: imageFile,
parsed: parsed,
),
),
);
}
}
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);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final vehicle = appState.vehicleById(vehicleId);
if (vehicle == null) {
return const Scaffold(body: Center(child: Text('Vehicle not found')));
}
final entries = appState.entriesForVehicle(vehicleId);
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
final dateFormat = DateFormat.yMMMd().add_jm();
final currencyFormat = NumberFormat.simpleCurrency();
return Scaffold(
appBar: AppBar(
title: Text(vehicle.displayName),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit vehicle',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AddEditVehicleScreen(vehicle: vehicle)),
),
),
],
),
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,
),
],
),
),
Expanded(
child: entries.isEmpty
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final entry = entries[index];
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: entry.receiptImagePath != null
? GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(
File(entry.receiptImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
)
: const CircleAvatar(child: Icon(Icons.receipt_long)),
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),
),
),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _captureReceipt(context),
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('Log Fuel Receipt'),
),
);
}
}

129
lib/services/app_state.dart Normal file
View file

@ -0,0 +1,129 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'storage_service.dart';
/// Single source of truth for the app's in-memory data (vehicles + fuel
/// entries), backed by [StorageService] for persistence. Screens read from
/// this via Provider and call its mutating methods, which take care of
/// writing through to disk and notifying listeners.
class AppState extends ChangeNotifier {
final StorageService storage = StorageService();
final _uuid = const Uuid();
List<Vehicle> vehicles = [];
List<FuelEntry> fuelEntries = [];
bool isLoading = true;
Future<void> init() async {
await storage.init();
final data = await storage.loadData();
vehicles = data.vehicles;
fuelEntries = data.entries;
isLoading = false;
notifyListeners();
}
Future<void> addVehicle({
required String make,
required String model,
required String color,
required String licensePlate,
}) async {
vehicles.add(Vehicle(
id: _uuid.v4(),
make: make,
model: model,
color: color,
licensePlate: licensePlate,
));
await _persist();
}
Future<void> updateVehicle(Vehicle updated) async {
final index = vehicles.indexWhere((v) => v.id == updated.id);
if (index != -1) {
vehicles[index] = updated;
await _persist();
}
}
Future<void> deleteVehicle(String vehicleId) async {
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) {
await storage.deleteReceiptImage(entry.receiptImagePath);
}
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId);
vehicles.removeWhere((v) => v.id == vehicleId);
await _persist();
}
Future<FuelEntry> addFuelEntry({
required String vehicleId,
required DateTime date,
required double gallons,
required double pricePerGallon,
required double totalCost,
File? receiptImage,
}) async {
final id = _uuid.v4();
String? storedImagePath;
if (receiptImage != null) {
storedImagePath = await storage.storeReceiptImage(receiptImage, id);
}
final entry = FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
receiptImagePath: storedImagePath,
);
fuelEntries.add(entry);
await _persist();
return entry;
}
Future<void> deleteFuelEntry(String entryId) async {
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
await storage.deleteReceiptImage(entry.receiptImagePath);
fuelEntries.removeWhere((e) => e.id == entryId);
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));
return list;
}
double totalGallonsForVehicle(String vehicleId) => fuelEntries
.where((e) => e.vehicleId == vehicleId)
.fold(0.0, (sum, e) => sum + e.gallons);
double get totalGallonsAllVehicles =>
fuelEntries.fold(0.0, (sum, e) => sum + e.gallons);
Vehicle? vehicleById(String id) {
try {
return vehicles.firstWhere((v) => v.id == id);
} catch (_) {
return null;
}
}
Future<void> changeSaveDirectory(String newPath) async {
await storage.setSaveDirectory(newPath);
notifyListeners();
}
Future<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
notifyListeners();
}
}

View file

@ -0,0 +1,21 @@
import 'dart:io';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
/// Thin wrapper around ML Kit's on-device text recognizer so the rest of
/// the app depends on a single, easily-swappable entry point rather than
/// the ML Kit API directly.
class OcrService {
final TextRecognizer _recognizer =
TextRecognizer(script: TextRecognitionScript.latin);
Future<String> recognizeText(File imageFile) async {
final inputImage = InputImage.fromFile(imageFile);
final result = await _recognizer.processImage(inputImage);
return result.text;
}
void dispose() {
_recognizer.close();
}
}

View file

@ -0,0 +1,105 @@
/// Best-effort values pulled out of OCR'd receipt text. Any field can be
/// null if it couldn't be found, and the confirm screen lets the user fill
/// in or correct whatever the parser got wrong.
class ParsedReceipt {
final double? gallons;
final double? pricePerGallon;
final double? totalCost;
final String rawText;
ParsedReceipt({
this.gallons,
this.pricePerGallon,
this.totalCost,
required this.rawText,
});
}
/// Parses free-form OCR text from a fuel receipt looking for the number of
/// gallons pumped, the price per gallon, and the total amount charged.
///
/// Fuel receipts vary a lot between stations, so this uses a handful of
/// label-based regexes rather than assuming a fixed layout, and falls back
/// to deriving a missing value from the other two when exactly one is
/// missing (total = gallons * price, etc).
class ReceiptParser {
static final _gallonsPatterns = [
RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false),
];
static final _pricePerGallonPatterns = [
RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
caseSensitive: false),
RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
caseSensitive: false),
RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
caseSensitive: false),
];
static final _totalPatterns = [
RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'(?<!SUB)\bTOTAL\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2})',
caseSensitive: false),
];
static ParsedReceipt parse(String text) {
final normalized = text.replaceAll(',', '');
final gallons = _firstMatch(_gallonsPatterns, normalized);
final pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
var totalCost = _firstMatch(_totalPatterns, normalized);
final derivedTotal = _deriveMissingValue(
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
);
return ParsedReceipt(
gallons: derivedTotal.gallons,
pricePerGallon: derivedTotal.pricePerGallon,
totalCost: derivedTotal.totalCost,
rawText: text,
);
}
static double? _firstMatch(List<RegExp> patterns, String text) {
for (final pattern in patterns) {
final match = pattern.firstMatch(text);
if (match != null) {
return double.tryParse(match.group(1)!);
}
}
return null;
}
static ({double? gallons, double? pricePerGallon, double? totalCost})
_deriveMissingValue({
required double? gallons,
required double? pricePerGallon,
required double? totalCost,
}) {
final missingCount =
[gallons, pricePerGallon, totalCost].where((v) => v == null).length;
// Only safe to derive when exactly one of the three is missing.
if (missingCount != 1) {
return (gallons: gallons, pricePerGallon: pricePerGallon, totalCost: totalCost);
}
if (totalCost == null && gallons != null && pricePerGallon != null) {
totalCost = double.parse((gallons * pricePerGallon).toStringAsFixed(2));
} else if (gallons == null && totalCost != null && pricePerGallon != null && pricePerGallon > 0) {
gallons = double.parse((totalCost / pricePerGallon).toStringAsFixed(3));
} else if (pricePerGallon == null && totalCost != null && gallons != null && gallons > 0) {
pricePerGallon = double.parse((totalCost / gallons).toStringAsFixed(3));
}
return (gallons: gallons, pricePerGallon: pricePerGallon, totalCost: totalCost);
}
}

View file

@ -0,0 +1,128 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
/// Owns the on-disk layout for the app: a `data.json` file holding vehicles
/// and fuel entries, plus a `receipts/` subfolder holding receipt photos.
/// The parent directory containing both is user-configurable (see
/// [setSaveDirectory]) and persisted across launches via SharedPreferences.
class StorageService {
static const _prefsKey = 'save_directory_path';
static const _dataFileName = 'fuel_tax_data.json';
static const _receiptsFolderName = 'receipts';
late Directory _saveDirectory;
Directory get saveDirectory => _saveDirectory;
Directory get receiptsDirectory =>
Directory(p.join(_saveDirectory.path, _receiptsFolderName));
File get _dataFile => File(p.join(_saveDirectory.path, _dataFileName));
/// Must be called once before any other method. Loads the previously
/// chosen save directory, falling back to the app's own documents
/// directory the first time the app runs.
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final storedPath = prefs.getString(_prefsKey);
if (storedPath != null && await Directory(storedPath).exists()) {
_saveDirectory = Directory(storedPath);
} else {
final docsDir = await getApplicationDocumentsDirectory();
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
}
await _saveDirectory.create(recursive: true);
await receiptsDirectory.create(recursive: true);
}
Future<({List<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async {
if (!await _dataFile.exists()) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
final raw = await _dataFile.readAsString();
if (raw.trim().isEmpty) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
final json = jsonDecode(raw) as Map<String, dynamic>;
final vehicles = (json['vehicles'] as List<dynamic>? ?? [])
.map((v) => Vehicle.fromJson(v as Map<String, dynamic>))
.toList();
final entries = (json['fuelEntries'] as List<dynamic>? ?? [])
.map((e) => FuelEntry.fromJson(e as Map<String, dynamic>))
.toList();
return (vehicles: vehicles, entries: entries);
}
Future<void> saveData({
required List<Vehicle> vehicles,
required List<FuelEntry> entries,
}) async {
final json = {
'vehicles': vehicles.map((v) => v.toJson()).toList(),
'fuelEntries': entries.map((e) => e.toJson()).toList(),
};
await _dataFile.writeAsString(const JsonEncoder.withIndent(' ').convert(json));
}
/// Copies a captured receipt image into the receipts folder and returns
/// the path it was stored at.
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
final ext = p.extension(sourceImage.path);
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
final copied = await sourceImage.copy(destPath);
return copied.path;
}
Future<void> deleteReceiptImage(String? path) async {
if (path == null) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
/// Changes where the data file and receipt images live, moving any
/// existing data/images from the old location into the new one.
Future<void> setSaveDirectory(String newPath) async {
final newDir = Directory(newPath);
await newDir.create(recursive: true);
final newReceiptsDir = Directory(p.join(newPath, _receiptsFolderName));
await newReceiptsDir.create(recursive: true);
final oldDataFile = _dataFile;
final oldReceiptsDir = receiptsDirectory;
if (await oldDataFile.exists() &&
p.equals(oldDataFile.path, p.join(newPath, _dataFileName)) == false) {
await oldDataFile.copy(p.join(newPath, _dataFileName));
}
if (await oldReceiptsDir.exists()) {
await for (final entity in oldReceiptsDir.list()) {
if (entity is File) {
final destPath = p.join(newReceiptsDir.path, p.basename(entity.path));
if (!p.equals(entity.path, destPath)) {
await entity.copy(destPath);
}
}
}
}
_saveDirectory = newDir;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, newPath);
}
}