diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index 93d267c..bb00682 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/objects/objects.dart'; @@ -38,19 +36,72 @@ class _LegCardState extends State { title: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth > 520; - final routeText = Text('${leg.start} → ${leg.end}'); - final timeText = - Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate)); + final beginTimeWidget = _timeWithDelay( + context, + leg.beginTime, + leg.beginDelayMinutes, + includeDate: widget.showDate, + ); + final endTimeWidget = leg.endTime == null + ? null + : _timeWithDelay( + context, + leg.endTime!, + leg.endDelayMinutes, + includeDate: widget.showDate, + ); + + final routeText = Text( + '${leg.start} → ${leg.end}', + softWrap: true, + ); if (!isWide) { - return routeText; + final timeStyle = Theme.of(context).textTheme.labelSmall; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + routeText, + const SizedBox(height: 2), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _timeWithDelay( + context, + leg.beginTime, + leg.beginDelayMinutes, + includeDate: widget.showDate, + style: timeStyle, + ), + if (endTimeWidget != null) ...[ + const Text('·'), + _timeWithDelay( + context, + leg.endTime!, + leg.endDelayMinutes, + includeDate: widget.showDate, + style: timeStyle, + ), + ], + ], + ), + ], + ); } - return Row( + + return Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - timeText, - const SizedBox(width: 6), + beginTimeWidget, const Text('·'), - const SizedBox(width: 6), - Expanded(child: routeText), + routeText, + if (endTimeWidget != null) ...[ + const Text('·'), + endTimeWidget, + ], ], ); }, @@ -58,8 +109,12 @@ class _LegCardState extends State { subtitle: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth > 520; - final timeWidget = - Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate)); + final timeWidget = _timeWithDelay( + context, + leg.beginTime, + leg.beginDelayMinutes, + includeDate: widget.showDate, + ); final tractionWrap = !_expanded && leg.locos.isNotEmpty ? Wrap( spacing: 8, @@ -90,9 +145,7 @@ class _LegCardState extends State { children.add(tractionWrap); } } else { - children.add(timeWidget); if (tractionWrap != null) { - children.add(const SizedBox(height: 4)); children.add(tractionWrap); } } @@ -191,6 +244,12 @@ class _LegCardState extends State { ), const SizedBox(height: 12), ], + if (_hasTrainDetails(leg)) ...[ + Text('Train', style: textTheme.titleSmall), + const SizedBox(height: 6), + ..._buildTrainDetails(leg, textTheme), + const SizedBox(height: 12), + ], if (routeSegments.isNotEmpty) ...[ Text('Route', style: textTheme.titleSmall), const SizedBox(height: 6), @@ -238,6 +297,40 @@ class _LegCardState extends State { } } + Widget _timeWithDelay( + BuildContext context, + DateTime time, + int? delay, { + bool includeDate = true, + TextStyle? style, + }) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final delayMinutes = delay ?? 0; + final delayText = + delayMinutes == 0 ? null : '${delayMinutes > 0 ? '+' : ''}$delayMinutes'; + final delayColor = delayMinutes == 0 + ? null + : (delayMinutes < 0 ? Colors.green : colorScheme.error); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatDateTime(time, includeDate: includeDate), + style: style, + ), + if (delayText != null) ...[ + const SizedBox(width: 4), + Text( + '$delayText m', + style: + (style ?? textTheme.labelSmall)?.copyWith(color: delayColor), + ), + ], + ], + ); + } + String _formatDate(DateTime? date) { if (date == null) return ''; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; @@ -286,6 +379,51 @@ class _LegCardState extends State { .toList(); } + bool _hasTrainDetails(Leg leg) { + return leg.headcode.isNotEmpty || + leg.origin.isNotEmpty || + leg.destination.isNotEmpty || + leg.originTime != null || + leg.destinationTime != null; + } + + List _buildTrainDetails(Leg leg, TextTheme textTheme) { + final widgets = []; + if (leg.headcode.isNotEmpty) { + widgets.add( + Text( + 'Headcode: ${leg.headcode}', + style: textTheme.bodyMedium, + ), + ); + } + final originLine = _locationLine( + 'Origin', + leg.origin, + leg.originTime, + ); + if (originLine != null) { + widgets.add(Text(originLine, style: textTheme.bodyMedium)); + } + final destinationLine = _locationLine( + 'Destination', + leg.destination, + leg.destinationTime, + ); + if (destinationLine != null) { + widgets.add(Text(destinationLine, style: textTheme.bodyMedium)); + } + return widgets; + } + + String? _locationLine(String label, String location, DateTime? time) { + final parts = []; + if (location.trim().isNotEmpty) parts.add(location.trim()); + if (time != null) parts.add(_formatDateTime(time)); + if (parts.isEmpty) return null; + return '$label: ${parts.join(' · ')}'; + } + Widget _buildRouteList(List segments) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -306,38 +444,7 @@ class _LegCardState extends State { ); } - List _parseRouteSegments(String route) { - final trimmed = route.trim(); - if (trimmed.isEmpty) return []; - try { - final decoded = jsonDecode(trimmed); - if (decoded is List) { - return decoded.map((e) => e.toString()).toList(); - } - } catch (_) {} - 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]; + List _parseRouteSegments(List route) { + return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList(); } } diff --git a/lib/components/pages/logbook.dart b/lib/components/pages/logbook.dart index 546c2d6..9821dad 100644 --- a/lib/components/pages/logbook.dart +++ b/lib/components/pages/logbook.dart @@ -21,8 +21,7 @@ class LogbookPage extends StatelessWidget { children: [ TabBar( onTap: (index) { - final dest = - index == 0 ? '/logbook/entries' : '/logbook/trips'; + final dest = index == 0 ? '/logbook/entries' : '/logbook/trips'; final current = GoRouterState.of(context).uri.path; if (current != dest) { context.go(dest); @@ -34,12 +33,7 @@ class LogbookPage extends StatelessWidget { ], ), Expanded( - child: TabBarView( - children: const [ - LegsPage(), - TripsPage(), - ], - ), + child: TabBarView(children: const [LegsPage(), TripsPage()]), ), ], ), diff --git a/lib/components/pages/new_entry/new_entry_draft_logic.dart b/lib/components/pages/new_entry/new_entry_draft_logic.dart index 8ebedae..c9f88d9 100644 --- a/lib/components/pages/new_entry/new_entry_draft_logic.dart +++ b/lib/components/pages/new_entry/new_entry_draft_logic.dart @@ -13,6 +13,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState { if (choice == _ExitChoice.save) { await _saveDraftEntry(draftId: _activeDraftId); } else if (choice == _ExitChoice.discard) { + // Delay reset to avoid setState during the dialog/build phase. + await Future.delayed(Duration.zero); + if (!mounted) return false; await _resetFormState(clearDraft: true); _activeDraftId = null; } @@ -29,12 +32,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState { } bool _formIsEmpty() { + final beginDelayVal = _parseDelayMinutes(_beginDelayController.text); + final endDelayVal = _parseDelayMinutes(_endDelayController.text); 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 && + _originController.text.trim().isEmpty && + _destinationController.text.trim().isEmpty && + beginDelayVal == 0 && + endDelayVal == 0 && + !_hasOriginTime && + !_hasDestinationTime && + !_hasEndTime && _routeResult == null && _tractionItems.length <= 1; } @@ -122,6 +134,30 @@ extension _NewEntryDraftLogic on _NewEntryPageState { "notes": _notesController.text, "mileage": _mileageController.text, "network": _networkController.text, + "origin": _originController.text, + "destination": _destinationController.text, + "hasEndTime": _hasEndTime, + "hasOriginTime": _hasOriginTime, + "hasDestinationTime": _hasDestinationTime, + "endDate": _selectedEndDate.toIso8601String(), + "endTime": { + "hour": _selectedEndTime.hour, + "minute": _selectedEndTime.minute, + }, + "originDate": _selectedOriginDate.toIso8601String(), + "originTime": { + "hour": _selectedOriginTime.hour, + "minute": _selectedOriginTime.minute, + }, + "destinationDate": _selectedDestinationDate.toIso8601String(), + "destinationTime": { + "hour": _selectedDestinationTime.hour, + "minute": _selectedDestinationTime.minute, + }, + "matchOriginToEntry": _matchOriginToEntry, + "matchDestinationToEntry": _matchDestinationToEntry, + "beginDelay": _parseDelayMinutes(_beginDelayController.text), + "endDelay": _parseDelayMinutes(_endDelayController.text), "useManualMileage": _useManualMileage, "selectedTripId": _selectedTripId, "routeResult": _routeResult == null @@ -200,6 +236,12 @@ extension _NewEntryDraftLogic on _NewEntryPageState { bool includeTimestamp = true, }) { final routeStations = _routeResult?.calculatedRoute ?? []; + final endTime = _legEndDateTime; + final originTime = _originDateTime; + final destinationTime = _destinationDateTime; + final beginDelay = _parseDelayMinutes(_beginDelayController.text); + final endDelay = + _hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; final startVal = _useManualMileage ? _startController.text.trim() : (routeStations.isNotEmpty ? routeStations.first : ''); @@ -210,27 +252,33 @@ extension _NewEntryDraftLogic on _NewEntryPageState { ? double.tryParse(_mileageController.text.trim()) ?? 0 : (_routeResult?.distance ?? 0); final tractionPayload = _buildTractionPayload(); + final commonPayload = { + "leg_trip": _selectedTripId, + "leg_begin_time": _legDateTime.toIso8601String(), + if (endTime != null) "leg_end_time": endTime.toIso8601String(), + if (originTime != null) "leg_origin_time": originTime.toIso8601String(), + if (destinationTime != null) + "leg_destination_time": destinationTime.toIso8601String(), + "leg_notes": _notesController.text.trim(), + "leg_headcode": _headcodeController.text.trim(), + "leg_network": _networkController.text.trim(), + "leg_origin": _originController.text.trim(), + "leg_destination": _destinationController.text.trim(), + "leg_begin_delay": beginDelay, + if (_hasEndTime) "leg_end_delay": endDelay, + "locos": tractionPayload, + }; final payload = _useManualMileage ? { - "leg_trip": _selectedTripId, + ...commonPayload, "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(), + ...commonPayload, "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 { @@ -265,6 +313,29 @@ extension _NewEntryDraftLogic on _NewEntryPageState { final beginTime = beginStr == null ? DateTime.now() : DateTime.tryParse(beginStr) ?? DateTime.now(); + final originTimeStr = payload['leg_origin_time'] as String?; + final destinationTimeStr = payload['leg_destination_time'] as String?; + final originTime = + originTimeStr == null ? null : DateTime.tryParse(originTimeStr); + final destinationTime = destinationTimeStr == null + ? null + : DateTime.tryParse(destinationTimeStr); + final endStr = payload['leg_end_time'] as String?; + final endTime = + endStr == null ? null : DateTime.tryParse(endStr); + final beginDelay = + _parseDelayMinutes('${payload['leg_begin_delay'] ?? ''}'); + final endDelay = + _parseDelayMinutes('${payload['leg_end_delay'] ?? ''}'); + final hasEndTime = endTime != null || endDelay != 0; + final matchOrigin = data['matchOriginToEntry'] == true; + final matchDestination = data['matchDestinationToEntry'] == true; + final hasOriginTime = + originTime != null || data['hasOriginTime'] == true; + final hasDestinationTime = + destinationTime != null || data['hasDestinationTime'] == true; + final origin = payload['leg_origin'] as String? ?? ''; + final destination = payload['leg_destination'] as String? ?? ''; final tripRaw = payload['leg_trip']; final tripId = tripRaw is num ? tripRaw.toInt() : null; @@ -312,6 +383,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState { _useManualMileage = useManual; _selectedDate = beginTime; _selectedTime = TimeOfDay.fromDateTime(beginTime); + _selectedEndDate = endTime ?? beginTime; + _selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime); + _hasEndTime = hasEndTime; + _matchOriginToEntry = matchOrigin; + _matchDestinationToEntry = matchDestination; + _selectedOriginDate = originTime ?? beginTime; + _selectedOriginTime = + TimeOfDay.fromDateTime(originTime ?? beginTime); + _selectedDestinationDate = + destinationTime ?? endTime ?? beginTime; + _selectedDestinationTime = TimeOfDay.fromDateTime( + destinationTime ?? endTime ?? beginTime, + ); + _hasOriginTime = hasOriginTime; + _hasDestinationTime = hasDestinationTime; _selectedTripId = tripId == null || tripId == 0 ? null : tripId; _routeResult = restoredRouteResult; _headcodeController.text = (payload['leg_headcode'] as String? ?? '') @@ -319,6 +405,10 @@ extension _NewEntryDraftLogic on _NewEntryPageState { _networkController.text = (payload['leg_network'] as String? ?? '') .toUpperCase(); _notesController.text = payload['leg_notes'] ?? ''; + _originController.text = origin; + _destinationController.text = destination; + _beginDelayController.text = beginDelay.toString(); + _endDelayController.text = endDelay.toString(); if (useManual) { _startController.text = payload['leg_start'] ?? ''; @@ -359,6 +449,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState { includeTimestamp: false, ); _restoringDraft = false; + _scheduleMatchUpdate(); } Future _loadDraft() async { diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 9502d1b..2f0087b 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -14,15 +14,32 @@ class _NewEntryPageState extends State { final _formKey = GlobalKey(); DateTime _selectedDate = DateTime.now(); TimeOfDay _selectedTime = TimeOfDay.now(); + DateTime _selectedEndDate = DateTime.now(); + TimeOfDay _selectedEndTime = TimeOfDay.now(); final _startController = TextEditingController(); final _endController = TextEditingController(); final _headcodeController = TextEditingController(); final _notesController = TextEditingController(); final _mileageController = TextEditingController(); final _networkController = TextEditingController(); + final _originController = TextEditingController(); + final _destinationController = TextEditingController(); + final _beginDelayController = TextEditingController(text: '0'); + final _endDelayController = TextEditingController(text: '0'); + DateTime _selectedOriginDate = DateTime.now(); + DateTime _selectedDestinationDate = DateTime.now(); + TimeOfDay _selectedOriginTime = TimeOfDay.now(); + TimeOfDay _selectedDestinationTime = TimeOfDay.now(); + bool _hasOriginTime = false; + bool _hasDestinationTime = false; bool _submitting = false; bool _useManualMileage = false; + bool _hasEndTime = false; + bool _matchOriginToEntry = false; + bool _matchDestinationToEntry = false; RouteResult? _routeResult; + List _stations = const []; + bool _loadingStations = false; final List<_TractionItem> _tractionItems = [_TractionItem.marker()]; int? _selectedTripId; bool _restoringDraft = false; @@ -50,9 +67,11 @@ class _NewEntryPageState extends State { final data = context.read(); data.fetchClassList(); data.fetchTripOptions(); + _loadStations(); if (_draftPersistenceEnabled) { _loadDraft(); } + _loadStations(); if (_isEditing && widget.editLegId != null) { _loadLegForEdit(widget.editLegId!); } @@ -68,6 +87,10 @@ class _NewEntryPageState extends State { _notesController.dispose(); _mileageController.dispose(); _networkController.dispose(); + _originController.dispose(); + _destinationController.dispose(); + _beginDelayController.dispose(); + _endDelayController.dispose(); super.dispose(); } @@ -136,7 +159,8 @@ class _NewEntryPageState extends State { child: const Text('Cancel'), ), ElevatedButton( - onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()), + onPressed: () => + Navigator.of(dialogContext).pop(controller.text.trim()), child: const Text('Add'), ), ], @@ -147,15 +171,15 @@ class _NewEntryPageState extends State { return; } if (result != null && result.isNotEmpty) { - final api = context.read(); - final data = context.read(); - final messenger = ScaffoldMessenger.maybeOf(context); - try { - final encoded = Uri.encodeComponent(result); - final res = await api.put('/trips/new?trip_name=$encoded', {}); - await data.fetchTripOptions(); - if (!context.mounted) return; - final trips = data.tripList; + final api = context.read(); + final data = context.read(); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + final encoded = Uri.encodeComponent(result); + final res = await api.put('/trips/new?trip_name=$encoded', {}); + await data.fetchTripOptions(); + if (!context.mounted) return; + final trips = data.tripList; final apiTripId = res is Map ? res['trip_id'] as int? : null; TripSummary match; try { @@ -206,6 +230,7 @@ class _NewEntryPageState extends State { _useManualMileage = false; }); _saveDraft(); + _scheduleMatchUpdate(); } } @@ -218,6 +243,7 @@ class _NewEntryPageState extends State { ); if (picked != null) setState(() => _selectedDate = picked); _saveDraft(); + _scheduleMatchUpdate(); } Future _pickTime() async { @@ -229,6 +255,158 @@ class _NewEntryPageState extends State { setState(() => _selectedTime = picked); _saveDraft(); } + _scheduleMatchUpdate(); + } + + Future _pickEndDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedEndDate, + firstDate: DateTime(1970), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) setState(() => _selectedEndDate = picked); + _saveDraft(); + _scheduleMatchUpdate(); + } + + Future _pickEndTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _selectedEndTime, + ); + if (picked != null) { + setState(() => _selectedEndTime = picked); + _saveDraft(); + } + _scheduleMatchUpdate(); + } + + void _toggleEndTime(bool? value) { + final useEndTime = value ?? false; + final wasEnabled = _hasEndTime; + setState(() { + _hasEndTime = useEndTime; + if (useEndTime && !wasEnabled) { + _selectedEndDate = _selectedDate; + _selectedEndTime = _selectedTime; + if (_endDelayController.text.isEmpty) { + _endDelayController.text = '0'; + } + } + }); + _saveDraft(); + _scheduleMatchUpdate(); + } + + void _toggleMatchOrigin(bool? value) { + final enabled = value ?? false; + setState(() { + _matchOriginToEntry = enabled; + if (enabled) _hasOriginTime = true; + }); + _scheduleMatchUpdate(); + _saveDraft(); + } + + void _toggleMatchDestination(bool? value) { + final enabled = value ?? false; + setState(() { + _matchDestinationToEntry = enabled; + if (enabled) _hasDestinationTime = true; + }); + _scheduleMatchUpdate(); + _saveDraft(); + } + + Future _pickOriginDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedOriginDate, + firstDate: DateTime(1970), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) setState(() => _selectedOriginDate = picked); + _saveDraft(); + _scheduleMatchUpdate(); + } + + Future _pickOriginTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _selectedOriginTime, + ); + if (picked != null) { + setState(() => _selectedOriginTime = picked); + _saveDraft(); + } + _scheduleMatchUpdate(); + } + + Future _pickDestinationDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDestinationDate, + firstDate: DateTime(1970), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) setState(() => _selectedDestinationDate = picked); + _saveDraft(); + _scheduleMatchUpdate(); + } + + Future _pickDestinationTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _selectedDestinationTime, + ); + if (picked != null) { + setState(() => _selectedDestinationTime = picked); + _saveDraft(); + } + _scheduleMatchUpdate(); + } + + void _toggleOriginTime(bool? value) { + final enabled = value ?? false; + setState(() { + _hasOriginTime = enabled; + if (enabled) { + _selectedOriginDate = _selectedDate; + _selectedOriginTime = _selectedTime; + } + }); + _scheduleMatchUpdate(); + _saveDraft(); + } + + void _toggleDestinationTime(bool? value) { + final enabled = value ?? false; + setState(() { + _hasDestinationTime = enabled; + if (enabled) { + _selectedDestinationDate = _selectedEndDate; + _selectedDestinationTime = _selectedEndTime; + } + }); + _scheduleMatchUpdate(); + _saveDraft(); + } + + Future _loadStations() async { + if (_loadingStations) return; + setState(() => _loadingStations = true); + try { + final data = context.read(); + await data.fetchStationFilters(); + final stations = await data.fetchStations(); + if (!mounted) return; + setState(() => _stations = stations); + } catch (e) { + debugPrint('Failed to load stations: $e'); + } finally { + if (mounted) setState(() => _loadingStations = false); + } } Future _loadLegForEdit(int legId) async { @@ -245,6 +423,7 @@ class _NewEntryPageState extends State { } final beginTime = DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate; + final endTime = DateTime.tryParse(json['leg_end_time'] ?? ''); final routeStations = _parseRouteStations(json['leg_route']); final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0; final useManual = routeStations.isEmpty; @@ -262,6 +441,16 @@ class _NewEntryPageState extends State { .map((e) => Map.from(e)) .toList(), ); + final beginDelay = (json['leg_begin_delay'] as num?)?.toInt() ?? 0; + final endDelay = (json['leg_end_delay'] as num?)?.toInt() ?? 0; + final origin = json['leg_origin'] as String? ?? ''; + final destination = json['leg_destination'] as String? ?? ''; + final hasEndTime = endTime != null || endDelay != 0; + final originTime = DateTime.tryParse(json['leg_origin_time'] ?? ''); + final destinationTime = + DateTime.tryParse(json['leg_destination_time'] ?? ''); + final hasOriginTime = originTime != null; + final hasDestinationTime = destinationTime != null; _restoringDraft = true; setState(() { @@ -270,6 +459,16 @@ class _NewEntryPageState extends State { _selectedTripId = tripId == null || tripId == 0 ? null : tripId; _selectedDate = beginTime; _selectedTime = TimeOfDay.fromDateTime(beginTime); + _selectedEndDate = endTime ?? beginTime; + _selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime); + _hasEndTime = hasEndTime; + _selectedOriginDate = originTime ?? beginTime; + _selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime); + _selectedDestinationDate = destinationTime ?? endTime ?? beginTime; + _selectedDestinationTime = + TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime); + _hasOriginTime = hasOriginTime; + _hasDestinationTime = hasDestinationTime; _useManualMileage = useManual; _routeResult = routeResult; _startController.text = json['leg_start'] ?? ''; @@ -279,6 +478,10 @@ class _NewEntryPageState extends State { _notesController.text = json['leg_notes'] ?? ''; _networkController.text = (json['leg_network'] as String? ?? '') .toUpperCase(); + _originController.text = origin; + _destinationController.text = destination; + _beginDelayController.text = beginDelay.toString(); + _endDelayController.text = endDelay.toString(); _mileageController.text = mileageVal == 0 ? '' : mileageVal.toStringAsFixed(2); @@ -357,6 +560,161 @@ class _NewEntryPageState extends State { _selectedTime.minute, ); + DateTime? get _legEndDateTime { + if (!_hasEndTime) return null; + return DateTime( + _selectedEndDate.year, + _selectedEndDate.month, + _selectedEndDate.day, + _selectedEndTime.hour, + _selectedEndTime.minute, + ); + } + + DateTime? get _originDateTime { + if (!_hasOriginTime) return null; + return DateTime( + _selectedOriginDate.year, + _selectedOriginDate.month, + _selectedOriginDate.day, + _selectedOriginTime.hour, + _selectedOriginTime.minute, + ); + } + + DateTime? get _destinationDateTime { + if (!_hasDestinationTime) return null; + return DateTime( + _selectedDestinationDate.year, + _selectedDestinationDate.month, + _selectedDestinationDate.day, + _selectedDestinationTime.hour, + _selectedDestinationTime.minute, + ); + } + + int _parseDelayMinutes(String value) { + final trimmed = value.trim(); + final parsed = int.tryParse(trimmed); + return parsed ?? 0; + } + + String _derivedStartLocation() { + if (_useManualMileage) return _startController.text.trim(); + final routeStations = _routeResult?.calculatedRoute ?? []; + if (routeStations.isNotEmpty) return routeStations.first; + return _startController.text.trim(); + } + + String _derivedEndLocation() { + if (_useManualMileage) return _endController.text.trim(); + final routeStations = _routeResult?.calculatedRoute ?? []; + if (routeStations.isNotEmpty) return routeStations.last; + return _endController.text.trim(); + } + + void _applyMatchSelections() { + if (!mounted) return; + if (!(_matchOriginToEntry || _matchDestinationToEntry)) return; + setState(() { + if (_matchOriginToEntry) { + final startVal = _derivedStartLocation(); + if (_originController.text != startVal) { + _originController.text = startVal; + } + if (_hasOriginTime) { + final startTime = _legDateTime; + _selectedOriginDate = DateTime( + startTime.year, + startTime.month, + startTime.day, + ); + _selectedOriginTime = TimeOfDay.fromDateTime(startTime); + } + } + if (_matchDestinationToEntry) { + final endVal = _derivedEndLocation(); + if (_destinationController.text != endVal) { + _destinationController.text = endVal; + } + if (_hasDestinationTime) { + final endTime = _legEndDateTime ?? _legDateTime; + _selectedDestinationDate = DateTime( + endTime.year, + endTime.month, + endTime.day, + ); + _selectedDestinationTime = TimeOfDay.fromDateTime(endTime); + } + } + }); + _saveDraft(); + } + + bool _matchUpdateScheduled = false; + + void _scheduleMatchUpdate() { + if (!(_matchOriginToEntry || _matchDestinationToEntry)) return; + if (_matchUpdateScheduled) return; + _matchUpdateScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _matchUpdateScheduled = false; + _applyMatchSelections(); + }); + } + + Widget _trainLocationBlock({ + required String label, + required TextEditingController controller, + required bool hasTime, + required ValueChanged? onTimeChanged, + required String matchLabel, + required bool matchValue, + required ValueChanged? onMatchChanged, + required Widget Function() pickerBuilder, + }) { + final matchInfo = matchValue + ? Text( + '$label set to entry ${label == 'Origin' ? 'start' : 'end'}', + style: Theme.of(context).textTheme.bodySmall, + ) + : null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CheckboxListTile( + value: matchValue, + onChanged: onMatchChanged, + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(matchLabel), + ), + if (matchInfo != null) ...[ + Padding( + padding: const EdgeInsets.only(left: 12.0, bottom: 4), + child: matchInfo, + ), + ], + if (!matchValue) ...[ + _stationField( + label: label, + controller: controller, + ), + CheckboxListTile( + value: hasTime, + onChanged: onTimeChanged, + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text('Add $label time'), + ), + if (hasTime) pickerBuilder(), + ], + ], + ); + } + @override Widget build(BuildContext context) { Widget body; @@ -376,7 +734,7 @@ class _NewEntryPageState extends State { final balancePanels = twoCol && tractionEmpty && mileageEmpty; final balancedHeight = balancePanels ? 165.0 : null; - final detailPanel = _section('Details', [ + final entryPanel = _section('Entry', [ Row( children: [ TextButton.icon( @@ -424,31 +782,46 @@ class _NewEntryPageState extends State { ], ), _buildTripSelector(context), + _dateTimeGroup( + context, + title: 'Departure time', + onDateTap: _pickDate, + onTimeTap: _pickTime, + selectedDate: _selectedDate, + selectedTime: _selectedTime, + delayController: _beginDelayController, + singleColumn: isMobile, + ), 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)), - ), + Checkbox( + value: _hasEndTime, + onChanged: _submitting ? null : _toggleEndTime, ), + const Text('Add arrival time'), ], ), + if (_hasEndTime) + _dateTimeGroup( + context, + title: 'Arrival time', + onDateTap: _pickEndDate, + onTimeTap: _pickEndTime, + selectedDate: _selectedEndDate, + selectedTime: _selectedEndTime, + delayController: _endDelayController, + singleColumn: isMobile, + ), if (_useManualMileage) Row( children: [ Expanded( child: TextFormField( controller: _startController, + onChanged: (_) { + _saveDraft(); + _scheduleMatchUpdate(); + }, decoration: const InputDecoration( labelText: 'From', border: OutlineInputBorder(), @@ -462,6 +835,10 @@ class _NewEntryPageState extends State { Expanded( child: TextFormField( controller: _endController, + onChanged: (_) { + _saveDraft(); + _scheduleMatchUpdate(); + }, decoration: const InputDecoration( labelText: 'To', border: OutlineInputBorder(), @@ -473,6 +850,9 @@ class _NewEntryPageState extends State { ), ], ), + ]); + + final trainPanel = _section('Train', [ TextFormField( controller: _headcodeController, textCapitalization: TextCapitalization.characters, @@ -482,6 +862,43 @@ class _NewEntryPageState extends State { border: OutlineInputBorder(), ), ), + _trainLocationBlock( + label: 'Origin', + controller: _originController, + hasTime: _hasOriginTime, + onTimeChanged: _submitting ? null : _toggleOriginTime, + matchLabel: 'Match entry start', + matchValue: _matchOriginToEntry, + onMatchChanged: _submitting ? null : _toggleMatchOrigin, + pickerBuilder: () => _dateTimeGroupSimple( + context, + title: 'Origin departure', + onDateTap: _pickOriginDate, + onTimeTap: _pickOriginTime, + selectedDate: _selectedOriginDate, + selectedTime: _selectedOriginTime, + singleColumn: true, + ), + ), + _trainLocationBlock( + label: 'Destination', + controller: _destinationController, + hasTime: _hasDestinationTime, + onTimeChanged: _submitting ? null : _toggleDestinationTime, + matchLabel: 'Match entry end', + matchValue: _matchDestinationToEntry, + onMatchChanged: + _submitting ? null : _toggleMatchDestination, + pickerBuilder: () => _dateTimeGroupSimple( + context, + title: 'Destination arrival', + onDateTap: _pickDestinationDate, + onTimeTap: _pickDestinationTime, + selectedDate: _selectedDestinationDate, + selectedTime: _selectedDestinationTime, + singleColumn: true, + ), + ), TextFormField( controller: _networkController, textCapitalization: TextCapitalization.characters, @@ -519,14 +936,7 @@ class _NewEntryPageState extends State { 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), - ), + child: ElevatedButton.icon( onPressed: _openCalculator, icon: const Icon(Icons.calculate, size: 18), label: const Text('Open mileage calculator'), @@ -565,6 +975,7 @@ class _NewEntryPageState extends State { onSelected: (val) { setState(() => _useManualMileage = val); _saveDraft(); + _scheduleMatchUpdate(); }, ), minHeight: balancedHeight, @@ -575,7 +986,9 @@ class _NewEntryPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - detailPanel, + entryPanel, + const SizedBox(height: 16), + trainPanel, const SizedBox(height: 16), twoCol ? Row( @@ -736,6 +1149,150 @@ class _NewEntryPageState extends State { ); } + Widget _delayField( + TextEditingController controller, { + required String label, + bool expand = false, + }) { + final field = TextFormField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(signed: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))], + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixText: 'min', + ), + textAlign: TextAlign.right, + onChanged: (_) => _saveDraft(), + ); + if (expand) return field; + return SizedBox(width: 150, child: field); + } + + Widget _stationField({ + required String label, + required TextEditingController controller, + }) { + final stationNames = _stations + .map((s) => s.name.trim()) + .where((name) => name.isNotEmpty) + .toSet() + .toList(); + if (stationNames.isEmpty) { + return TextFormField( + controller: controller, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: _loadingStations + ? const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(4.0), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : const Icon(Icons.search), + ), + onChanged: (_) => _saveDraft(), + ); + } + + Iterable optionsBuilder(TextEditingValue value) { + final query = value.text.trim(); + if (query.isEmpty) return const Iterable.empty(); + return _matchStations(query, stationNames); + } + + return Autocomplete( + optionsBuilder: optionsBuilder, + onSelected: (selection) { + controller.text = selection; + _saveDraft(); + }, + fieldViewBuilder: + (context, textEditingController, focusNode, onFieldSubmitted) { + if (textEditingController.text != controller.text) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (textEditingController.text != controller.text) { + textEditingController.value = controller.value; + } + }); + } + return TextFormField( + controller: textEditingController, + focusNode: focusNode, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: _loadingStations + ? const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(4.0), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : const Icon(Icons.search), + ), + textInputAction: TextInputAction.done, + onChanged: (_) { + controller.value = textEditingController.value; + _saveDraft(); + }, + onFieldSubmitted: (_) { + final matches = _matchStations( + textEditingController.text, + stationNames, + ).toList(); + if (matches.isNotEmpty) { + final top = matches.first; + controller.text = top; + textEditingController.text = top; + _saveDraft(); + } + focusNode.unfocus(); + }, + ); + }, + ); + } + + Iterable _matchStations(String rawQuery, List stationNames) { + final query = rawQuery.toLowerCase(); + final best = []; + for (final name in stationNames) { + if (!name.toLowerCase().contains(query)) continue; + _insertCandidate(best, name, max: 10); + } + return best; + } + + void _insertCandidate(List best, String candidate, {required int max}) { + final existingIndex = best.indexOf(candidate); + if (existingIndex >= 0) return; + + int insertAt = 0; + while (insertAt < best.length && + _candidateCompare(best[insertAt], candidate) <= 0) { + insertAt++; + } + best.insert(insertAt, candidate); + if (best.length > max) best.removeLast(); + } + + int _candidateCompare(String a, String b) { + final byLength = a.length.compareTo(b.length); + if (byLength != 0) return byLength; + return a.compareTo(b); + } + Widget _section( String title, List children, { @@ -781,6 +1338,154 @@ class _NewEntryPageState extends State { return card; } + + Widget _dateTimeGroup( + BuildContext context, { + required String title, + required VoidCallback onDateTap, + required VoidCallback onTimeTap, + required DateTime selectedDate, + required TimeOfDay selectedTime, + required TextEditingController delayController, + bool singleColumn = false, + }) { + final headerStyle = Theme.of(context).textTheme.labelLarge; + final dateButton = OutlinedButton.icon( + onPressed: onDateTap, + icon: const Icon(Icons.calendar_today), + label: Text(DateFormat.yMMMd().format(selectedDate)), + ); + final timeButton = OutlinedButton.icon( + onPressed: onTimeTap, + icon: const Icon(Icons.schedule), + label: Text(selectedTime.format(context)), + ); + final delayField = _delayField( + delayController, + label: 'Delay', + expand: singleColumn, + ); + + if (singleColumn) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: headerStyle), + const SizedBox(height: 6), + SizedBox(width: double.infinity, child: dateButton), + const SizedBox(height: 8), + SizedBox(width: double.infinity, child: timeButton), + const SizedBox(height: 8), + SizedBox(width: double.infinity, child: delayField), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: headerStyle), + const SizedBox(height: 6), + Row( + children: [ + Expanded(child: dateButton), + const SizedBox(width: 12), + Expanded(child: timeButton), + const SizedBox(width: 12), + delayField, + ], + ), + ], + ); + } + + Widget _dateTimeGroupSimple( + BuildContext context, { + required String title, + required VoidCallback onDateTap, + required VoidCallback onTimeTap, + required DateTime selectedDate, + required TimeOfDay selectedTime, + bool singleColumn = true, + }) { + final headerStyle = Theme.of(context).textTheme.labelLarge; + final dateButton = OutlinedButton.icon( + onPressed: onDateTap, + icon: const Icon(Icons.calendar_today), + label: Text(DateFormat.yMMMd().format(selectedDate)), + ); + final timeButton = OutlinedButton.icon( + onPressed: onTimeTap, + icon: const Icon(Icons.schedule), + label: Text(selectedTime.format(context)), + ); + + if (singleColumn) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: headerStyle), + const SizedBox(height: 6), + SizedBox(width: double.infinity, child: dateButton), + const SizedBox(height: 8), + SizedBox(width: double.infinity, child: timeButton), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: headerStyle), + const SizedBox(height: 6), + Row( + children: [ + Expanded(child: dateButton), + const SizedBox(width: 12), + Expanded(child: timeButton), + ], + ), + ], + ); + } + + Widget _timeToggleBlock({ + required String label, + required bool value, + required ValueChanged? onChanged, + required String matchLabel, + required bool matchValue, + required ValueChanged? onMatchChanged, + required bool showMatch, + Widget? picker, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CheckboxListTile( + value: value, + onChanged: onChanged, + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(label), + ), + if (showMatch) + CheckboxListTile( + value: matchValue, + onChanged: onMatchChanged, + dense: true, + contentPadding: const EdgeInsets.only(left: 12), + controlAffinity: ListTileControlAffinity.leading, + title: Text(matchLabel), + ), + if (picker != null) ...[ + const SizedBox(height: 6), + picker, + ], + ], + ); + } } class _UpperCaseTextFormatter extends TextInputFormatter { diff --git a/lib/components/pages/new_entry/new_entry_submit_logic.dart b/lib/components/pages/new_entry/new_entry_submit_logic.dart index db1b542..beafd20 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -62,6 +62,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { ? double.tryParse(_mileageController.text.trim()) ?? 0 : (_routeResult?.distance ?? 0); final tractionPayload = _buildTractionPayload(); + final endTime = _legEndDateTime; + final originTime = _originDateTime; + final destinationTime = _destinationDateTime; + final beginDelay = _parseDelayMinutes(_beginDelayController.text); + final endDelay = + _hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; final snapshot = _buildSubmissionSnapshot( routeStations: routeStations, startVal: startVal, @@ -82,19 +88,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { final isEditingExisting = _isEditing && widget.editLegId != null; try { + final commonPayload = { + if (isEditingExisting) "leg_id": widget.editLegId, + "leg_trip": _selectedTripId, + "leg_begin_time": _legDateTime.toIso8601String(), + if (endTime != null) "leg_end_time": endTime.toIso8601String(), + if (originTime != null) + "leg_origin_time": originTime.toIso8601String(), + if (destinationTime != null) + "leg_destination_time": destinationTime.toIso8601String(), + "leg_notes": _notesController.text.trim(), + "leg_headcode": _headcodeController.text.trim(), + "leg_network": _networkController.text.trim(), + "leg_origin": _originController.text.trim(), + "leg_destination": _destinationController.text.trim(), + "leg_begin_delay": beginDelay, + if (_hasEndTime) "leg_end_delay": endDelay, + "locos": tractionPayload, + }; if (_useManualMileage) { final body = { - if (isEditingExisting) "leg_id": widget.editLegId, - "leg_trip": _selectedTripId, + ...commonPayload, "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); @@ -103,14 +121,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { } } else { final body = { - if (isEditingExisting) "leg_id": widget.editLegId, - "leg_trip": _selectedTripId, - "leg_begin_time": _legDateTime.toIso8601String(), + ...commonPayload, "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); @@ -148,18 +160,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { required double mileageVal, required List> tractionPayload, }) { + final beginDelay = _parseDelayMinutes(_beginDelayController.text); + final endDelay = + _hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; return { "legId": widget.editLegId, "useManualMileage": _useManualMileage, "tripId": _selectedTripId, "legDateTime": _legDateTime.toIso8601String(), + "legEndTime": _legEndDateTime?.toIso8601String(), + "hasEndTime": _hasEndTime, + "legOriginTime": _originDateTime?.toIso8601String(), + "hasOriginTime": _hasOriginTime, + "legDestinationTime": _destinationDateTime?.toIso8601String(), + "hasDestinationTime": _hasDestinationTime, "start": startVal, "end": endVal, + "origin": _originController.text.trim(), + "destination": _destinationController.text.trim(), "routeStations": routeStations, "mileage": mileageVal, "network": _networkController.text.trim(), "notes": _notesController.text.trim(), "headcode": _headcodeController.text.trim(), + "beginDelay": beginDelay, + "endDelay": endDelay, "locos": tractionPayload, "routeResult": _routeResult == null ? null @@ -202,11 +227,27 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { _notesController.clear(); _mileageController.clear(); _networkController.clear(); + _originController.clear(); + _destinationController.clear(); + _beginDelayController.text = '0'; + _endDelayController.text = '0'; final now = DateTime.now(); _setState(() { _selectedDate = now; _selectedTime = TimeOfDay.fromDateTime(now); + _selectedEndDate = now; + _selectedEndTime = TimeOfDay.fromDateTime(now); + _selectedOriginDate = now; + _selectedOriginTime = TimeOfDay.fromDateTime(now); + _selectedDestinationDate = now; + _selectedDestinationTime = TimeOfDay.fromDateTime(now); _useManualMileage = false; + _hasEndTime = false; + _hasOriginTime = false; + _hasDestinationTime = false; + _matchOriginToEntry = false; + _matchDestinationToEntry = false; + _matchUpdateScheduled = false; _routeResult = null; _tractionItems ..clear() diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index f9aa921..cfd0859 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -27,10 +27,6 @@ class _TripsPageState extends State { _tripLocoStatsFutures.clear(); final data = context.read(); await data.fetchTripDetails(); - if (!mounted) return; - for (final trip in data.tripDetails) { - _tripStatsFuture(trip.id); - } } Future _renameTrip(TripDetail trip, String newName) async { @@ -42,10 +38,7 @@ class _TripsPageState extends State { "trip_id": trip.id, "trip_name": newName, }); - await Future.wait([ - data.fetchTripDetails(), - data.fetchTrips(), - ]); + await data.fetchTripDetails(); } catch (e) { messenger?.showSnackBar( SnackBar(content: Text('Failed to rename trip: $e')), @@ -54,10 +47,24 @@ class _TripsPageState extends State { } } - Future> _tripStatsFuture(int tripId) { + List _cachedTripStats( + TripDetail trip, + TripSummary? summary, + ) { + if (trip.locoStats.isNotEmpty) return trip.locoStats; + if (summary?.locoStats.isNotEmpty == true) return summary!.locoStats; + return const []; + } + + Future> _loadTripStats( + TripDetail trip, + TripSummary? summary, + ) { + final cached = _cachedTripStats(trip, summary); + if (cached.isNotEmpty) return Future.value(cached); return _tripLocoStatsFutures.putIfAbsent( - tripId, - () => context.read().fetchTripLocoStats(tripId), + trip.id, + () => context.read().fetchTripLocoStats(trip.id), ); } @@ -93,7 +100,10 @@ class _TripsPageState extends State { Widget build(BuildContext context) { final data = context.watch(); final tripDetails = data.tripDetails; - final tripSummaries = data.trips; + final tripSummaries = data.tripList; + final summaryById = { + for (final summary in tripSummaries) summary.tripId: summary, + }; final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty; return RefreshIndicator( @@ -184,18 +194,25 @@ class _TripsPageState extends State { } final trip = tripDetails[index - 1]; - return _buildTripCard(context, trip); + final summary = summaryById[trip.id]; + return _buildTripCard(context, trip, summary); }, ), ); } - Widget _buildTripCard(BuildContext context, TripDetail trip) { + Widget _buildTripCard( + BuildContext context, + TripDetail trip, + TripSummary? summary, + ) { final legs = trip.legs; - final legCount = trip.legCount > 0 ? trip.legCount : legs.length; + final legCount = + trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length; final dateRange = _formatDateRange(legs); final endpoints = _formatEndpoints(legs); - final statsFuture = _tripStatsFuture(trip.id); + final stats = _cachedTripStats(trip, summary); + final winnerCount = stats.where((e) => e.won).length; return Card( child: Padding( @@ -245,50 +262,25 @@ class _TripsPageState extends State { ], ), const SizedBox(height: 12), - FutureBuilder>( - future: statsFuture, - builder: (context, snapshot) { - final chips = [ - _buildMetaChip(context, Icons.timeline, '$legCount legs'), - if (dateRange != null) - _buildMetaChip(context, Icons.calendar_month, dateRange), - if (endpoints != null) - _buildMetaChip(context, Icons.route, endpoints), - ]; - - final stats = snapshot.data ?? const []; - final hasStats = stats.isNotEmpty; - final loading = - snapshot.connectionState == ConnectionState.waiting; - - if (loading && !hasStats) { - chips.add( - _buildMetaChip(context, Icons.train, 'Loading traction...'), - ); - } else if (hasStats) { - final winnerCount = stats.where((e) => e.won).length; - chips.add( - _buildMetaChip(context, Icons.train, '${stats.length} had'), - ); - chips.add( - _buildMetaChip( - context, - Icons.emoji_events_outlined, - '$winnerCount winners', - ), - ); - } else if (snapshot.connectionState == ConnectionState.done) { - chips.add( - _buildMetaChip(context, Icons.train, 'No traction yet'), - ); - } - - return Wrap( - spacing: 8, - runSpacing: 8, - children: chips, - ); - }, + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildMetaChip(context, Icons.timeline, '$legCount legs'), + if (dateRange != null) + _buildMetaChip(context, Icons.calendar_month, dateRange), + if (endpoints != null) + _buildMetaChip(context, Icons.route, endpoints), + if (stats.isNotEmpty) ...[ + _buildMetaChip(context, Icons.train, '${stats.length} had'), + _buildMetaChip( + context, + Icons.emoji_events_outlined, + '$winnerCount winners', + ), + ] else + _buildMetaChip(context, Icons.train, 'No traction yet'), + ], ), const SizedBox(height: 12), Align( @@ -301,7 +293,7 @@ class _TripsPageState extends State { OutlinedButton.icon( icon: const Icon(Icons.train), label: const Text('Locos'), - onPressed: () => _showTripWinners(context, trip), + onPressed: () => _showTripWinners(context, trip, summary), ), FilledButton.icon( icon: const Icon(Icons.open_in_new), @@ -532,14 +524,19 @@ class _TripsPageState extends State { ); } - void _showTripWinners(BuildContext context, TripDetail trip) { + void _showTripWinners( + BuildContext context, + TripDetail trip, + TripSummary? summary, + ) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) { return SafeArea( child: FutureBuilder>( - future: _tripStatsFuture(trip.id), + future: _loadTripStats(trip, summary), + initialData: _cachedTripStats(trip, summary), builder: (ctx, snapshot) { final items = snapshot.data ?? []; final loading = diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 8cae07b..b5fd0d1 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -20,6 +22,35 @@ String _asString(dynamic value, [String fallback = '']) { return (str == null) ? fallback : str; } +List _asStringList(dynamic value) { + if (value is List) { + return value.map((e) => e.toString()).toList(); + } + final trimmed = value?.toString().trim() ?? ''; + if (trimmed.isEmpty) return const []; + try { + final decoded = jsonDecode(trimmed); + 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]; +} + bool _asBool(dynamic value, [bool fallback = false]) { if (value is bool) return value; if (value is num) return value != 0; @@ -487,25 +518,45 @@ class TripSummary { final int tripId; final String tripName; final double tripMileage; + final int legCount; + final List locoStats; + + int get locoHadCount => locoStats.length; + int get winnersCount => locoStats.where((e) => e.won).length; TripSummary({ required this.tripId, required this.tripName, required this.tripMileage, - }); + this.legCount = 0, + List? locoStats, + }) : locoStats = locoStats ?? const []; factory TripSummary.fromJson(Map json) => TripSummary( tripId: _asInt(json['trip_id']), tripName: _asString(json['trip_name']), tripMileage: _asDouble(json['trip_mileage']), + legCount: _asInt( + json['leg_count'], + (json['trip_legs'] as List?)?.length ?? 0, + ), + locoStats: TripLocoStat.listFromJson( + json['stats'] ?? json['trip_locos'] ?? json['locos'], + ), ); } class Leg { final int id, tripId, timezone, driving; - final String start, end, route, network, notes, headcode, user; + final String start, end, network, notes, headcode, user; + final String origin, destination; + final List route; final DateTime beginTime; + final DateTime? endTime; + final DateTime? originTime; + final DateTime? destinationTime; final double mileage; + final int? beginDelayMinutes, endDelayMinutes; final List locos; Leg({ @@ -523,27 +574,55 @@ class Leg { required this.driving, required this.user, required this.locos, + this.endTime, + this.originTime, + this.destinationTime, + this.beginDelayMinutes, + this.endDelayMinutes, + this.origin = '', + this.destination = '', }); - factory Leg.fromJson(Map json) => Leg( - id: _asInt(json['leg_id']), - tripId: _asInt(json['leg_trip']), - start: _asString(json['leg_start']), - end: _asString(json['leg_end']), - beginTime: _asDateTime(json['leg_begin_time']), - timezone: _asInt(json['leg_timezone']), - network: _asString(json['leg_network']), - route: _asString(json['leg_route']), - mileage: _asDouble(json['leg_mileage']), - notes: _asString(json['leg_notes']), - headcode: _asString(json['leg_headcode']), - driving: _asInt(json['leg_driving']), - user: _asString(json['leg_user']), - locos: (json['locos'] is List ? (json['locos'] as List) : const []) - .whereType() - .map((e) => Loco.fromJson(Map.from(e))) - .toList(), - ); + factory Leg.fromJson(Map json) { + final endTimeRaw = json['leg_end_time']; + final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty) + ? null + : _asDateTime(endTimeRaw); + return Leg( + id: _asInt(json['leg_id']), + tripId: _asInt(json['leg_trip']), + start: _asString(json['leg_start']), + end: _asString(json['leg_end']), + beginTime: _asDateTime(json['leg_begin_time']), + endTime: parsedEndTime, + originTime: json['leg_origin_time'] == null + ? null + : _asDateTime(json['leg_origin_time']), + destinationTime: json['leg_destination_time'] == null + ? null + : _asDateTime(json['leg_destination_time']), + timezone: _asInt(json['leg_timezone']), + network: _asString(json['leg_network']), + route: _asStringList(json['leg_route']), + mileage: _asDouble(json['leg_mileage']), + notes: _asString(json['leg_notes']), + headcode: _asString(json['leg_headcode']), + driving: _asInt(json['leg_driving']), + user: _asString(json['leg_user']), + locos: (json['locos'] is List ? (json['locos'] as List) : const []) + .whereType() + .map((e) => Loco.fromJson(Map.from(e))) + .toList(), + beginDelayMinutes: json['leg_begin_delay'] == null + ? null + : _asInt(json['leg_begin_delay']), + endDelayMinutes: json['leg_end_delay'] == null + ? null + : _asInt(json['leg_end_delay']), + origin: _asString(json['leg_origin']), + destination: _asString(json['leg_destination']), + ); + } } class RouteError { @@ -625,17 +704,23 @@ class TripLeg { }); factory TripLeg.fromJson(Map json) => TripLeg( - id: json['leg_id'], - start: json['leg_start'] ?? '', - end: json['leg_end'] ?? '', + id: _asInt(json['leg_id']), + start: _asString(json['leg_start']), + end: _asString(json['leg_end']), beginTime: json['leg_begin_time'] != null && json['leg_begin_time'] is String ? DateTime.tryParse(json['leg_begin_time']) : (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null), - network: json['leg_network'], - route: json['leg_route'], + network: _asString(json['leg_network'], ''), + route: () { + final route = json['leg_route']; + if (route is List) { + return route.whereType().join(' → '); + } + return _asString(route, ''); + }(), mileage: (json['leg_mileage'] as num?)?.toDouble(), - notes: json['leg_notes'], + notes: _asString(json['leg_notes'], ''), locos: (json['locos'] as List?) ?.map((e) => Loco.fromJson(e as Map)) @@ -649,21 +734,32 @@ class TripDetail { final String name; final double mileage; final int legCount; + final List locoStats; final List legs; + int get locoHadCount => locoStats.length; + int get winnersCount => locoStats.where((e) => e.won).length; + TripDetail({ required this.id, required this.name, required this.mileage, required this.legCount, required this.legs, - }); + List? locoStats, + }) : locoStats = locoStats ?? const []; factory TripDetail.fromJson(Map json) => TripDetail( id: json['trip_id'] ?? json['id'] ?? 0, name: json['trip_name'] ?? '', mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0, - legCount: json['leg_count'] ?? ((json['trip_legs'] as List?)?.length ?? 0), + legCount: _asInt( + json['leg_count'], + (json['trip_legs'] as List?)?.length ?? 0, + ), + locoStats: TripLocoStat.listFromJson( + json['stats'] ?? json['trip_locos'] ?? json['locos'], + ), legs: (json['trip_legs'] as List?) ?.map((e) => TripLeg.fromJson(e as Map)) @@ -712,6 +808,26 @@ class TripLocoStat { ); } + static List listFromJson(dynamic json) { + List? list; + if (json is List) { + list = json.expand((e) => e is List ? e : [e]).toList(); + } else if (json is Map) { + for (final key in ['locos', 'stats', 'data', 'trip_locos']) { + final candidate = json[key]; + if (candidate is List) { + list = candidate.expand((e) => e is List ? e : [e]).toList(); + break; + } + } + } + if (list == null) return const []; + return list + .whereType() + .map((e) => TripLocoStat.fromJson(Map.from(e))) + .toList(); + } + static bool _parseWonFlag(dynamic value) { if (value == null) return false; if (value is bool) return value; diff --git a/lib/services/data_service/data_service_trips.dart b/lib/services/data_service/data_service_trips.dart index f5f78c0..a2332b7 100644 --- a/lib/services/data_service/data_service_trips.dart +++ b/lib/services/data_service/data_service_trips.dart @@ -4,16 +4,25 @@ extension DataServiceTrips on DataService { Future fetchTripDetails() async { _isTripDetailsLoading = true; try { - final json = await api.get('/trips/legs-and-stats'); - if (json is List) { - final tripMap = json.map((e) => TripDetail.fromJson(e)).toList(); - _tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id)); - } else { - _tripDetails = []; - } + final json = await api.get('/trips/info'); + final tripDetails = _parseTripInfoList(json); + _tripDetails = [...tripDetails]..sort((a, b) => b.id.compareTo(a.id)); + _tripList = tripDetails + .map( + (detail) => TripSummary( + tripId: detail.id, + tripName: detail.name, + tripMileage: detail.mileage, + legCount: detail.legCount, + locoStats: detail.locoStats, + ), + ) + .toList() + ..sort((a, b) => b.tripId.compareTo(a.tripId)); } catch (e) { debugPrint('Failed to fetch trip_map: $e'); _tripDetails = []; + _tripList = []; } finally { _isTripDetailsLoading = false; _notifyAsync(); @@ -23,48 +32,17 @@ extension DataServiceTrips on DataService { Future> fetchTripLocoStats(int tripId) async { try { final json = await api.get('/trips/stats/$tripId'); - return _parseTripLocoStats(json); + return TripLocoStat.listFromJson(json); } catch (e) { debugPrint('Failed to fetch trip loco stats: $e'); return []; } } - List _parseTripLocoStats(dynamic json) { - List? list; - if (json is List) { - list = json.expand((e) => e is List ? e : [e]).toList(); - } else if (json is Map) { - for (final key in ['locos', 'stats', 'data', 'trip_locos']) { - final candidate = json[key]; - if (candidate is List) { - list = candidate.expand((e) => e is List ? e : [e]).toList(); - break; - } - } - } - if (list == null) return []; - return list - .whereType>() - .map((e) => TripLocoStat.fromJson(e)) - .toList(); - } - Future fetchTrips() async { try { - final json = await api.get('/trips/mileage'); - Iterable? raw; - if (json is List) { - raw = json; - } else if (json is Map) { - for (final key in ['trips', 'trip_data', 'data']) { - final value = json[key]; - if (value is List) { - raw = value; - break; - } - } - } + final json = await api.get('/trips/info'); + final raw = _extractTrips(json); if (raw != null) { final tripMap = raw .whereType>() @@ -119,8 +97,9 @@ extension DataServiceTrips on DataService { } void upsertTripSummary(TripSummary trip) { - final existingIndex = - _tripList.indexWhere((element) => element.tripId == trip.tripId); + final existingIndex = _tripList.indexWhere( + (element) => element.tripId == trip.tripId, + ); if (existingIndex >= 0) { _tripList[existingIndex] = trip; } else { @@ -129,4 +108,24 @@ extension DataServiceTrips on DataService { _tripList.sort((a, b) => b.tripId.compareTo(a.tripId)); _notifyAsync(); } + + Iterable? _extractTrips(dynamic json) { + if (json is List) return json; + if (json is Map) { + for (final key in ['trips', 'trip_data', 'data', 'trip_info']) { + final value = json[key]; + if (value is List) return value; + } + } + return null; + } + + List _parseTripInfoList(dynamic json) { + final raw = _extractTrips(json); + if (raw == null) return const []; + return raw + .whereType>() + .map((e) => TripDetail.fromJson(e)) + .toList(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index f1be19a..94b0661 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.4.5+1 +version: 0.5.0+1 environment: sdk: ^3.8.1