Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
|
|
@ -1,8 +1,14 @@
|
|||
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';
|
||||
|
||||
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
||||
/// omit it to create a new one.
|
||||
|
|
@ -17,52 +23,107 @@ class AddEditVehicleScreen extends StatefulWidget {
|
|||
|
||||
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;
|
||||
late final TextEditingController _nicknameController;
|
||||
late final TextEditingController _vinController;
|
||||
|
||||
bool _saving = false;
|
||||
bool _scanningVin = false;
|
||||
String? _error;
|
||||
|
||||
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 ?? '');
|
||||
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
|
||||
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_makeController.dispose();
|
||||
_modelController.dispose();
|
||||
_colorController.dispose();
|
||||
_plateController.dispose();
|
||||
_nicknameController.dispose();
|
||||
_vinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _scanVin() async {
|
||||
final source = await chooseImageSource(context);
|
||||
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;
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Couldn't read a VIN from that photo. Please enter it manually."),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
final nickname = _nicknameController.text.trim();
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
try {
|
||||
if (_isEditing) {
|
||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||
vin: _vinController.text.trim(),
|
||||
nickname: nickname.isEmpty ? null : nickname,
|
||||
clearNickname: nickname.isEmpty,
|
||||
));
|
||||
} else {
|
||||
await appState.addVehicle(
|
||||
vin: _vinController.text.trim(),
|
||||
nickname: nickname.isEmpty ? null : nickname,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} on DuplicateVinException catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete() async {
|
||||
|
|
@ -72,7 +133,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
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.',
|
||||
'${widget.vehicle!.displayLabel}. This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
|
||||
|
|
@ -114,36 +175,48 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _makeController,
|
||||
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'),
|
||||
controller: _nicknameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nickname (optional)',
|
||||
hintText: 'e.g. Mom\'s Car, Red Ford F-150',
|
||||
),
|
||||
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'),
|
||||
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: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue