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

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