import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import '../models/vehicle.dart'; import '../services/app_state.dart'; import '../services/ocr_service.dart'; import '../services/vin_parser.dart'; 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. [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, this.promptToCreateForReceipt = false, }); @override State createState() => _AddEditVehicleScreenState(); } class _AddEditVehicleScreenState extends State { final _formKey = GlobalKey(); late final TextEditingController _nicknameController; late final TextEditingController _vinController; bool _saving = false; bool _scanningVin = false; bool _checkingDuplicateVin = false; String? _error; bool get _isEditing => widget.vehicle != null; @override void initState() { super.initState(); _nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? ''); _vinController = TextEditingController(text: widget.vehicle?.vin ?? ''); if (widget.promptToCreateForReceipt) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; showDialog( 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 void dispose() { _nicknameController.dispose(); _vinController.dispose(); super.dispose(); } void _showVinLocationHelp() { showDialog( 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 _scanVin() async { final source = await chooseImageSource( context, heading: 'VIN From', onInfoTap: _showVinLocationHelp, ); if (source == null || !mounted) return; final picker = ImagePicker(); XFile? photo; try { photo = await picker.pickImage(source: source, imageQuality: 85); } catch (e) { if (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; setState(() => _scanningVin = true); final ocrService = OcrService(); String recognizedText = ''; try { recognizedText = await ocrService.recognizeText(File(photo.path)); } catch (_) { recognizedText = ''; } finally { ocrService.dispose(); } if (!mounted) return; final vin = VinParser.parse(recognizedText); setState(() => _scanningVin = false); if (vin != null) { _vinController.text = vin; await _checkForExistingVin(); } else if (mounted) { ScaffoldMessenger.of(context).showSnackBar( 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( 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 /// id while editing, same as [AppState.updateVehicle]'s check, so editing /// a vehicle without touching its VIN doesn't flag itself. String? _validateVin(String? value) { final vin = value?.trim() ?? ''; if (vin.isEmpty) return 'Required'; final existing = context.read().vehicleByVin(vin); if (existing != null && existing.id != widget.vehicle?.id) { return 'A vehicle with VIN "$vin" already exists.'; } return null; } /// Only relevant when adding a new vehicle — editing one already starts /// with its own VIN pre-filled, which would trivially "match itself". /// Guarded against overlapping calls so rapid edits (or the scan-fill /// path firing right after `onChanged`) can't stack multiple dialogs. Future _checkForExistingVin() async { if (_isEditing || _checkingDuplicateVin) return; final vin = _vinController.text.trim(); if (vin.isEmpty) return; final existing = context.read().vehicleByVin(vin); if (existing == null) return; _checkingDuplicateVin = true; final openExisting = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Vehicle already exists'), content: Text('Vehicle with the VIN $vin already exists, would you like to open that vehicle?'), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')), TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')), ], ), ); _checkingDuplicateVin = false; if (!mounted) return; if (openExisting != true) { // Declining to open the existing vehicle leaves them here with a // still-duplicate VIN — surface that on the field itself rather than // only catching it later at Save. _formKey.currentState?.validate(); return; } Navigator.of(context) ..pop() ..push(MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: existing.id))); } Future _save() async { if (!_formKey.currentState!.validate()) return; setState(() { _saving = true; _error = null; }); final vin = _vinController.text.trim(); final nickname = _nicknameController.text.trim(); final appState = context.read(); try { if (_isEditing) { await appState.updateVehicle(widget.vehicle!.copyWith( vin: vin, nickname: nickname.isEmpty ? null : nickname, clearNickname: nickname.isEmpty, )); } else { await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname); } 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 { if (mounted) setState(() => _saving = false); } } Future _confirmDelete() async { final confirmed = await showDialog( 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!.displayLabel}. 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().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: _nicknameController, decoration: const InputDecoration( labelText: 'Nickname (optional)', hintText: 'e.g. Mom\'s Car, Red Ford F-150', ), textCapitalization: TextCapitalization.words, ), const SizedBox(height: 12), TextFormField( controller: _vinController, decoration: InputDecoration( labelText: 'VIN *', hintText: 'Vehicle Identification Number', helperText: 'Required — tap the camera to scan it from a photo', suffixIcon: _scanningVin ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ) : IconButton( icon: const Icon(Icons.camera_alt_outlined), tooltip: 'Scan VIN from a photo', onPressed: _scanVin, ), ), textCapitalization: TextCapitalization.characters, validator: _validateVin, onChanged: _isEditing ? null : (_) => _checkForExistingVin(), ), if (_error != null) ...[ const SizedBox(height: 12), Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ], const SizedBox(height: 24), FilledButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) : Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), ), ], ), ), ); } }