support new fields in adding
All checks were successful
All checks were successful
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:mileograph_flutter/objects/objects.dart';
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
@@ -38,19 +36,72 @@ class _LegCardState extends State<LegCard> {
|
|||||||
title: LayoutBuilder(
|
title: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth > 520;
|
final isWide = constraints.maxWidth > 520;
|
||||||
final routeText = Text('${leg.start} → ${leg.end}');
|
final beginTimeWidget = _timeWithDelay(
|
||||||
final timeText =
|
context,
|
||||||
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
|
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) {
|
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: [
|
children: [
|
||||||
timeText,
|
beginTimeWidget,
|
||||||
const SizedBox(width: 6),
|
|
||||||
const Text('·'),
|
const Text('·'),
|
||||||
const SizedBox(width: 6),
|
routeText,
|
||||||
Expanded(child: routeText),
|
if (endTimeWidget != null) ...[
|
||||||
|
const Text('·'),
|
||||||
|
endTimeWidget,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -58,8 +109,12 @@ class _LegCardState extends State<LegCard> {
|
|||||||
subtitle: LayoutBuilder(
|
subtitle: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth > 520;
|
final isWide = constraints.maxWidth > 520;
|
||||||
final timeWidget =
|
final timeWidget = _timeWithDelay(
|
||||||
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
|
context,
|
||||||
|
leg.beginTime,
|
||||||
|
leg.beginDelayMinutes,
|
||||||
|
includeDate: widget.showDate,
|
||||||
|
);
|
||||||
final tractionWrap = !_expanded && leg.locos.isNotEmpty
|
final tractionWrap = !_expanded && leg.locos.isNotEmpty
|
||||||
? Wrap(
|
? Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -90,9 +145,7 @@ class _LegCardState extends State<LegCard> {
|
|||||||
children.add(tractionWrap);
|
children.add(tractionWrap);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
children.add(timeWidget);
|
|
||||||
if (tractionWrap != null) {
|
if (tractionWrap != null) {
|
||||||
children.add(const SizedBox(height: 4));
|
|
||||||
children.add(tractionWrap);
|
children.add(tractionWrap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +244,12 @@ class _LegCardState extends State<LegCard> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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) ...[
|
if (routeSegments.isNotEmpty) ...[
|
||||||
Text('Route', style: textTheme.titleSmall),
|
Text('Route', style: textTheme.titleSmall),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -238,6 +297,40 @@ class _LegCardState extends State<LegCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
String _formatDate(DateTime? date) {
|
||||||
if (date == null) return '';
|
if (date == null) return '';
|
||||||
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
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<LegCard> {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _hasTrainDetails(Leg leg) {
|
||||||
|
return leg.headcode.isNotEmpty ||
|
||||||
|
leg.origin.isNotEmpty ||
|
||||||
|
leg.destination.isNotEmpty ||
|
||||||
|
leg.originTime != null ||
|
||||||
|
leg.destinationTime != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildTrainDetails(Leg leg, TextTheme textTheme) {
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
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 = <String>[];
|
||||||
|
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<String> segments) {
|
Widget _buildRouteList(List<String> segments) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -306,38 +444,7 @@ class _LegCardState extends State<LegCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _parseRouteSegments(String route) {
|
List<String> _parseRouteSegments(List<String> route) {
|
||||||
final trimmed = route.trim();
|
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ class LogbookPage extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
TabBar(
|
TabBar(
|
||||||
onTap: (index) {
|
onTap: (index) {
|
||||||
final dest =
|
final dest = index == 0 ? '/logbook/entries' : '/logbook/trips';
|
||||||
index == 0 ? '/logbook/entries' : '/logbook/trips';
|
|
||||||
final current = GoRouterState.of(context).uri.path;
|
final current = GoRouterState.of(context).uri.path;
|
||||||
if (current != dest) {
|
if (current != dest) {
|
||||||
context.go(dest);
|
context.go(dest);
|
||||||
@@ -34,12 +33,7 @@ class LogbookPage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(children: const [LegsPage(), TripsPage()]),
|
||||||
children: const [
|
|
||||||
LegsPage(),
|
|
||||||
TripsPage(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
if (choice == _ExitChoice.save) {
|
if (choice == _ExitChoice.save) {
|
||||||
await _saveDraftEntry(draftId: _activeDraftId);
|
await _saveDraftEntry(draftId: _activeDraftId);
|
||||||
} else if (choice == _ExitChoice.discard) {
|
} else if (choice == _ExitChoice.discard) {
|
||||||
|
// Delay reset to avoid setState during the dialog/build phase.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
if (!mounted) return false;
|
||||||
await _resetFormState(clearDraft: true);
|
await _resetFormState(clearDraft: true);
|
||||||
_activeDraftId = null;
|
_activeDraftId = null;
|
||||||
}
|
}
|
||||||
@@ -29,12 +32,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _formIsEmpty() {
|
bool _formIsEmpty() {
|
||||||
|
final beginDelayVal = _parseDelayMinutes(_beginDelayController.text);
|
||||||
|
final endDelayVal = _parseDelayMinutes(_endDelayController.text);
|
||||||
return _startController.text.trim().isEmpty &&
|
return _startController.text.trim().isEmpty &&
|
||||||
_endController.text.trim().isEmpty &&
|
_endController.text.trim().isEmpty &&
|
||||||
_headcodeController.text.trim().isEmpty &&
|
_headcodeController.text.trim().isEmpty &&
|
||||||
_notesController.text.trim().isEmpty &&
|
_notesController.text.trim().isEmpty &&
|
||||||
_networkController.text.trim().isEmpty &&
|
_networkController.text.trim().isEmpty &&
|
||||||
_mileageController.text.trim().isEmpty &&
|
_mileageController.text.trim().isEmpty &&
|
||||||
|
_originController.text.trim().isEmpty &&
|
||||||
|
_destinationController.text.trim().isEmpty &&
|
||||||
|
beginDelayVal == 0 &&
|
||||||
|
endDelayVal == 0 &&
|
||||||
|
!_hasOriginTime &&
|
||||||
|
!_hasDestinationTime &&
|
||||||
|
!_hasEndTime &&
|
||||||
_routeResult == null &&
|
_routeResult == null &&
|
||||||
_tractionItems.length <= 1;
|
_tractionItems.length <= 1;
|
||||||
}
|
}
|
||||||
@@ -122,6 +134,30 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
"notes": _notesController.text,
|
"notes": _notesController.text,
|
||||||
"mileage": _mileageController.text,
|
"mileage": _mileageController.text,
|
||||||
"network": _networkController.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,
|
"useManualMileage": _useManualMileage,
|
||||||
"selectedTripId": _selectedTripId,
|
"selectedTripId": _selectedTripId,
|
||||||
"routeResult": _routeResult == null
|
"routeResult": _routeResult == null
|
||||||
@@ -200,6 +236,12 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
bool includeTimestamp = true,
|
bool includeTimestamp = true,
|
||||||
}) {
|
}) {
|
||||||
final routeStations = _routeResult?.calculatedRoute ?? [];
|
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
|
final startVal = _useManualMileage
|
||||||
? _startController.text.trim()
|
? _startController.text.trim()
|
||||||
: (routeStations.isNotEmpty ? routeStations.first : '');
|
: (routeStations.isNotEmpty ? routeStations.first : '');
|
||||||
@@ -210,27 +252,33 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
? double.tryParse(_mileageController.text.trim()) ?? 0
|
? double.tryParse(_mileageController.text.trim()) ?? 0
|
||||||
: (_routeResult?.distance ?? 0);
|
: (_routeResult?.distance ?? 0);
|
||||||
final tractionPayload = _buildTractionPayload();
|
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
|
final payload = _useManualMileage
|
||||||
? {
|
? {
|
||||||
"leg_trip": _selectedTripId,
|
...commonPayload,
|
||||||
"leg_start": startVal,
|
"leg_start": startVal,
|
||||||
"leg_end": endVal,
|
"leg_end": endVal,
|
||||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
||||||
"leg_network": _networkController.text.trim(),
|
|
||||||
"leg_distance": mileageVal,
|
"leg_distance": mileageVal,
|
||||||
"isKilometers": false,
|
"isKilometers": false,
|
||||||
"leg_notes": _notesController.text.trim(),
|
|
||||||
"leg_headcode": _headcodeController.text.trim(),
|
|
||||||
"locos": tractionPayload,
|
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
"leg_trip": _selectedTripId,
|
...commonPayload,
|
||||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
||||||
"leg_route": routeStations,
|
"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,
|
"leg_mileage": _routeResult?.distance ?? mileageVal,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
@@ -265,6 +313,29 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
final beginTime = beginStr == null
|
final beginTime = beginStr == null
|
||||||
? DateTime.now()
|
? DateTime.now()
|
||||||
: DateTime.tryParse(beginStr) ?? 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 tripRaw = payload['leg_trip'];
|
||||||
final tripId = tripRaw is num ? tripRaw.toInt() : null;
|
final tripId = tripRaw is num ? tripRaw.toInt() : null;
|
||||||
|
|
||||||
@@ -312,6 +383,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
_useManualMileage = useManual;
|
_useManualMileage = useManual;
|
||||||
_selectedDate = beginTime;
|
_selectedDate = beginTime;
|
||||||
_selectedTime = TimeOfDay.fromDateTime(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;
|
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
||||||
_routeResult = restoredRouteResult;
|
_routeResult = restoredRouteResult;
|
||||||
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
|
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
|
||||||
@@ -319,6 +405,10 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
_networkController.text = (payload['leg_network'] as String? ?? '')
|
_networkController.text = (payload['leg_network'] as String? ?? '')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
_notesController.text = payload['leg_notes'] ?? '';
|
_notesController.text = payload['leg_notes'] ?? '';
|
||||||
|
_originController.text = origin;
|
||||||
|
_destinationController.text = destination;
|
||||||
|
_beginDelayController.text = beginDelay.toString();
|
||||||
|
_endDelayController.text = endDelay.toString();
|
||||||
|
|
||||||
if (useManual) {
|
if (useManual) {
|
||||||
_startController.text = payload['leg_start'] ?? '';
|
_startController.text = payload['leg_start'] ?? '';
|
||||||
@@ -359,6 +449,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
includeTimestamp: false,
|
includeTimestamp: false,
|
||||||
);
|
);
|
||||||
_restoringDraft = false;
|
_restoringDraft = false;
|
||||||
|
_scheduleMatchUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadDraft() async {
|
Future<void> _loadDraft() async {
|
||||||
|
|||||||
@@ -14,15 +14,32 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
DateTime _selectedDate = DateTime.now();
|
DateTime _selectedDate = DateTime.now();
|
||||||
TimeOfDay _selectedTime = TimeOfDay.now();
|
TimeOfDay _selectedTime = TimeOfDay.now();
|
||||||
|
DateTime _selectedEndDate = DateTime.now();
|
||||||
|
TimeOfDay _selectedEndTime = TimeOfDay.now();
|
||||||
final _startController = TextEditingController();
|
final _startController = TextEditingController();
|
||||||
final _endController = TextEditingController();
|
final _endController = TextEditingController();
|
||||||
final _headcodeController = TextEditingController();
|
final _headcodeController = TextEditingController();
|
||||||
final _notesController = TextEditingController();
|
final _notesController = TextEditingController();
|
||||||
final _mileageController = TextEditingController();
|
final _mileageController = TextEditingController();
|
||||||
final _networkController = 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 _submitting = false;
|
||||||
bool _useManualMileage = false;
|
bool _useManualMileage = false;
|
||||||
|
bool _hasEndTime = false;
|
||||||
|
bool _matchOriginToEntry = false;
|
||||||
|
bool _matchDestinationToEntry = false;
|
||||||
RouteResult? _routeResult;
|
RouteResult? _routeResult;
|
||||||
|
List<Station> _stations = const [];
|
||||||
|
bool _loadingStations = false;
|
||||||
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
|
||||||
int? _selectedTripId;
|
int? _selectedTripId;
|
||||||
bool _restoringDraft = false;
|
bool _restoringDraft = false;
|
||||||
@@ -50,9 +67,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
data.fetchClassList();
|
data.fetchClassList();
|
||||||
data.fetchTripOptions();
|
data.fetchTripOptions();
|
||||||
|
_loadStations();
|
||||||
if (_draftPersistenceEnabled) {
|
if (_draftPersistenceEnabled) {
|
||||||
_loadDraft();
|
_loadDraft();
|
||||||
}
|
}
|
||||||
|
_loadStations();
|
||||||
if (_isEditing && widget.editLegId != null) {
|
if (_isEditing && widget.editLegId != null) {
|
||||||
_loadLegForEdit(widget.editLegId!);
|
_loadLegForEdit(widget.editLegId!);
|
||||||
}
|
}
|
||||||
@@ -68,6 +87,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
_mileageController.dispose();
|
_mileageController.dispose();
|
||||||
_networkController.dispose();
|
_networkController.dispose();
|
||||||
|
_originController.dispose();
|
||||||
|
_destinationController.dispose();
|
||||||
|
_beginDelayController.dispose();
|
||||||
|
_endDelayController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +159,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()),
|
onPressed: () =>
|
||||||
|
Navigator.of(dialogContext).pop(controller.text.trim()),
|
||||||
child: const Text('Add'),
|
child: const Text('Add'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -147,15 +171,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result != null && result.isNotEmpty) {
|
if (result != null && result.isNotEmpty) {
|
||||||
final api = context.read<ApiService>();
|
final api = context.read<ApiService>();
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
try {
|
try {
|
||||||
final encoded = Uri.encodeComponent(result);
|
final encoded = Uri.encodeComponent(result);
|
||||||
final res = await api.put('/trips/new?trip_name=$encoded', {});
|
final res = await api.put('/trips/new?trip_name=$encoded', {});
|
||||||
await data.fetchTripOptions();
|
await data.fetchTripOptions();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final trips = data.tripList;
|
final trips = data.tripList;
|
||||||
final apiTripId = res is Map ? res['trip_id'] as int? : null;
|
final apiTripId = res is Map ? res['trip_id'] as int? : null;
|
||||||
TripSummary match;
|
TripSummary match;
|
||||||
try {
|
try {
|
||||||
@@ -206,6 +230,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_useManualMileage = false;
|
_useManualMileage = false;
|
||||||
});
|
});
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +243,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
);
|
);
|
||||||
if (picked != null) setState(() => _selectedDate = picked);
|
if (picked != null) setState(() => _selectedDate = picked);
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickTime() async {
|
Future<void> _pickTime() async {
|
||||||
@@ -229,6 +255,158 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
setState(() => _selectedTime = picked);
|
setState(() => _selectedTime = picked);
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
}
|
}
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _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<void> _pickOriginTime() async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _selectedOriginTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _selectedOriginTime = picked);
|
||||||
|
_saveDraft();
|
||||||
|
}
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _loadStations() async {
|
||||||
|
if (_loadingStations) return;
|
||||||
|
setState(() => _loadingStations = true);
|
||||||
|
try {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
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<void> _loadLegForEdit(int legId) async {
|
Future<void> _loadLegForEdit(int legId) async {
|
||||||
@@ -245,6 +423,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
}
|
}
|
||||||
final beginTime =
|
final beginTime =
|
||||||
DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate;
|
DateTime.tryParse(json['leg_begin_time'] ?? '') ?? _selectedDate;
|
||||||
|
final endTime = DateTime.tryParse(json['leg_end_time'] ?? '');
|
||||||
final routeStations = _parseRouteStations(json['leg_route']);
|
final routeStations = _parseRouteStations(json['leg_route']);
|
||||||
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
|
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
|
||||||
final useManual = routeStations.isEmpty;
|
final useManual = routeStations.isEmpty;
|
||||||
@@ -262,6 +441,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
.map((e) => Map<String, dynamic>.from(e))
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
.toList(),
|
.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;
|
_restoringDraft = true;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -270,6 +459,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
|
||||||
_selectedDate = beginTime;
|
_selectedDate = beginTime;
|
||||||
_selectedTime = TimeOfDay.fromDateTime(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;
|
_useManualMileage = useManual;
|
||||||
_routeResult = routeResult;
|
_routeResult = routeResult;
|
||||||
_startController.text = json['leg_start'] ?? '';
|
_startController.text = json['leg_start'] ?? '';
|
||||||
@@ -279,6 +478,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_notesController.text = json['leg_notes'] ?? '';
|
_notesController.text = json['leg_notes'] ?? '';
|
||||||
_networkController.text = (json['leg_network'] as String? ?? '')
|
_networkController.text = (json['leg_network'] as String? ?? '')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
_originController.text = origin;
|
||||||
|
_destinationController.text = destination;
|
||||||
|
_beginDelayController.text = beginDelay.toString();
|
||||||
|
_endDelayController.text = endDelay.toString();
|
||||||
_mileageController.text = mileageVal == 0
|
_mileageController.text = mileageVal == 0
|
||||||
? ''
|
? ''
|
||||||
: mileageVal.toStringAsFixed(2);
|
: mileageVal.toStringAsFixed(2);
|
||||||
@@ -357,6 +560,161 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
_selectedTime.minute,
|
_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<bool?>? onTimeChanged,
|
||||||
|
required String matchLabel,
|
||||||
|
required bool matchValue,
|
||||||
|
required ValueChanged<bool?>? 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Widget body;
|
Widget body;
|
||||||
@@ -376,7 +734,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
final balancePanels = twoCol && tractionEmpty && mileageEmpty;
|
final balancePanels = twoCol && tractionEmpty && mileageEmpty;
|
||||||
final balancedHeight = balancePanels ? 165.0 : null;
|
final balancedHeight = balancePanels ? 165.0 : null;
|
||||||
|
|
||||||
final detailPanel = _section('Details', [
|
final entryPanel = _section('Entry', [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
@@ -424,31 +782,46 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
_buildTripSelector(context),
|
_buildTripSelector(context),
|
||||||
|
_dateTimeGroup(
|
||||||
|
context,
|
||||||
|
title: 'Departure time',
|
||||||
|
onDateTap: _pickDate,
|
||||||
|
onTimeTap: _pickTime,
|
||||||
|
selectedDate: _selectedDate,
|
||||||
|
selectedTime: _selectedTime,
|
||||||
|
delayController: _beginDelayController,
|
||||||
|
singleColumn: isMobile,
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Checkbox(
|
||||||
child: OutlinedButton.icon(
|
value: _hasEndTime,
|
||||||
onPressed: _pickDate,
|
onChanged: _submitting ? null : _toggleEndTime,
|
||||||
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)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
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)
|
if (_useManualMileage)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _startController,
|
controller: _startController,
|
||||||
|
onChanged: (_) {
|
||||||
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
},
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'From',
|
labelText: 'From',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -462,6 +835,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _endController,
|
controller: _endController,
|
||||||
|
onChanged: (_) {
|
||||||
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
|
},
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'To',
|
labelText: 'To',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -473,6 +850,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final trainPanel = _section('Train', [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _headcodeController,
|
controller: _headcodeController,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
@@ -482,6 +862,43 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
border: OutlineInputBorder(),
|
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(
|
TextFormField(
|
||||||
controller: _networkController,
|
controller: _networkController,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
@@ -519,14 +936,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
if (!_useManualMileage)
|
if (!_useManualMileage)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: TextButton.icon(
|
child: ElevatedButton.icon(
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
minimumSize: const Size(0, 32),
|
|
||||||
),
|
|
||||||
onPressed: _openCalculator,
|
onPressed: _openCalculator,
|
||||||
icon: const Icon(Icons.calculate, size: 18),
|
icon: const Icon(Icons.calculate, size: 18),
|
||||||
label: const Text('Open mileage calculator'),
|
label: const Text('Open mileage calculator'),
|
||||||
@@ -565,6 +975,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
onSelected: (val) {
|
onSelected: (val) {
|
||||||
setState(() => _useManualMileage = val);
|
setState(() => _useManualMileage = val);
|
||||||
_saveDraft();
|
_saveDraft();
|
||||||
|
_scheduleMatchUpdate();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
minHeight: balancedHeight,
|
minHeight: balancedHeight,
|
||||||
@@ -575,7 +986,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
detailPanel,
|
entryPanel,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
trainPanel,
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
twoCol
|
twoCol
|
||||||
? Row(
|
? Row(
|
||||||
@@ -736,6 +1149,150 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<String> optionsBuilder(TextEditingValue value) {
|
||||||
|
final query = value.text.trim();
|
||||||
|
if (query.isEmpty) return const Iterable<String>.empty();
|
||||||
|
return _matchStations(query, stationNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Autocomplete<String>(
|
||||||
|
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<String> _matchStations(String rawQuery, List<String> stationNames) {
|
||||||
|
final query = rawQuery.toLowerCase();
|
||||||
|
final best = <String>[];
|
||||||
|
for (final name in stationNames) {
|
||||||
|
if (!name.toLowerCase().contains(query)) continue;
|
||||||
|
_insertCandidate(best, name, max: 10);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertCandidate(List<String> 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(
|
Widget _section(
|
||||||
String title,
|
String title,
|
||||||
List<Widget> children, {
|
List<Widget> children, {
|
||||||
@@ -781,6 +1338,154 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
|
|
||||||
return card;
|
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<bool?>? onChanged,
|
||||||
|
required String matchLabel,
|
||||||
|
required bool matchValue,
|
||||||
|
required ValueChanged<bool?>? 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 {
|
class _UpperCaseTextFormatter extends TextInputFormatter {
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
? double.tryParse(_mileageController.text.trim()) ?? 0
|
? double.tryParse(_mileageController.text.trim()) ?? 0
|
||||||
: (_routeResult?.distance ?? 0);
|
: (_routeResult?.distance ?? 0);
|
||||||
final tractionPayload = _buildTractionPayload();
|
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(
|
final snapshot = _buildSubmissionSnapshot(
|
||||||
routeStations: routeStations,
|
routeStations: routeStations,
|
||||||
startVal: startVal,
|
startVal: startVal,
|
||||||
@@ -82,19 +88,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
final isEditingExisting = _isEditing && widget.editLegId != null;
|
final isEditingExisting = _isEditing && widget.editLegId != null;
|
||||||
|
|
||||||
try {
|
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) {
|
if (_useManualMileage) {
|
||||||
final body = {
|
final body = {
|
||||||
if (isEditingExisting) "leg_id": widget.editLegId,
|
...commonPayload,
|
||||||
"leg_trip": _selectedTripId,
|
|
||||||
"leg_start": startVal,
|
"leg_start": startVal,
|
||||||
"leg_end": endVal,
|
"leg_end": endVal,
|
||||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
||||||
"leg_network": _networkController.text.trim(),
|
|
||||||
"leg_distance": mileageVal,
|
"leg_distance": mileageVal,
|
||||||
"isKilometers": false,
|
"isKilometers": false,
|
||||||
"leg_notes": _notesController.text.trim(),
|
|
||||||
"leg_headcode": _headcodeController.text.trim(),
|
|
||||||
"locos": tractionPayload,
|
|
||||||
};
|
};
|
||||||
if (isEditingExisting) {
|
if (isEditingExisting) {
|
||||||
await api.put('/update', body);
|
await api.put('/update', body);
|
||||||
@@ -103,14 +121,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final body = {
|
final body = {
|
||||||
if (isEditingExisting) "leg_id": widget.editLegId,
|
...commonPayload,
|
||||||
"leg_trip": _selectedTripId,
|
|
||||||
"leg_begin_time": _legDateTime.toIso8601String(),
|
|
||||||
"leg_route": routeStations,
|
"leg_route": routeStations,
|
||||||
"leg_notes": _notesController.text.trim(),
|
|
||||||
"leg_headcode": _headcodeController.text.trim(),
|
|
||||||
"leg_network": _networkController.text.trim(),
|
|
||||||
"locos": tractionPayload,
|
|
||||||
};
|
};
|
||||||
if (isEditingExisting) {
|
if (isEditingExisting) {
|
||||||
await api.put('/update', body);
|
await api.put('/update', body);
|
||||||
@@ -148,18 +160,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
required double mileageVal,
|
required double mileageVal,
|
||||||
required List<Map<String, dynamic>> tractionPayload,
|
required List<Map<String, dynamic>> tractionPayload,
|
||||||
}) {
|
}) {
|
||||||
|
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
|
||||||
|
final endDelay =
|
||||||
|
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
|
||||||
return {
|
return {
|
||||||
"legId": widget.editLegId,
|
"legId": widget.editLegId,
|
||||||
"useManualMileage": _useManualMileage,
|
"useManualMileage": _useManualMileage,
|
||||||
"tripId": _selectedTripId,
|
"tripId": _selectedTripId,
|
||||||
"legDateTime": _legDateTime.toIso8601String(),
|
"legDateTime": _legDateTime.toIso8601String(),
|
||||||
|
"legEndTime": _legEndDateTime?.toIso8601String(),
|
||||||
|
"hasEndTime": _hasEndTime,
|
||||||
|
"legOriginTime": _originDateTime?.toIso8601String(),
|
||||||
|
"hasOriginTime": _hasOriginTime,
|
||||||
|
"legDestinationTime": _destinationDateTime?.toIso8601String(),
|
||||||
|
"hasDestinationTime": _hasDestinationTime,
|
||||||
"start": startVal,
|
"start": startVal,
|
||||||
"end": endVal,
|
"end": endVal,
|
||||||
|
"origin": _originController.text.trim(),
|
||||||
|
"destination": _destinationController.text.trim(),
|
||||||
"routeStations": routeStations,
|
"routeStations": routeStations,
|
||||||
"mileage": mileageVal,
|
"mileage": mileageVal,
|
||||||
"network": _networkController.text.trim(),
|
"network": _networkController.text.trim(),
|
||||||
"notes": _notesController.text.trim(),
|
"notes": _notesController.text.trim(),
|
||||||
"headcode": _headcodeController.text.trim(),
|
"headcode": _headcodeController.text.trim(),
|
||||||
|
"beginDelay": beginDelay,
|
||||||
|
"endDelay": endDelay,
|
||||||
"locos": tractionPayload,
|
"locos": tractionPayload,
|
||||||
"routeResult": _routeResult == null
|
"routeResult": _routeResult == null
|
||||||
? null
|
? null
|
||||||
@@ -202,11 +227,27 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
_notesController.clear();
|
_notesController.clear();
|
||||||
_mileageController.clear();
|
_mileageController.clear();
|
||||||
_networkController.clear();
|
_networkController.clear();
|
||||||
|
_originController.clear();
|
||||||
|
_destinationController.clear();
|
||||||
|
_beginDelayController.text = '0';
|
||||||
|
_endDelayController.text = '0';
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_setState(() {
|
_setState(() {
|
||||||
_selectedDate = now;
|
_selectedDate = now;
|
||||||
_selectedTime = TimeOfDay.fromDateTime(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;
|
_useManualMileage = false;
|
||||||
|
_hasEndTime = false;
|
||||||
|
_hasOriginTime = false;
|
||||||
|
_hasDestinationTime = false;
|
||||||
|
_matchOriginToEntry = false;
|
||||||
|
_matchDestinationToEntry = false;
|
||||||
|
_matchUpdateScheduled = false;
|
||||||
_routeResult = null;
|
_routeResult = null;
|
||||||
_tractionItems
|
_tractionItems
|
||||||
..clear()
|
..clear()
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
_tripLocoStatsFutures.clear();
|
_tripLocoStatsFutures.clear();
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
await data.fetchTripDetails();
|
await data.fetchTripDetails();
|
||||||
if (!mounted) return;
|
|
||||||
for (final trip in data.tripDetails) {
|
|
||||||
_tripStatsFuture(trip.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _renameTrip(TripDetail trip, String newName) async {
|
Future<void> _renameTrip(TripDetail trip, String newName) async {
|
||||||
@@ -42,10 +38,7 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
"trip_id": trip.id,
|
"trip_id": trip.id,
|
||||||
"trip_name": newName,
|
"trip_name": newName,
|
||||||
});
|
});
|
||||||
await Future.wait([
|
await data.fetchTripDetails();
|
||||||
data.fetchTripDetails(),
|
|
||||||
data.fetchTrips(),
|
|
||||||
]);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(content: Text('Failed to rename trip: $e')),
|
SnackBar(content: Text('Failed to rename trip: $e')),
|
||||||
@@ -54,10 +47,24 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TripLocoStat>> _tripStatsFuture(int tripId) {
|
List<TripLocoStat> _cachedTripStats(
|
||||||
|
TripDetail trip,
|
||||||
|
TripSummary? summary,
|
||||||
|
) {
|
||||||
|
if (trip.locoStats.isNotEmpty) return trip.locoStats;
|
||||||
|
if (summary?.locoStats.isNotEmpty == true) return summary!.locoStats;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<TripLocoStat>> _loadTripStats(
|
||||||
|
TripDetail trip,
|
||||||
|
TripSummary? summary,
|
||||||
|
) {
|
||||||
|
final cached = _cachedTripStats(trip, summary);
|
||||||
|
if (cached.isNotEmpty) return Future.value(cached);
|
||||||
return _tripLocoStatsFutures.putIfAbsent(
|
return _tripLocoStatsFutures.putIfAbsent(
|
||||||
tripId,
|
trip.id,
|
||||||
() => context.read<DataService>().fetchTripLocoStats(tripId),
|
() => context.read<DataService>().fetchTripLocoStats(trip.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +100,10 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
final tripDetails = data.tripDetails;
|
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;
|
final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -184,18 +194,25 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final trip = tripDetails[index - 1];
|
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 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 dateRange = _formatDateRange(legs);
|
||||||
final endpoints = _formatEndpoints(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(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -245,50 +262,25 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FutureBuilder<List<TripLocoStat>>(
|
Wrap(
|
||||||
future: statsFuture,
|
spacing: 8,
|
||||||
builder: (context, snapshot) {
|
runSpacing: 8,
|
||||||
final chips = <Widget>[
|
children: [
|
||||||
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
|
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
|
||||||
if (dateRange != null)
|
if (dateRange != null)
|
||||||
_buildMetaChip(context, Icons.calendar_month, dateRange),
|
_buildMetaChip(context, Icons.calendar_month, dateRange),
|
||||||
if (endpoints != null)
|
if (endpoints != null)
|
||||||
_buildMetaChip(context, Icons.route, endpoints),
|
_buildMetaChip(context, Icons.route, endpoints),
|
||||||
];
|
if (stats.isNotEmpty) ...[
|
||||||
|
_buildMetaChip(context, Icons.train, '${stats.length} had'),
|
||||||
final stats = snapshot.data ?? const [];
|
_buildMetaChip(
|
||||||
final hasStats = stats.isNotEmpty;
|
context,
|
||||||
final loading =
|
Icons.emoji_events_outlined,
|
||||||
snapshot.connectionState == ConnectionState.waiting;
|
'$winnerCount winners',
|
||||||
|
),
|
||||||
if (loading && !hasStats) {
|
] else
|
||||||
chips.add(
|
_buildMetaChip(context, Icons.train, 'No traction yet'),
|
||||||
_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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Align(
|
Align(
|
||||||
@@ -301,7 +293,7 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.train),
|
icon: const Icon(Icons.train),
|
||||||
label: const Text('Locos'),
|
label: const Text('Locos'),
|
||||||
onPressed: () => _showTripWinners(context, trip),
|
onPressed: () => _showTripWinners(context, trip, summary),
|
||||||
),
|
),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
icon: const Icon(Icons.open_in_new),
|
icon: const Icon(Icons.open_in_new),
|
||||||
@@ -532,14 +524,19 @@ class _TripsPageState extends State<TripsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showTripWinners(BuildContext context, TripDetail trip) {
|
void _showTripWinners(
|
||||||
|
BuildContext context,
|
||||||
|
TripDetail trip,
|
||||||
|
TripSummary? summary,
|
||||||
|
) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: FutureBuilder<List<TripLocoStat>>(
|
child: FutureBuilder<List<TripLocoStat>>(
|
||||||
future: _tripStatsFuture(trip.id),
|
future: _loadTripStats(trip, summary),
|
||||||
|
initialData: _cachedTripStats(trip, summary),
|
||||||
builder: (ctx, snapshot) {
|
builder: (ctx, snapshot) {
|
||||||
final items = snapshot.data ?? [];
|
final items = snapshot.data ?? [];
|
||||||
final loading =
|
final loading =
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
@@ -20,6 +22,35 @@ String _asString(dynamic value, [String fallback = '']) {
|
|||||||
return (str == null) ? fallback : str;
|
return (str == null) ? fallback : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> _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]) {
|
bool _asBool(dynamic value, [bool fallback = false]) {
|
||||||
if (value is bool) return value;
|
if (value is bool) return value;
|
||||||
if (value is num) return value != 0;
|
if (value is num) return value != 0;
|
||||||
@@ -487,25 +518,45 @@ class TripSummary {
|
|||||||
final int tripId;
|
final int tripId;
|
||||||
final String tripName;
|
final String tripName;
|
||||||
final double tripMileage;
|
final double tripMileage;
|
||||||
|
final int legCount;
|
||||||
|
final List<TripLocoStat> locoStats;
|
||||||
|
|
||||||
|
int get locoHadCount => locoStats.length;
|
||||||
|
int get winnersCount => locoStats.where((e) => e.won).length;
|
||||||
|
|
||||||
TripSummary({
|
TripSummary({
|
||||||
required this.tripId,
|
required this.tripId,
|
||||||
required this.tripName,
|
required this.tripName,
|
||||||
required this.tripMileage,
|
required this.tripMileage,
|
||||||
});
|
this.legCount = 0,
|
||||||
|
List<TripLocoStat>? locoStats,
|
||||||
|
}) : locoStats = locoStats ?? const [];
|
||||||
|
|
||||||
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary(
|
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary(
|
||||||
tripId: _asInt(json['trip_id']),
|
tripId: _asInt(json['trip_id']),
|
||||||
tripName: _asString(json['trip_name']),
|
tripName: _asString(json['trip_name']),
|
||||||
tripMileage: _asDouble(json['trip_mileage']),
|
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 {
|
class Leg {
|
||||||
final int id, tripId, timezone, driving;
|
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<String> route;
|
||||||
final DateTime beginTime;
|
final DateTime beginTime;
|
||||||
|
final DateTime? endTime;
|
||||||
|
final DateTime? originTime;
|
||||||
|
final DateTime? destinationTime;
|
||||||
final double mileage;
|
final double mileage;
|
||||||
|
final int? beginDelayMinutes, endDelayMinutes;
|
||||||
final List<Loco> locos;
|
final List<Loco> locos;
|
||||||
|
|
||||||
Leg({
|
Leg({
|
||||||
@@ -523,27 +574,55 @@ class Leg {
|
|||||||
required this.driving,
|
required this.driving,
|
||||||
required this.user,
|
required this.user,
|
||||||
required this.locos,
|
required this.locos,
|
||||||
|
this.endTime,
|
||||||
|
this.originTime,
|
||||||
|
this.destinationTime,
|
||||||
|
this.beginDelayMinutes,
|
||||||
|
this.endDelayMinutes,
|
||||||
|
this.origin = '',
|
||||||
|
this.destination = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Leg.fromJson(Map<String, dynamic> json) => Leg(
|
factory Leg.fromJson(Map<String, dynamic> json) {
|
||||||
id: _asInt(json['leg_id']),
|
final endTimeRaw = json['leg_end_time'];
|
||||||
tripId: _asInt(json['leg_trip']),
|
final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty)
|
||||||
start: _asString(json['leg_start']),
|
? null
|
||||||
end: _asString(json['leg_end']),
|
: _asDateTime(endTimeRaw);
|
||||||
beginTime: _asDateTime(json['leg_begin_time']),
|
return Leg(
|
||||||
timezone: _asInt(json['leg_timezone']),
|
id: _asInt(json['leg_id']),
|
||||||
network: _asString(json['leg_network']),
|
tripId: _asInt(json['leg_trip']),
|
||||||
route: _asString(json['leg_route']),
|
start: _asString(json['leg_start']),
|
||||||
mileage: _asDouble(json['leg_mileage']),
|
end: _asString(json['leg_end']),
|
||||||
notes: _asString(json['leg_notes']),
|
beginTime: _asDateTime(json['leg_begin_time']),
|
||||||
headcode: _asString(json['leg_headcode']),
|
endTime: parsedEndTime,
|
||||||
driving: _asInt(json['leg_driving']),
|
originTime: json['leg_origin_time'] == null
|
||||||
user: _asString(json['leg_user']),
|
? null
|
||||||
locos: (json['locos'] is List ? (json['locos'] as List) : const [])
|
: _asDateTime(json['leg_origin_time']),
|
||||||
.whereType<Map>()
|
destinationTime: json['leg_destination_time'] == null
|
||||||
.map((e) => Loco.fromJson(Map<String, dynamic>.from(e)))
|
? null
|
||||||
.toList(),
|
: _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>()
|
||||||
|
.map((e) => Loco.fromJson(Map<String, dynamic>.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 {
|
class RouteError {
|
||||||
@@ -625,17 +704,23 @@ class TripLeg {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg(
|
factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg(
|
||||||
id: json['leg_id'],
|
id: _asInt(json['leg_id']),
|
||||||
start: json['leg_start'] ?? '',
|
start: _asString(json['leg_start']),
|
||||||
end: json['leg_end'] ?? '',
|
end: _asString(json['leg_end']),
|
||||||
beginTime:
|
beginTime:
|
||||||
json['leg_begin_time'] != null && json['leg_begin_time'] is String
|
json['leg_begin_time'] != null && json['leg_begin_time'] is String
|
||||||
? DateTime.tryParse(json['leg_begin_time'])
|
? DateTime.tryParse(json['leg_begin_time'])
|
||||||
: (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null),
|
: (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null),
|
||||||
network: json['leg_network'],
|
network: _asString(json['leg_network'], ''),
|
||||||
route: json['leg_route'],
|
route: () {
|
||||||
|
final route = json['leg_route'];
|
||||||
|
if (route is List) {
|
||||||
|
return route.whereType<String>().join(' → ');
|
||||||
|
}
|
||||||
|
return _asString(route, '');
|
||||||
|
}(),
|
||||||
mileage: (json['leg_mileage'] as num?)?.toDouble(),
|
mileage: (json['leg_mileage'] as num?)?.toDouble(),
|
||||||
notes: json['leg_notes'],
|
notes: _asString(json['leg_notes'], ''),
|
||||||
locos:
|
locos:
|
||||||
(json['locos'] as List?)
|
(json['locos'] as List?)
|
||||||
?.map((e) => Loco.fromJson(e as Map<String, dynamic>))
|
?.map((e) => Loco.fromJson(e as Map<String, dynamic>))
|
||||||
@@ -649,21 +734,32 @@ class TripDetail {
|
|||||||
final String name;
|
final String name;
|
||||||
final double mileage;
|
final double mileage;
|
||||||
final int legCount;
|
final int legCount;
|
||||||
|
final List<TripLocoStat> locoStats;
|
||||||
final List<TripLeg> legs;
|
final List<TripLeg> legs;
|
||||||
|
|
||||||
|
int get locoHadCount => locoStats.length;
|
||||||
|
int get winnersCount => locoStats.where((e) => e.won).length;
|
||||||
|
|
||||||
TripDetail({
|
TripDetail({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.mileage,
|
required this.mileage,
|
||||||
required this.legCount,
|
required this.legCount,
|
||||||
required this.legs,
|
required this.legs,
|
||||||
});
|
List<TripLocoStat>? locoStats,
|
||||||
|
}) : locoStats = locoStats ?? const [];
|
||||||
|
|
||||||
factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail(
|
factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail(
|
||||||
id: json['trip_id'] ?? json['id'] ?? 0,
|
id: json['trip_id'] ?? json['id'] ?? 0,
|
||||||
name: json['trip_name'] ?? '',
|
name: json['trip_name'] ?? '',
|
||||||
mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0,
|
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:
|
legs:
|
||||||
(json['trip_legs'] as List?)
|
(json['trip_legs'] as List?)
|
||||||
?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>))
|
?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>))
|
||||||
@@ -712,6 +808,26 @@ class TripLocoStat {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<TripLocoStat> listFromJson(dynamic json) {
|
||||||
|
List<dynamic>? 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>()
|
||||||
|
.map((e) => TripLocoStat.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
static bool _parseWonFlag(dynamic value) {
|
static bool _parseWonFlag(dynamic value) {
|
||||||
if (value == null) return false;
|
if (value == null) return false;
|
||||||
if (value is bool) return value;
|
if (value is bool) return value;
|
||||||
|
|||||||
@@ -4,16 +4,25 @@ extension DataServiceTrips on DataService {
|
|||||||
Future<void> fetchTripDetails() async {
|
Future<void> fetchTripDetails() async {
|
||||||
_isTripDetailsLoading = true;
|
_isTripDetailsLoading = true;
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/trips/legs-and-stats');
|
final json = await api.get('/trips/info');
|
||||||
if (json is List) {
|
final tripDetails = _parseTripInfoList(json);
|
||||||
final tripMap = json.map((e) => TripDetail.fromJson(e)).toList();
|
_tripDetails = [...tripDetails]..sort((a, b) => b.id.compareTo(a.id));
|
||||||
_tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id));
|
_tripList = tripDetails
|
||||||
} else {
|
.map(
|
||||||
_tripDetails = [];
|
(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) {
|
} catch (e) {
|
||||||
debugPrint('Failed to fetch trip_map: $e');
|
debugPrint('Failed to fetch trip_map: $e');
|
||||||
_tripDetails = [];
|
_tripDetails = [];
|
||||||
|
_tripList = [];
|
||||||
} finally {
|
} finally {
|
||||||
_isTripDetailsLoading = false;
|
_isTripDetailsLoading = false;
|
||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
@@ -23,48 +32,17 @@ extension DataServiceTrips on DataService {
|
|||||||
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
|
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/trips/stats/$tripId');
|
final json = await api.get('/trips/stats/$tripId');
|
||||||
return _parseTripLocoStats(json);
|
return TripLocoStat.listFromJson(json);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to fetch trip loco stats: $e');
|
debugPrint('Failed to fetch trip loco stats: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TripLocoStat> _parseTripLocoStats(dynamic json) {
|
|
||||||
List<dynamic>? 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<String, dynamic>>()
|
|
||||||
.map((e) => TripLocoStat.fromJson(e))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchTrips() async {
|
Future<void> fetchTrips() async {
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/trips/mileage');
|
final json = await api.get('/trips/info');
|
||||||
Iterable<dynamic>? raw;
|
final raw = _extractTrips(json);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (raw != null) {
|
if (raw != null) {
|
||||||
final tripMap = raw
|
final tripMap = raw
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
@@ -119,8 +97,9 @@ extension DataServiceTrips on DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void upsertTripSummary(TripSummary trip) {
|
void upsertTripSummary(TripSummary trip) {
|
||||||
final existingIndex =
|
final existingIndex = _tripList.indexWhere(
|
||||||
_tripList.indexWhere((element) => element.tripId == trip.tripId);
|
(element) => element.tripId == trip.tripId,
|
||||||
|
);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
_tripList[existingIndex] = trip;
|
_tripList[existingIndex] = trip;
|
||||||
} else {
|
} else {
|
||||||
@@ -129,4 +108,24 @@ extension DataServiceTrips on DataService {
|
|||||||
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
|
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
|
||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Iterable<dynamic>? _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<TripDetail> _parseTripInfoList(dynamic json) {
|
||||||
|
final raw = _extractTrips(json);
|
||||||
|
if (raw == null) return const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => TripDetail.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user