first commit
This commit is contained in:
commit
e630f2a3ec
80 changed files with 3889 additions and 0 deletions
153
lib/screens/add_edit_vehicle_screen.dart
Normal file
153
lib/screens/add_edit_vehicle_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
224
lib/screens/confirm_fuel_entry_screen.dart
Normal file
224
lib/screens/confirm_fuel_entry_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
lib/screens/home_screen.dart
Normal file
106
lib/screens/home_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
lib/screens/receipt_image_screen.dart
Normal file
27
lib/screens/receipt_image_screen.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/screens/settings_screen.dart
Normal file
105
lib/screens/settings_screen.dart
Normal 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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
188
lib/screens/vehicle_detail_screen.dart
Normal file
188
lib/screens/vehicle_detail_screen.dart
Normal 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue