import 'dart:async'; import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:mileograph_flutter/components/calculator/calculator.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; class NewEntryPage extends StatefulWidget { const NewEntryPage({super.key, this.editLegId}); final int? editLegId; @override State createState() => _NewEntryPageState(); } class _NewEntryPageState extends State { static const _draftPrefsKey = 'new_entry_draft'; static const _draftListPrefsKey = 'new_entry_drafts_list'; final _formKey = GlobalKey(); DateTime _selectedDate = DateTime.now(); TimeOfDay _selectedTime = TimeOfDay.now(); final _startController = TextEditingController(); final _endController = TextEditingController(); final _headcodeController = TextEditingController(); final _notesController = TextEditingController(); final _mileageController = TextEditingController(); final _networkController = TextEditingController(); bool _submitting = false; bool _useManualMileage = false; RouteResult? _routeResult; final List<_TractionItem> _tractionItems = [_TractionItem.marker()]; int? _selectedTripId; bool _restoringDraft = false; bool _loadingEdit = false; String? _loadError; Map? _lastSubmittedSnapshot; Map? _loadedDraftSnapshot; final DeepCollectionEquality _snapshotEquality = const DeepCollectionEquality(); String? _activeDraftId; bool get _isEditing => widget.editLegId != null; bool get _draftPersistenceEnabled => false; // legacy single draft disabled in favor of draft list @override void initState() { super.initState(); NavigationGuard.register(_handleExitIntent); // legacy single-draft auto-save listeners removed in favor of explicit multi-draft flow Future.microtask(() { if (!mounted) return; final data = context.read(); data.fetchClassList(); data.fetchTrips(); if (_draftPersistenceEnabled) { _loadDraft(); } if (_isEditing && widget.editLegId != null) { _loadLegForEdit(widget.editLegId!); } }); } @override void dispose() { NavigationGuard.unregister(_handleExitIntent); _startController.dispose(); _endController.dispose(); _headcodeController.dispose(); _notesController.dispose(); _mileageController.dispose(); _networkController.dispose(); super.dispose(); } Widget _buildTripSelector(BuildContext context) { final trips = context.watch().tripList; final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId)); final tripIds = sorted.map((t) => t.tripId).toSet(); final selectedValue = (_selectedTripId != null && tripIds.contains(_selectedTripId)) ? _selectedTripId : null; return Row( children: [ Expanded( child: DropdownButtonFormField( value: selectedValue, decoration: const InputDecoration( labelText: 'Trip', border: OutlineInputBorder(), ), items: [ const DropdownMenuItem(value: null, child: Text('No trip')), ...sorted.map( (t) => DropdownMenuItem( value: t.tripId, child: Text(t.tripName), ), ), ], onChanged: (val) { setState(() => _selectedTripId = val); _saveDraft(); }, ), ), const SizedBox(width: 8), ElevatedButton.icon( onPressed: () => _showAddTripDialog(context), icon: const Icon(Icons.add), label: const Text('New Trip'), ), ], ); } Future _showAddTripDialog(BuildContext context) async { final controller = TextEditingController(); final result = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('New Trip'), content: TextField( controller: controller, decoration: const InputDecoration(labelText: 'Trip name'), autofocus: true, ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), ElevatedButton( onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()), child: const Text('Add'), ), ], ), ); if (!context.mounted) { controller.dispose(); return; } if (result != null && result.isNotEmpty) { final api = context.read(); final data = context.read(); final messenger = ScaffoldMessenger.maybeOf(context); try { await api.put('/trips/new', {"trip_name": result}); await data.fetchTrips(); if (!context.mounted) return; final trips = data.tripList; final match = trips.firstWhere( (t) => t.tripName == result, orElse: () => trips.isNotEmpty ? trips.first : TripSummary(tripId: 0, tripName: result, tripMileage: 0), ); setState(() => _selectedTripId = match.tripId); _saveDraft(); } catch (e) { if (!context.mounted) return; messenger?.showSnackBar( SnackBar(content: Text('Failed to add trip: $e')), ); } finally { controller.dispose(); } } else { controller.dispose(); } } Future _openCalculator() async { final result = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => _CalculatorPickerPage( onResult: (res) => Navigator.of(context).pop(res), ), ), ); if (result != null) { setState(() { _routeResult = result; _mileageController.text = result.distance.toStringAsFixed(2); _useManualMileage = false; }); _saveDraft(); } } Future _openTractionPicker() async { final selectedKeys = _tractionItems .where((e) => !e.isMarker && e.loco != null) .map((e) => '${e.loco!.locoClass}-${e.loco!.number}') .toSet(); await Navigator.of(context).push( MaterialPageRoute( builder: (_) => TractionPage( selectionMode: true, selectedKeys: selectedKeys, onSelect: (loco) { final markerIndex = _tractionItems.indexWhere( (element) => element.isMarker, ); final key = '${loco.locoClass}-${loco.number}'; setState(() { final existingIndex = _tractionItems.indexWhere( (e) => !e.isMarker && e.loco != null && '${e.loco!.locoClass}-${e.loco!.number}' == key, ); if (existingIndex != -1) { _tractionItems.removeAt(existingIndex); } else { _tractionItems.insert( markerIndex, _TractionItem(loco: loco, powering: true), ); } }); _saveDraft(); }, ), ), ); } Future _pickDate() async { final picked = await showDatePicker( context: context, initialDate: _selectedDate, firstDate: DateTime(1970), lastDate: DateTime.now().add(const Duration(days: 365)), ); if (picked != null) setState(() => _selectedDate = picked); _saveDraft(); } Future _pickTime() async { final picked = await showTimePicker( context: context, initialTime: _selectedTime, ); if (picked != null) { setState(() => _selectedTime = picked); _saveDraft(); } } Future _loadLegForEdit(int legId) async { setState(() { _loadingEdit = true; _loadError = null; }); try { final api = context.read(); final json = await api.get('/legs/by-id/$legId'); if (!mounted) return; if (json is! Map) { throw Exception('Unexpected response for leg $legId'); } final beginTime = DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate; final routeStations = _parseRouteStations(json['leg_route']); final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0; final useManual = routeStations.isEmpty; final routeResult = useManual ? null : RouteResult( inputRoute: routeStations, calculatedRoute: routeStations, costs: const [], distance: mileageVal, ); final tractionItems = _buildTractionFromApi( (json['locos'] as List? ?? []) .whereType() .map((e) => Map.from(e)) .toList(), ); _restoringDraft = true; setState(() { final tripRaw = json['leg_trip']; final tripId = tripRaw is num ? tripRaw.toInt() : null; _selectedTripId = tripId == null || tripId == 0 ? null : tripId; _selectedDate = beginTime; _selectedTime = TimeOfDay.fromDateTime(beginTime); _useManualMileage = useManual; _routeResult = routeResult; _startController.text = json['leg_start'] ?? ''; _endController.text = json['leg_end'] ?? ''; _headcodeController.text = (json['leg_headcode'] as String? ?? '') .toUpperCase(); _notesController.text = json['leg_notes'] ?? ''; _networkController.text = (json['leg_network'] as String? ?? '') .toUpperCase(); _mileageController.text = mileageVal == 0 ? '' : mileageVal.toStringAsFixed(2); _tractionItems ..clear() ..addAll(tractionItems); if (_tractionItems.where((e) => e.isMarker).isEmpty) { _tractionItems.insert(0, _TractionItem.marker()); } _lastSubmittedSnapshot = null; }); } catch (e) { if (!mounted) return; setState(() { _loadError = 'Failed to load entry: $e'; }); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Failed to load entry: $e'))); } finally { _restoringDraft = false; if (mounted) { setState(() => _loadingEdit = false); } } } List _parseRouteStations(dynamic raw) { if (raw is List) { return raw.map((e) => e.toString()).toList(); } if (raw is String) { final trimmed = raw.trim(); if (trimmed.isEmpty) return []; try { final decoded = jsonDecode(trimmed); if (decoded is List) { return decoded.map((e) => e.toString()).toList(); } } catch (_) { // ignore and try alternative parsing } if (trimmed.startsWith('[') && trimmed.endsWith(']')) { try { final replaced = trimmed.replaceAll("'", '"'); final decoded = jsonDecode(replaced); if (decoded is List) { return decoded.map((e) => e.toString()).toList(); } } catch (_) {} } if (trimmed.contains('->')) { return trimmed .split('->') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); } if (trimmed.contains(',')) { return trimmed .split(',') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); } return [trimmed]; } return []; } List<_TractionItem> _buildTractionFromApi( List> locoData, ) { if (locoData.isEmpty) return [_TractionItem.marker()]; final sorted = [...locoData] ..sort((a, b) { return _allocPos(b).compareTo(_allocPos(a)); }); final leading = sorted.where((e) => _allocPos(e) >= 0); final trailing = sorted.where((e) => _allocPos(e) < 0); return [ ...leading.map(_mapLocoToTractionItem), _TractionItem.marker(), ...trailing.map(_mapLocoToTractionItem), ]; } int _allocPos(Map loco) => (loco['alloc_pos'] as num?)?.toInt() ?? 0; _TractionItem _mapLocoToTractionItem(Map loco) { final poweringRaw = loco['alloc_powering']; final powering = poweringRaw == true || poweringRaw == 1; return _TractionItem(loco: LocoSummary.fromJson(loco), powering: powering); } DateTime get _legDateTime => DateTime( _selectedDate.year, _selectedDate.month, _selectedDate.day, _selectedTime.hour, _selectedTime.minute, ); List> _buildTractionPayload() { final markerIndex = _tractionItems.indexWhere( (element) => element.isMarker, ); final payload = >[]; for (var i = 0; i < _tractionItems.length; i++) { final item = _tractionItems[i]; if (item.isMarker || item.loco == null) continue; int allocPos; if (i > markerIndex) { allocPos = -(i - markerIndex); } else { allocPos = (markerIndex - 1) - i; } payload.add({ "loco_type": item.loco!.type, "loco_number": item.loco!.number, "alloc_pos": allocPos, "alloc_powering": item.powering ? 1 : 0, }); } return payload; } Future _handleExitIntent() async { if (!mounted) return false; if (_isEditing) return true; if (_formIsEmpty()) return true; if (_activeDraftId != null && !_draftChangedFromBaseline()) { return true; } final choice = await _promptSaveDraft(); if (choice == _ExitChoice.cancel) return false; if (choice == _ExitChoice.save) { await _saveDraftEntry(draftId: _activeDraftId); } else if (choice == _ExitChoice.discard) { await _resetFormState(clearDraft: true); _activeDraftId = null; } return true; } bool _draftChangedFromBaseline() { if (_loadedDraftSnapshot == null) return true; final current = _buildDraftSnapshot( id: _activeDraftId ?? 'temp', includeTimestamp: false, ); return !_snapshotEquality.equals(_loadedDraftSnapshot, current); } bool _formIsEmpty() { return _startController.text.trim().isEmpty && _endController.text.trim().isEmpty && _headcodeController.text.trim().isEmpty && _notesController.text.trim().isEmpty && _networkController.text.trim().isEmpty && _mileageController.text.trim().isEmpty && _routeResult == null && _tractionItems.length <= 1; } Future<_ExitChoice> _promptSaveDraft() async { if (!mounted) return _ExitChoice.cancel; final result = await showDialog<_ExitChoice>( context: context, barrierDismissible: false, useRootNavigator: false, builder: (_) => AlertDialog( title: const Text('Save draft?'), content: const Text( 'Do you want to save this entry as a draft before leaving?', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(_ExitChoice.discard), child: const Text('No'), ), TextButton( onPressed: () => Navigator.of(context).pop(_ExitChoice.save), child: const Text('Yes'), ), TextButton( onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel), child: const Text('Cancel'), ), ], ), ); return result ?? _ExitChoice.cancel; } Future _openDrafts() async { final selected = await Navigator.of(context).push<_StoredDraft>( MaterialPageRoute( builder: (_) => _DraftListPage( loadDrafts: _loadSavedDrafts, onDeleteDraft: _deleteDraft, ), ), ); if (selected != null) { _activeDraftId = selected.id; await _loadDraftEntry(selected.data); } } Future _validateRequiredFields() async { final missing = []; if (_useManualMileage) { if (_startController.text.trim().isEmpty) missing.add('From'); if (_endController.text.trim().isEmpty) missing.add('To'); final mileageText = _mileageController.text.trim(); if (double.tryParse(mileageText) == null) { missing.add('Mileage'); } } else { if (_routeResult == null || _routeResult!.calculatedRoute.isEmpty) { missing.add('Route'); } } if (_networkController.text.trim().isEmpty) { missing.add('Network'); } if (missing.isEmpty) return true; if (!mounted) return false; final fieldList = missing.join(', '); await showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Required field missing'), content: Text( missing.length == 1 ? 'Please fill the following field: $fieldList.' : 'Please fill the following fields: $fieldList.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('OK'), ), ], ), ); return false; } Future _submit() async { if (!_formKey.currentState!.validate()) return; if (!await _validateRequiredFields()) return; final routeStations = _routeResult?.calculatedRoute ?? []; final startVal = _useManualMileage ? _startController.text.trim() : (routeStations.isNotEmpty ? routeStations.first : ''); final endVal = _useManualMileage ? _endController.text.trim() : (routeStations.isNotEmpty ? routeStations.last : ''); final mileageVal = _useManualMileage ? double.tryParse(_mileageController.text.trim()) ?? 0 : (_routeResult?.distance ?? 0); final tractionPayload = _buildTractionPayload(); final snapshot = _buildSubmissionSnapshot( routeStations: routeStations, startVal: startVal, endVal: endVal, mileageVal: mileageVal, tractionPayload: tractionPayload, ); if (_lastSubmittedSnapshot != null && _snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) { final confirmed = await _confirmDuplicateSubmission(); if (!confirmed) return; } if (!mounted) return; final api = context.read(); final dataService = context.read(); final messenger = ScaffoldMessenger.maybeOf(context); setState(() => _submitting = true); final isEditingExisting = _isEditing && widget.editLegId != null; try { if (_useManualMileage) { final body = { if (isEditingExisting) "leg_id": widget.editLegId, "leg_trip": _selectedTripId, "leg_start": startVal, "leg_end": endVal, "leg_begin_time": _legDateTime.toIso8601String(), "leg_network": _networkController.text.trim(), "leg_distance": mileageVal, "isKilometers": false, "leg_notes": _notesController.text.trim(), "leg_headcode": _headcodeController.text.trim(), "locos": tractionPayload, }; if (isEditingExisting) { await api.put('/update', body); } else { await api.post('/add/manual', body); } } else { final body = { if (isEditingExisting) "leg_id": widget.editLegId, "leg_trip": _selectedTripId, "leg_begin_time": _legDateTime.toIso8601String(), "leg_route": routeStations, "leg_notes": _notesController.text.trim(), "leg_headcode": _headcodeController.text.trim(), "leg_network": _networkController.text.trim(), "locos": tractionPayload, }; if (isEditingExisting) { await api.put('/update', body); } else { await api.post('/add', body); } } if (!mounted) return; dataService.refreshLegs(); if (!mounted) return; messenger?.showSnackBar( SnackBar( content: Text( isEditingExisting ? 'Entry updated' : 'Entry submitted', ), ), ); _lastSubmittedSnapshot = snapshot; _activeDraftId = null; } catch (e) { if (!mounted) return; messenger?.showSnackBar( SnackBar(content: Text('Failed to submit: $e')), ); } finally { if (mounted) setState(() => _submitting = false); } } Map _buildSubmissionSnapshot({ required List routeStations, required String startVal, required String endVal, required double mileageVal, required List> tractionPayload, }) { return { "legId": widget.editLegId, "useManualMileage": _useManualMileage, "tripId": _selectedTripId, "legDateTime": _legDateTime.toIso8601String(), "start": startVal, "end": endVal, "routeStations": routeStations, "mileage": mileageVal, "network": _networkController.text.trim(), "notes": _notesController.text.trim(), "headcode": _headcodeController.text.trim(), "locos": tractionPayload, "routeResult": _routeResult == null ? null : { "input_route": _routeResult!.inputRoute, "calculated_route": _routeResult!.calculatedRoute, "costs": _routeResult!.costs, "distance": _routeResult!.distance, }, }; } Future _confirmDuplicateSubmission() async { if (!mounted) return false; final result = await showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Duplicate entry?'), content: const Text('Entry already added, are you sure?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), ElevatedButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Submit anyway'), ), ], ), ); return result ?? false; } Future _resetFormState({bool clearDraft = false}) async { _formKey.currentState?.reset(); _startController.clear(); _endController.clear(); _headcodeController.clear(); _notesController.clear(); _mileageController.clear(); _networkController.clear(); final now = DateTime.now(); setState(() { _selectedDate = now; _selectedTime = TimeOfDay.fromDateTime(now); _useManualMileage = false; _routeResult = null; _tractionItems ..clear() ..add(_TractionItem.marker()); _selectedTripId = null; _submitting = false; _activeDraftId = null; }); if (clearDraft) { await _clearDraft(); } } Future _saveDraft() async { if (_restoringDraft || !_draftPersistenceEnabled) return; final prefs = await SharedPreferences.getInstance(); final draft = { "date": _selectedDate.toIso8601String(), "time": {"hour": _selectedTime.hour, "minute": _selectedTime.minute}, "start": _startController.text, "end": _endController.text, "headcode": _headcodeController.text, "notes": _notesController.text, "mileage": _mileageController.text, "network": _networkController.text, "useManualMileage": _useManualMileage, "selectedTripId": _selectedTripId, "routeResult": _routeResult == null ? null : { "input_route": _routeResult!.inputRoute, "calculated_route": _routeResult!.calculatedRoute, "costs": _routeResult!.costs, "distance": _routeResult!.distance, }, "tractionItems": _serializeTractionItems(), }; await prefs.setString(_draftPrefsKey, jsonEncode(draft)); } Future _clearDraft() async { if (!_draftPersistenceEnabled) return; final prefs = await SharedPreferences.getInstance(); await prefs.remove(_draftPrefsKey); } Future> _loadSavedDrafts() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_draftListPrefsKey); if (raw == null || raw.isEmpty) return []; try { final decoded = jsonDecode(raw); if (decoded is! List) return []; return decoded .whereType() .map((e) => _StoredDraft.fromJson(Map.from(e))) .toList() ..sort((a, b) => b.savedAt.compareTo(a.savedAt)); } catch (_) { return []; } } Future _deleteDraft(String id) async { final drafts = await _loadSavedDrafts(); drafts.removeWhere((d) => d.id == id); final prefs = await SharedPreferences.getInstance(); await prefs.setString( _draftListPrefsKey, jsonEncode(drafts.map((e) => e.toJson()).toList()), ); if (_activeDraftId == id) { _activeDraftId = null; } } Future _saveDraftEntry({String? draftId}) async { final id = draftId ?? DateTime.now().microsecondsSinceEpoch.toString(); final snapshot = _buildDraftSnapshot(id: id); final drafts = await _loadSavedDrafts(); final now = DateTime.now(); final existingIndex = drafts.indexWhere((d) => d.id == id); final newDraft = _StoredDraft(id: id, savedAt: now, data: snapshot); if (existingIndex >= 0) { drafts[existingIndex] = newDraft; } else { drafts.insert(0, newDraft); } final prefs = await SharedPreferences.getInstance(); await prefs.setString( _draftListPrefsKey, jsonEncode(drafts.map((e) => e.toJson()).toList()), ); _activeDraftId = id; _loadedDraftSnapshot = _buildDraftSnapshot(id: id, includeTimestamp: false); return id; } Map _buildDraftSnapshot({ required String id, bool includeTimestamp = true, }) { final routeStations = _routeResult?.calculatedRoute ?? []; final startVal = _useManualMileage ? _startController.text.trim() : (routeStations.isNotEmpty ? routeStations.first : ''); final endVal = _useManualMileage ? _endController.text.trim() : (routeStations.isNotEmpty ? routeStations.last : ''); final mileageVal = _useManualMileage ? double.tryParse(_mileageController.text.trim()) ?? 0 : (_routeResult?.distance ?? 0); final tractionPayload = _buildTractionPayload(); final payload = _useManualMileage ? { "leg_trip": _selectedTripId, "leg_start": startVal, "leg_end": endVal, "leg_begin_time": _legDateTime.toIso8601String(), "leg_network": _networkController.text.trim(), "leg_distance": mileageVal, "isKilometers": false, "leg_notes": _notesController.text.trim(), "leg_headcode": _headcodeController.text.trim(), "locos": tractionPayload, } : { "leg_trip": _selectedTripId, "leg_begin_time": _legDateTime.toIso8601String(), "leg_route": routeStations, "leg_notes": _notesController.text.trim(), "leg_headcode": _headcodeController.text.trim(), "leg_network": _networkController.text.trim(), "locos": tractionPayload, "leg_mileage": _routeResult?.distance ?? mileageVal, }; return { "id": id, if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(), "mode": _useManualMileage ? 'manual' : 'auto', "payload": payload, "routeResult": _routeResult == null ? null : { "input_route": _routeResult!.inputRoute, "calculated_route": _routeResult!.calculatedRoute, "costs": _routeResult!.costs, "distance": _routeResult!.distance, }, "tractionItems": _serializeTractionItems(), }; } Future _loadDraftEntry(Map data) async { if (!mounted) return; final payloadRaw = data['payload']; if (payloadRaw is! Map) return; final payload = Map.from(payloadRaw); final mode = data['mode'] as String?; final useManual = mode == 'manual' || (payload.containsKey('leg_distance') && !payload.containsKey('leg_route')); final beginStr = payload['leg_begin_time'] as String?; final beginTime = beginStr == null ? DateTime.now() : DateTime.tryParse(beginStr) ?? DateTime.now(); final tripRaw = payload['leg_trip']; final tripId = tripRaw is num ? tripRaw.toInt() : null; List routeStations = []; RouteResult? restoredRouteResult; if (!useManual) { if (payload['leg_route'] is List) { routeStations = (payload['leg_route'] as List) .map((e) => e.toString()) .toList(); } final rr = data['routeResult']; if (rr is Map) { restoredRouteResult = RouteResult( inputRoute: (rr['input_route'] as List?)?.map((e) => e.toString()).toList() ?? routeStations, calculatedRoute: (rr['calculated_route'] as List?) ?.map((e) => e.toString()) .toList() ?? routeStations, costs: (rr['costs'] as List?) ?.map((e) => (e as num).toDouble()) .toList() ?? [], distance: (rr['distance'] as num?)?.toDouble() ?? (payload['leg_mileage'] as num?)?.toDouble() ?? 0, ); } else if (routeStations.isNotEmpty) { restoredRouteResult = RouteResult( inputRoute: routeStations, calculatedRoute: routeStations, costs: const [], distance: (payload['leg_mileage'] as num?)?.toDouble() ?? 0, ); } } _restoringDraft = true; setState(() { _useManualMileage = useManual; _selectedDate = beginTime; _selectedTime = TimeOfDay.fromDateTime(beginTime); _selectedTripId = tripId == null || tripId == 0 ? null : tripId; _routeResult = restoredRouteResult; _headcodeController.text = (payload['leg_headcode'] as String? ?? '') .toUpperCase(); _networkController.text = (payload['leg_network'] as String? ?? '') .toUpperCase(); _notesController.text = payload['leg_notes'] ?? ''; if (useManual) { _startController.text = payload['leg_start'] ?? ''; _endController.text = payload['leg_end'] ?? ''; final miles = (payload['leg_distance'] as num?)?.toDouble(); _mileageController.text = miles == null || miles == 0 ? '' : miles.toStringAsFixed(2); } else { _startController.text = routeStations.isNotEmpty ? routeStations.first : ''; _endController.text = routeStations.isNotEmpty ? routeStations.last : ''; final dist = _routeResult?.distance ?? 0; _mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2); } final tractionRaw = data['tractionItems']; if (tractionRaw is List) { _restoreTractionItems( List>.from(tractionRaw.cast()), ); } else { _tractionItems ..clear() ..add(_TractionItem.marker()); } _lastSubmittedSnapshot = null; final idRaw = data['id']; if (idRaw != null) { _activeDraftId = idRaw.toString(); } }); final baselineId = _activeDraftId ?? data['id']?.toString() ?? DateTime.now().toString(); _loadedDraftSnapshot = _buildDraftSnapshot( id: baselineId, includeTimestamp: false, ); _restoringDraft = false; } Future _loadDraft() async { // legacy single draft no-op } List> _serializeTractionItems() { return _tractionItems .map( (item) => { "isMarker": item.isMarker, "powering": item.powering, "loco": item.loco == null ? null : { "id": item.loco!.id, "type": item.loco!.type, "number": item.loco!.number, "class": item.loco!.locoClass, "name": item.loco!.name, "operator": item.loco!.operator, "notes": item.loco!.notes, "evn": item.loco!.evn, }, }, ) .toList(); } void _restoreTractionItems(List> items) { final restored = <_TractionItem>[]; for (final item in items) { final locoData = item['loco'] as Map?; LocoSummary? loco; if (locoData != null) { loco = LocoSummary( locoId: locoData['id'] ?? 0, locoType: locoData['type'] ?? '', locoNumber: locoData['number'] ?? '', locoName: locoData['name'] ?? '', locoClass: locoData['class'] ?? '', locoOperator: locoData['operator'] ?? '', locoNotes: locoData['notes'], locoEvn: locoData['evn'], ); } restored.add( _TractionItem( loco: loco, powering: item['powering'] ?? true, isMarker: item['isMarker'] ?? false, ), ); } if (restored.where((e) => e.isMarker).isEmpty) { restored.insert(0, _TractionItem.marker()); } _tractionItems ..clear() ..addAll(restored); } @override Widget build(BuildContext context) { Widget body; if (_isEditing && _loadingEdit) { body = const Center(child: CircularProgressIndicator()); } else if (_isEditing && _loadError != null) { body = Center(child: Text(_loadError!)); } else { final isMobile = MediaQuery.of(context).size.width < 700; body = Form( key: _formKey, child: LayoutBuilder( builder: (context, constraints) { final twoCol = !isMobile && constraints.maxWidth > 1000; final tractionEmpty = _tractionItems.length == 1; final mileageEmpty = !_useManualMileage && _routeResult == null; final balancePanels = twoCol && tractionEmpty && mileageEmpty; final balancedHeight = balancePanels ? 165.0 : null; final detailPanel = _section('Details', [ Row( children: [ TextButton.icon( style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: const Size(0, 36), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), onPressed: _isEditing ? null : _openDrafts, icon: const Icon(Icons.list_alt, size: 16), label: const Text('Drafts'), ), const Spacer(), TextButton.icon( style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: const Size(0, 36), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), onPressed: _submitting ? null : () => _resetFormState(clearDraft: true), icon: const Icon(Icons.clear, size: 16), label: const Text('Clear form'), ), ], ), _buildTripSelector(context), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _pickDate, icon: const Icon(Icons.calendar_today), label: Text(DateFormat.yMMMd().format(_selectedDate)), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton.icon( onPressed: _pickTime, icon: const Icon(Icons.schedule), label: Text(_selectedTime.format(context)), ), ), ], ), if (_useManualMileage) Row( children: [ Expanded( child: TextFormField( controller: _startController, decoration: const InputDecoration( labelText: 'From', border: OutlineInputBorder(), ), validator: (v) => !_useManualMileage ? null : (v == null || v.isEmpty ? 'Required' : null), ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: _endController, decoration: const InputDecoration( labelText: 'To', border: OutlineInputBorder(), ), validator: (v) => !_useManualMileage ? null : (v == null || v.isEmpty ? 'Required' : null), ), ), ], ), TextFormField( controller: _headcodeController, textCapitalization: TextCapitalization.characters, inputFormatters: const [_UpperCaseTextFormatter()], decoration: const InputDecoration( labelText: 'Headcode', border: OutlineInputBorder(), ), ), TextFormField( controller: _networkController, textCapitalization: TextCapitalization.characters, inputFormatters: const [_UpperCaseTextFormatter()], decoration: const InputDecoration( labelText: 'Network', border: OutlineInputBorder(), ), ), TextFormField( controller: _notesController, maxLines: 3, decoration: const InputDecoration( labelText: 'Notes', border: OutlineInputBorder(), ), ), ]); final tractionPanel = _section('Traction', [ Align( alignment: Alignment.centerLeft, child: ElevatedButton.icon( onPressed: _openTractionPicker, icon: const Icon(Icons.search), label: const Text('Search traction'), ), ), _buildTractionList(), ], minHeight: balancedHeight); final mileagePanel = _section( 'Mileage', [ if (!_useManualMileage) Align( alignment: Alignment.centerLeft, child: TextButton.icon( style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), minimumSize: const Size(0, 32), ), onPressed: _openCalculator, icon: const Icon(Icons.calculate, size: 18), label: const Text('Open mileage calculator'), ), ), if (_useManualMileage) TextFormField( controller: _mileageController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration( labelText: 'Mileage (mi)', border: OutlineInputBorder(), ), ) else if (_routeResult != null) ListTile( contentPadding: EdgeInsets.zero, title: const Text('Calculated mileage'), subtitle: Text( '${_routeResult!.distance.toStringAsFixed(2)} mi', ), ) else const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), child: Text( 'No route selected. Use the calculator to add a route.', ), ), ], trailing: FilterChip( label: Text(_useManualMileage ? 'Manual' : 'Automatic'), selected: _useManualMileage, onSelected: (val) { setState(() => _useManualMileage = val); _saveDraft(); }, ), minHeight: balancedHeight, ); return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ detailPanel, const SizedBox(height: 16), twoCol ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: tractionPanel), const SizedBox(width: 16), Expanded(child: mileagePanel), ], ) : Column( children: [ tractionPanel, const SizedBox(height: 16), mileagePanel, ], ), const SizedBox(height: 12), ElevatedButton.icon( onPressed: _submitting ? null : _submit, icon: _submitting ? const SizedBox( height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send), label: Text( _submitting ? (_isEditing ? 'Saving...' : 'Submitting...') : (_isEditing ? 'Save changes' : 'Submit entry'), ), ), ], ), ); }, ), ); } return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) async { if (didPop) return; final allow = await _handleExitIntent(); if (allow && context.mounted) { Navigator.of(context).maybePop(); } }, child: Scaffold( appBar: _isEditing ? AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () async { if (!await _handleExitIntent()) return; if (!context.mounted) return; Navigator.of(context).maybePop(); }, ), title: const Text('Edit entry'), ) : null, body: body, ), ); } Widget _buildTractionList() { if (_tractionItems.length == 1) { return const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), child: Text('No traction selected yet.'), ); } return ReorderableListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), buildDefaultDragHandles: false, onReorder: (oldIndex, newIndex) { if (newIndex > oldIndex) newIndex -= 1; setState(() { final item = _tractionItems.removeAt(oldIndex); _tractionItems.insert(newIndex, item); }); _saveDraft(); }, itemCount: _tractionItems.length, itemBuilder: (context, index) { final item = _tractionItems[index]; if (item.isMarker) { return Card( key: const ValueKey('marker'), color: Theme.of(context).colorScheme.surfaceContainerHighest, child: const ListTile( leading: Icon(Icons.train), title: Text('Rolling stock marker'), subtitle: Text( 'Place locomotives above/below. Positions set relative to this.', ), ), ); } final loco = item.loco!; final markerIndex = _tractionItems.indexWhere( (element) => element.isMarker, ); final pos = index > markerIndex ? -(index - markerIndex) : (markerIndex - 1) - index; return Card( key: ValueKey('${loco.locoClass}-${loco.number}-$index'), child: ListTile( leading: ReorderableDragStartListener( index: index, child: const Icon(Icons.drag_indicator), ), title: Text('${loco.locoClass} ${loco.number}'), subtitle: Text('${loco.name ?? ''} · Position $pos'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ const Text('Powering'), Switch( value: item.powering, onChanged: (v) { setState(() { _tractionItems[index] = item.copyWith(powering: v); }); _saveDraft(); }, ), IconButton( icon: const Icon(Icons.delete), onPressed: () { setState(() { _tractionItems.removeAt(index); }); _saveDraft(); }, ), ], ), ), ); }, ); } Widget _section( String title, List children, { Widget? trailing, double? minHeight, }) { Widget card = Card( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), if (trailing != null) trailing, ], ), const SizedBox(height: 8), ...children.map( (w) => Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: w, ), ), ], ), ), ); if (minHeight != null) { card = ConstrainedBox( constraints: BoxConstraints(minHeight: minHeight), child: card, ); } return card; } } class _UpperCaseTextFormatter extends TextInputFormatter { const _UpperCaseTextFormatter(); @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { return newValue.copyWith( text: newValue.text.toUpperCase(), selection: newValue.selection, ); } } enum _ExitChoice { save, discard, cancel } class _StoredDraft { final String id; final DateTime savedAt; final Map data; _StoredDraft({required this.id, required this.savedAt, required this.data}); factory _StoredDraft.fromJson(Map json) { final savedAt = DateTime.tryParse(json['saved_at'] ?? '') ?? DateTime.now(); final data = Map.from(json['data'] as Map? ?? {}); final embeddedId = data['id']?.toString(); return _StoredDraft( id: json['id']?.toString() ?? embeddedId ?? savedAt.microsecondsSinceEpoch.toString(), savedAt: savedAt, data: data, ); } Map toJson() { return {"id": id, "saved_at": savedAt.toIso8601String(), "data": data}; } } class _DraftListPage extends StatelessWidget { const _DraftListPage({required this.loadDrafts, required this.onDeleteDraft}); final Future> Function() loadDrafts; final Future Function(String id) onDeleteDraft; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Drafts')), body: FutureBuilder>( future: loadDrafts(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } final drafts = snapshot.data ?? const []; if (drafts.isEmpty) { return const Center(child: Text('No drafts saved yet.')); } return _DraftListBody(drafts: drafts, onDelete: onDeleteDraft); }, ), ); } } class _DraftListBody extends StatefulWidget { const _DraftListBody({required this.drafts, required this.onDelete}); final List<_StoredDraft> drafts; final Future Function(String id) onDelete; @override State<_DraftListBody> createState() => _DraftListBodyState(); } class _DraftListBodyState extends State<_DraftListBody> { late final List<_StoredDraft> _drafts = List.of(widget.drafts); @override Widget build(BuildContext context) { return ListView.separated( itemCount: _drafts.length, separatorBuilder: (context, _) => const Divider(height: 0), itemBuilder: (context, index) { final draft = _drafts[index]; final routeLine = _draftSubtitle(draft); final metaLine = _draftMetaLine(draft); return ListTile( title: Text(DateFormat.yMMMd().add_jm().format(draft.savedAt)), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (routeLine != null) Text(routeLine), if (metaLine.isNotEmpty) Text(metaLine), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Delete draft', icon: const Icon(Icons.delete), onPressed: () => _confirmDelete(context, draft), ), const Icon(Icons.chevron_right), ], ), onTap: () => Navigator.of(context).pop(draft), ); }, ); } Future _confirmDelete(BuildContext context, _StoredDraft draft) async { final confirmed = await showDialog( context: context, builder: (dialogCtx) => AlertDialog( title: const Text('Delete draft?'), content: const Text('This draft will be removed permanently.'), actions: [ TextButton( onPressed: () => Navigator.of(dialogCtx).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(dialogCtx).pop(true), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; await widget.onDelete(draft.id); if (!mounted) return; setState(() { _drafts.removeWhere((d) => d.id == draft.id); }); } String? _draftSubtitle(_StoredDraft draft) { final payload = draft.data['payload']; if (payload is! Map) return null; final map = Map.from(payload); String start = map['leg_start']?.toString() ?? ''; String end = map['leg_end']?.toString() ?? ''; if (start.isEmpty && end.isEmpty) { if (map['leg_route'] is List && (map['leg_route'] as List).isNotEmpty) { start = (map['leg_route'] as List).first.toString(); end = (map['leg_route'] as List).last.toString(); } } if (start.isEmpty && end.isEmpty) return null; if (start.isNotEmpty && end.isNotEmpty) { return '$start → $end'; } return start.isNotEmpty ? start : end; } String _draftMetaLine(_StoredDraft draft) { final payload = draft.data['payload']; if (payload is! Map) return ''; final map = Map.from(payload); final parts = []; if ((map['leg_trip'] as int? ?? 0) != 0) { parts.add('Trip ${map['leg_trip']}'); } final headcode = (map['leg_headcode'] as String? ?? '').trim(); if (headcode.isNotEmpty) parts.add('Headcode $headcode'); final network = (map['leg_network'] as String? ?? '').trim(); if (network.isNotEmpty) parts.add('Network $network'); final notes = (map['leg_notes'] as String? ?? '').trim(); if (notes.isNotEmpty) parts.add('Notes'); final mileage = (map['leg_distance'] as num?)?.toDouble() ?? (map['leg_mileage'] as num?)?.toDouble(); if (mileage != null && mileage > 0) { parts.add('${mileage.toStringAsFixed(1)} mi'); } else if (map['leg_route'] is List && (map['leg_route'] as List).isNotEmpty) { parts.add('Route ${(map['leg_route'] as List).length} stops'); } final locos = map['locos']; if (locos is List && locos.isNotEmpty) { parts.add('${locos.length} traction'); } return parts.join(' • '); } } class _CalculatorPickerPage extends StatelessWidget { const _CalculatorPickerPage({required this.onResult}); final ValueChanged onResult; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), title: const Text('Mileage calculator'), ), body: RouteCalculator(onApplyRoute: onResult), ); } } class _TractionItem { final LocoSummary? loco; final bool powering; final bool isMarker; _TractionItem({ required this.loco, this.powering = true, this.isMarker = false, }); factory _TractionItem.marker() => _TractionItem(loco: null, powering: false, isMarker: true); _TractionItem copyWith({LocoSummary? loco, bool? powering, bool? isMarker}) { return _TractionItem( loco: loco ?? this.loco, powering: powering ?? this.powering, isMarker: isMarker ?? this.isMarker, ); } }