import 'dart:async'; import 'dart:convert'; 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/apiService.dart'; import 'package:mileograph_flutter/services/dataService.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; class NewEntryPage extends StatefulWidget { const NewEntryPage({super.key}); @override State createState() => _NewEntryPageState(); } class _NewEntryPageState extends State { static const _draftPrefsKey = 'new_entry_draft'; 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; @override void initState() { super.initState(); for (final controller in [ _startController, _endController, _headcodeController, _notesController, _mileageController, _networkController, ]) { controller.addListener(_saveDraft); } Future.microtask(() { if (!mounted) return; final data = context.read(); data.fetchClassList(); data.fetchTrips(); _loadDraft(); }); } @override void dispose() { for (final controller in [ _startController, _endController, _headcodeController, _notesController, _mileageController, _networkController, ]) { controller.removeListener(_saveDraft); } _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: (context) => AlertDialog( title: const Text('New Trip'), content: TextField( controller: controller, decoration: const InputDecoration(labelText: 'Trip name'), autofocus: true, ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), ElevatedButton( onPressed: () => Navigator.of(context).pop(controller.text.trim()), child: const Text('Add'), ), ], ), ); if (!mounted) return; if (result != null && result.isNotEmpty) { final api = context.read(); final data = context.read(); final messenger = ScaffoldMessenger.of(context); try { await api.put('/trips/new', {"trip_name": result}); await data.fetchTrips(); if (!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 (!mounted) return; messenger.showSnackBar( SnackBar(content: Text('Failed to add trip: $e')), ); } } } 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(); } } 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 _submit() async { if (!_formKey.currentState!.validate()) return; if (!_useManualMileage && _routeResult == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please calculate mileage first')), ); return; } setState(() => _submitting = true); final api = context.read(); 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(); if (_useManualMileage) { final body = { "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, }; await api.post('/add/manual', body); } else { final body = { "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, }; await api.post('/add', body); } if (mounted) { context.read().refreshLegs(); } try { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Entry submitted'))); _resetFormState(clearDraft: true); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Failed to submit: $e'))); } finally { if (mounted) setState(() => _submitting = 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; }); if (clearDraft) { await _clearDraft(); } else { _saveDraft(); } } Future _saveDraft() async { if (_restoringDraft) 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 { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_draftPrefsKey); } Future _loadDraft() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_draftPrefsKey); if (raw == null) return; try { final data = jsonDecode(raw); if (data is! Map) return; _restoringDraft = true; setState(() { if (data['date'] is String) { _selectedDate = DateTime.tryParse(data['date']) ?? _selectedDate; } if (data['time'] is Map) { final time = data['time'] as Map; final hour = time['hour'] as int?; final minute = time['minute'] as int?; if (hour != null && minute != null) { _selectedTime = TimeOfDay(hour: hour, minute: minute); } } _useManualMileage = data['useManualMileage'] ?? _useManualMileage; _selectedTripId = data['selectedTripId']; if (data['routeResult'] is Map) { _routeResult = RouteResult.fromJson( Map.from(data['routeResult']), ); _mileageController.text = _routeResult!.distance.toStringAsFixed(2); } if (data['tractionItems'] is List) { _restoreTractionItems( List>.from(data['tractionItems'].cast()), ); } }); _startController.text = data['start'] ?? ''; _endController.text = data['end'] ?? ''; _headcodeController.text = data['headcode'] is String ? data['headcode'].toUpperCase() : ''; _notesController.text = data['notes'] ?? ''; _mileageController.text = data['mileage'] ?? ''; _networkController.text = data['network'] is String ? data['network'].toUpperCase() : ''; } catch (_) { // Ignore corrupt draft data } finally { _restoringDraft = false; } } 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) { final isMobile = MediaQuery.of(context).size.width < 700; return Scaffold( appBar: null, body: Form( key: _formKey, child: LayoutBuilder( builder: (context, constraints) { final twoCol = !isMobile && constraints.maxWidth > 1000; final detailPanel = _section('Details', [ _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(), ]); final mileagePanel = _section( 'Mileage', [ 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', ), ), if (!_useManualMileage) Align( alignment: Alignment.centerLeft, child: ElevatedButton.icon( onPressed: _openCalculator, icon: const Icon(Icons.calculate), label: const Text('Open mileage calculator'), ), ), ], trailing: FilterChip( label: Text(_useManualMileage ? 'Manual' : 'Automatic'), selected: _useManualMileage, onSelected: (val) { setState(() => _useManualMileage = val); _saveDraft(); }, ), ); 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), OutlinedButton.icon( onPressed: _submitting ? null : () => _resetFormState(clearDraft: true), icon: const Icon(Icons.clear), label: const Text('Clear form'), ), const SizedBox(height: 8), 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 ? 'Submitting...' : 'Submit entry'), ), ], ), ); }, ), ), ); } 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}) { return 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, ), ), ], ), ), ); } } class _UpperCaseTextFormatter extends TextInputFormatter { const _UpperCaseTextFormatter(); @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { return newValue.copyWith( text: newValue.text.toUpperCase(), selection: newValue.selection, ); } } 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, ); } }