MO-Fuel-Tax-Back/lib/screens/add_edit_vehicle_screen.dart

378 lines
13 KiB
Dart

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<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
}
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
final _formKey = GlobalKey<FormState>();
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<void>(
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<void>(
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<void> _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<void>(
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<AppState>().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<void> _checkForExistingVin() async {
if (_isEditing || _checkingDuplicateVin) return;
final vin = _vinController.text.trim();
if (vin.isEmpty) return;
final existing = context.read<AppState>().vehicleByVin(vin);
if (existing == null) return;
_checkingDuplicateVin = true;
final openExisting = await showDialog<bool>(
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<void> _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<AppState>();
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<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!.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<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: _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'),
),
],
),
),
);
}
}