Improve entries page and latest changes panel, units on events and timeline
All checks were successful
All checks were successful
This commit is contained in:
@@ -39,9 +39,9 @@ class CalculatorDetailsPage extends StatelessWidget {
|
||||
child: RouteDetailsView(
|
||||
route: parsed.calculatedRoute,
|
||||
costs: parsed.costs,
|
||||
routingPoints: parsed.inputRoute.toSet(),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,8 @@ class _DashboardState extends State<Dashboard> {
|
||||
_buildOnThisDayCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildTripsCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
const LatestLocoChangesPanel(expanded: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -244,8 +246,6 @@ class _DashboardState extends State<Dashboard> {
|
||||
TopTractionPanel(),
|
||||
SizedBox(height: 16),
|
||||
LeaderboardPanel(),
|
||||
SizedBox(height: 16),
|
||||
LatestLocoChangesPanel(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -36,6 +36,21 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
|
||||
dynamic _normalizeFieldValue(_FieldEntry field) {
|
||||
final name = field.field.name.toLowerCase();
|
||||
final val = field.value;
|
||||
if (name == 'max_speed') {
|
||||
final numVal = val is num ? val.toDouble() : double.tryParse('$val');
|
||||
if (numVal == null) return val;
|
||||
final unit = (field.unit ?? 'kph').toLowerCase();
|
||||
if (unit == 'mph') {
|
||||
return numVal * 1.60934;
|
||||
}
|
||||
return numVal;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeDrafts(_draftEvents);
|
||||
@@ -57,7 +72,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
String? _eventDateForEntry(LocoAttrVersion entry) {
|
||||
final masked = entry.maskedValidFrom?.trim();
|
||||
if (masked != null && masked.isNotEmpty) return masked;
|
||||
final from = entry.validFrom ?? entry.txnFrom;
|
||||
final from = entry.validFrom;
|
||||
if (from == null) return null;
|
||||
return DateFormat('yyyy-MM-dd').format(from);
|
||||
}
|
||||
@@ -115,7 +130,8 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
draft.details = '';
|
||||
draft.fields.add(
|
||||
_FieldEntry(field: field)
|
||||
..value = _valueForEntry(entry),
|
||||
..value = _valueForEntry(entry)
|
||||
..unit = _guessUnit(field, entry.valueLabel),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
@@ -123,6 +139,16 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
});
|
||||
}
|
||||
|
||||
String? _guessUnit(EventField field, String valueLabel) {
|
||||
final name = field.name.toLowerCase();
|
||||
if (name == 'max_speed') {
|
||||
final val = valueLabel.toLowerCase();
|
||||
if (val.contains('mph')) return 'mph';
|
||||
return 'kph';
|
||||
}
|
||||
return _defaultUnitForField(field);
|
||||
}
|
||||
|
||||
Future<void> _deleteEntry(LocoAttrVersion entry) async {
|
||||
if (_isDeleting) return;
|
||||
final blockId = entry.versionId;
|
||||
@@ -241,7 +267,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||
invalid.add('Field ${field.field.display} is empty');
|
||||
break;
|
||||
}
|
||||
values[field.field.name] = val;
|
||||
values[field.field.name] = _normalizeFieldValue(field);
|
||||
}
|
||||
if (invalid.isNotEmpty) continue;
|
||||
if (values.isEmpty) {
|
||||
|
||||
@@ -184,7 +184,9 @@ class _FieldList extends StatelessWidget {
|
||||
value: null,
|
||||
onChanged: (field) {
|
||||
if (field == null) return;
|
||||
draft.fields.add(_FieldEntry(field: field));
|
||||
draft.fields.add(
|
||||
_FieldEntry(field: field)..unit = _defaultUnitForField(field),
|
||||
);
|
||||
onChange();
|
||||
},
|
||||
items: availableFields
|
||||
@@ -224,10 +226,10 @@ class _FieldList extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_FieldInput(
|
||||
field: field.field,
|
||||
value: field.value,
|
||||
onChanged: (val) {
|
||||
entry: field,
|
||||
onChanged: (val, {String? unit}) {
|
||||
field.value = val;
|
||||
if (unit != null) field.unit = unit;
|
||||
onChange();
|
||||
},
|
||||
),
|
||||
@@ -253,17 +255,18 @@ class _FieldList extends StatelessWidget {
|
||||
|
||||
class _FieldInput extends StatelessWidget {
|
||||
const _FieldInput({
|
||||
required this.field,
|
||||
required this.value,
|
||||
required this.entry,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final EventField field;
|
||||
final dynamic value;
|
||||
final ValueChanged<dynamic> onChanged;
|
||||
final _FieldEntry entry;
|
||||
final void Function(dynamic value, {String? unit}) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final field = entry.field;
|
||||
final value = entry.value;
|
||||
|
||||
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
|
||||
final options = field.enumValues!;
|
||||
return DropdownButtonFormField<String>(
|
||||
@@ -293,6 +296,119 @@ class _FieldInput extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final name = field.name.toLowerCase();
|
||||
if (name == 'max_speed') {
|
||||
final unit = entry.unit ?? 'kph';
|
||||
final isNumber = true;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(isNumber ? parsed : val, unit: unit);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'kph/mph',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: unit,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'kph', child: Text('kph')),
|
||||
DropdownMenuItem(value: 'mph', child: Text('mph')),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val == null) return;
|
||||
onChanged(value, unit: val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Unit',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if ({
|
||||
'height',
|
||||
'length',
|
||||
'width',
|
||||
'track_gauge',
|
||||
}.contains(name)) {
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(parsed ?? val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'mm',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}
|
||||
|
||||
if (name == 'weight') {
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(parsed ?? val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'tonnes',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}
|
||||
|
||||
if (name == 'power') {
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(parsed ?? val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'kW',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}
|
||||
|
||||
if (name == 'tractive_effort') {
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val);
|
||||
onChanged(parsed ?? val);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter value',
|
||||
suffixText: 'kN',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}
|
||||
|
||||
final isNumber = type == 'int' || type == 'integer';
|
||||
return TextFormField(
|
||||
initialValue: value?.toString(),
|
||||
@@ -326,6 +442,13 @@ class _EventDraft {
|
||||
class _FieldEntry {
|
||||
final EventField field;
|
||||
dynamic value;
|
||||
String? unit;
|
||||
|
||||
_FieldEntry({required this.field});
|
||||
}
|
||||
|
||||
String? _defaultUnitForField(EventField field) {
|
||||
final name = field.name.toLowerCase();
|
||||
if (name == 'max_speed') return 'kph';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ class _TimelineModel {
|
||||
_ValueSegment(
|
||||
start: start,
|
||||
end: end,
|
||||
value: entry.valueLabel,
|
||||
value: _formatValueWithUnits(entry),
|
||||
entry: entry,
|
||||
),
|
||||
);
|
||||
@@ -680,6 +680,53 @@ class _TimelineModel {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatValueWithUnits(LocoAttrVersion entry) {
|
||||
final raw = entry.valueLabel;
|
||||
final code = entry.attrCode.toLowerCase();
|
||||
final lowerRaw = raw.toLowerCase();
|
||||
// Avoid double-appending if units already present.
|
||||
final hasUnits = lowerRaw.contains('mm') ||
|
||||
lowerRaw.contains('tonne') ||
|
||||
lowerRaw.contains('kph') ||
|
||||
lowerRaw.contains('mph');
|
||||
|
||||
double? asNumber = double.tryParse(raw);
|
||||
String formatNumber(double value) {
|
||||
if (value % 1 == 0) return value.toStringAsFixed(0);
|
||||
return value.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'height':
|
||||
case 'length':
|
||||
case 'width':
|
||||
case 'track_gauge':
|
||||
if (hasUnits) return raw;
|
||||
return asNumber != null ? '${formatNumber(asNumber)} mm' : '$raw mm';
|
||||
case 'weight':
|
||||
if (hasUnits) return raw;
|
||||
return asNumber != null ? '${formatNumber(asNumber)} tonnes' : '$raw tonnes';
|
||||
case 'power':
|
||||
if (hasUnits) return raw;
|
||||
return asNumber != null ? '${formatNumber(asNumber)} kW' : '$raw kW';
|
||||
case 'tractive_effort':
|
||||
if (hasUnits) return raw;
|
||||
return asNumber != null ? '${formatNumber(asNumber)} kN' : '$raw kN';
|
||||
case 'max_speed':
|
||||
if (hasUnits) return raw;
|
||||
if (asNumber != null) {
|
||||
// Stored as kph.
|
||||
final formatted = asNumber % 1 == 0
|
||||
? asNumber.toStringAsFixed(0)
|
||||
: asNumber.toStringAsFixed(1);
|
||||
return '$formatted kph';
|
||||
}
|
||||
return '$raw kph';
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
class _AxisSegment {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
@@ -742,7 +789,15 @@ class _RowCell {
|
||||
color: Colors.transparent,
|
||||
);
|
||||
}
|
||||
final displayStart = _formatDate(seg.start) ?? '';
|
||||
final entry = seg.entry;
|
||||
String displayStart = '';
|
||||
if (entry != null) {
|
||||
if ((entry.maskedValidFrom ?? '').trim().isNotEmpty) {
|
||||
displayStart = entry.maskedValidFrom!.trim();
|
||||
} else if (entry.validFrom != null) {
|
||||
displayStart = _formatDate(entry.validFrom) ?? '';
|
||||
}
|
||||
}
|
||||
return _RowCell(
|
||||
value: seg.value,
|
||||
rangeLabel: displayStart,
|
||||
|
||||
@@ -128,7 +128,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
||||
);
|
||||
_lastSubmittedSnapshot = snapshot;
|
||||
_activeDraftId = null;
|
||||
} catch (e) {
|
||||
} catch (e, st) {
|
||||
debugPrint('Leg submit/update failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
if (!mounted) return;
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('Failed to submit: $e')),
|
||||
|
||||
@@ -73,6 +73,8 @@ extension _NewEntryTractionLogic on _NewEntryPageState {
|
||||
for (var i = 0; i < _tractionItems.length; i++) {
|
||||
final item = _tractionItems[i];
|
||||
if (item.isMarker || item.loco == null) continue;
|
||||
final locoId = item.loco!.id;
|
||||
if (locoId == 0) continue;
|
||||
int allocPos;
|
||||
if (i > markerIndex) {
|
||||
allocPos = -(i - markerIndex);
|
||||
@@ -80,8 +82,7 @@ extension _NewEntryTractionLogic on _NewEntryPageState {
|
||||
allocPos = (markerIndex - 1) - i;
|
||||
}
|
||||
payload.add({
|
||||
"loco_type": item.loco!.type,
|
||||
"loco_number": item.loco!.number,
|
||||
"loco_id": locoId,
|
||||
"alloc_pos": allocPos,
|
||||
"alloc_powering": item.powering ? 1 : 0,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user