Improve entries page and latest changes panel, units on events and timeline
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 8m3s
Release / android-build (push) Successful in 19m21s
Release / release-master (push) Successful in 40s
Release / release-dev (push) Successful in 42s

This commit is contained in:
2025-12-23 17:41:21 +00:00
parent 29959f7580
commit 44d79e7c28
16 changed files with 1764 additions and 498 deletions

View File

@@ -39,9 +39,9 @@ class CalculatorDetailsPage extends StatelessWidget {
child: RouteDetailsView(
route: parsed.calculatedRoute,
costs: parsed.costs,
routingPoints: parsed.inputRoute.toSet(),
onBack: () => context.pop(),
),
);
}
}

View File

@@ -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(),
],
),
),

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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')),

View File

@@ -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,
});

View File

@@ -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