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 'add_edit_vehicle_screen.dart'; import 'confirm_fuel_entry_screen.dart'; import 'receipt_image_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 { final String vehicleId; const VehicleDetailScreen({super.key, required this.vehicleId}); Future _captureReceipt(BuildContext context) async { final source = await chooseImageSource(context); if (source == null || !context.mounted) return; final keepMaxQuality = context.read().keepMaxQualityReceiptPhotos; final picker = ImagePicker(); XFile? photo; try { photo = await picker.pickImage( source: source, imageQuality: keepMaxQuality ? null : receiptImageQuality, maxWidth: keepMaxQuality ? null : receiptImageMaxDimension, maxHeight: keepMaxQuality ? null : receiptImageMaxDimension, ); } catch (e) { if (context.mounted) { final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library'; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Could not open $sourceLabel: $e')), ); } return; } if (photo == null) return; final imageFile = File(photo.path); if (!context.mounted) return; showDialog( context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator()), ); final ocrService = OcrService(); String recognizedText = ''; try { recognizedText = await ocrService.recognizeText(imageFile); } catch (_) { recognizedText = ''; } finally { ocrService.dispose(); } final parsed = ReceiptParser.parse(recognizedText); if (!context.mounted) return; Navigator.of(context).pop(); // close the OCR loading spinner final state = parsed.state; if (state != null && state != 'MO') { final proceed = await _confirmNonMissouriPurchase(context, state); if (!proceed || !context.mounted) return; } if (context.mounted) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => ConfirmFuelEntryScreen( vehicleId: vehicleId, imageFile: imageFile, parsed: parsed, ), ), ); } } /// Missouri's fuel tax refund only applies to fuel bought in Missouri — /// warn if the receipt's address is somewhere else, and let the user /// decide whether to log it anyway (e.g. it might still be worth /// tracking for other reasons even if it won't qualify for a refund). Future _confirmNonMissouriPurchase(BuildContext context, String stateCode) async { final stateName = usStateNames[stateCode] ?? stateCode; final proceed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Non-Missouri Purchase'), content: Text( 'This receipt looks like it\'s from a gas station in $stateName. ' 'The Missouri fuel tax refund only applies to fuel purchased in ' 'Missouri.\n\nDo you still want to add this entry?', ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')), FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')), ], ), ); return proceed ?? false; } Future _deleteEntry(BuildContext context, FuelEntry entry) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete fuel entry?'), content: const Text('This will remove the entry and its receipt photo.'), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), TextButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Delete', style: TextStyle(color: Colors.red)), ), ], ), ); if (confirmed == true && context.mounted) { await context.read().deleteFuelEntry(entry.id); } } Widget _buildReceiptLeading(BuildContext context, FuelEntry entry) { if (entry.receiptImagePath != null) { return GestureDetector( onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath), ), ), child: ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.file( File(entry.receiptImagePath!), width: 48, height: 48, fit: BoxFit.cover, ), ), ); } if (entry.isReceiptUploadedToDrive) { return GestureDetector( onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId), ), ), child: const CircleAvatar(child: Icon(Icons.cloud_outlined)), ); } return const CircleAvatar(child: Icon(Icons.receipt_long)); } @override Widget build(BuildContext context) { final appState = context.watch(); 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.displayLabel), 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: _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), ), ), ); }, ), ), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: () => _captureReceipt(context), icon: const Icon(Icons.camera_alt_outlined), label: const Text('Log Fuel Receipt'), ), ); } }