add timeline
Some checks failed
Release / meta (push) Successful in 9s
Release / linux-build (push) Failing after 6m22s
Release / android-build (push) Failing after 14m39s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped

This commit is contained in:
2025-12-15 16:02:21 +00:00
parent da70dce369
commit 80c315866f
6 changed files with 745 additions and 1 deletions

View File

@@ -0,0 +1,544 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class LocoTimelinePage extends StatefulWidget {
const LocoTimelinePage({
super.key,
required this.locoId,
required this.locoLabel,
});
final int locoId;
final String locoLabel;
@override
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
}
class _LocoTimelinePageState extends State<LocoTimelinePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() {
return context.read<DataService>().fetchLocoTimeline(widget.locoId);
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final timeline = data.timelineForLoco(widget.locoId);
final isLoading = data.isLocoTimelineLoading(widget.locoId);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: Text('Timeline · ${widget.locoLabel}'),
),
body: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Text(
'Attribute timeline',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Each row is an attribute. Blocks stretch between change dates; scroll sideways for history.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
if (isLoading && timeline.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
)
else if (timeline.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No timeline data yet',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'This locomotive does not have any attribute history to show right now.',
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
),
],
),
),
)
else
_TimelineGrid(entries: timeline),
],
),
),
);
}
}
class _TimelineGrid extends StatelessWidget {
const _TimelineGrid({required this.entries});
final List<LocoAttrVersion> entries;
@override
Widget build(BuildContext context) {
final model = _TimelineModel.fromEntries(entries);
final axisSegments = model.axisSegments;
const labelWidth = 150.0;
const rowHeight = 52.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Scrollbar(
thumbVisibility: true,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_AxisRow(
labelWidth: labelWidth,
segments: axisSegments,
endLabel: model.endLabel,
),
const SizedBox(height: 8),
...model.attrRows.entries.map(
(entry) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: _AttrRow(
labelWidth: labelWidth,
rowHeight: rowHeight,
attrLabel: _formatAttrLabel(entry.key),
cells: entry.value,
segments: axisSegments,
),
),
),
],
),
),
),
),
const SizedBox(height: 14),
Text(
'Dates mark when a value changed. The right edge of the last block is “Now” if no end date is present.',
style: Theme.of(context).textTheme.labelMedium,
),
],
);
}
}
class _AxisRow extends StatelessWidget {
const _AxisRow({
required this.labelWidth,
required this.segments,
required this.endLabel,
});
final double labelWidth;
final List<_AxisSegment> segments;
final String endLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: labelWidth,
height: 36,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Date',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
...segments.asMap().entries.map(
(entry) {
final i = entry.key;
final seg = entry.value;
final isLast = i == segments.length - 1;
return SizedBox(
width: seg.width,
height: 36,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
seg.label,
style: theme.textTheme.labelSmall,
),
),
if (isLast)
Align(
alignment: Alignment.centerRight,
child: Text(
endLabel,
style: theme.textTheme.labelSmall,
),
),
],
),
);
},
),
],
);
}
}
class _AttrRow extends StatelessWidget {
const _AttrRow({
required this.labelWidth,
required this.rowHeight,
required this.attrLabel,
required this.cells,
required this.segments,
});
final double labelWidth;
final double rowHeight;
final String attrLabel;
final List<_RowCell> cells;
final List<_AxisSegment> segments;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: labelWidth,
height: rowHeight,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Text(
attrLabel,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
...segments.asMap().entries.map((entry) {
final idx = entry.key;
final seg = entry.value;
final cell = cells[idx];
final hasValue = cell.value.isNotEmpty;
final textColor = ThemeData.estimateBrightnessForColor(
cell.color.withOpacity(0.9),
) ==
Brightness.dark
? Colors.white
: Colors.black87;
return Container(
width: seg.width,
height: rowHeight,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: hasValue
? cell.color.withOpacity(0.9)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: hasValue
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
cell.value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: textColor,
) ??
TextStyle(
fontWeight: FontWeight.w700,
color: textColor,
),
),
const SizedBox(height: 4),
Text(
cell.rangeLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: textColor.withOpacity(0.9),
) ??
TextStyle(color: textColor.withOpacity(0.9)),
),
],
)
: const SizedBox.shrink(),
);
}),
],
);
}
}
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
String? _formatDate(DateTime? date) {
if (date == null) return null;
return _dateFormat.format(date);
}
String _formatAttrLabel(String code) {
if (code.isEmpty) return 'Attribute';
final parts = code.split('_').where((p) => p.isNotEmpty).toList();
if (parts.isEmpty) return code;
return parts
.map((part) => part.length == 1
? part.toUpperCase()
: part[0].toUpperCase() + part.substring(1))
.join(' ');
}
DateTime? _parseDateString(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
DateTime? _effectiveStart(LocoAttrVersion entry) {
return entry.validFrom ??
_parseDateString(entry.maskedValidFrom) ??
entry.txnFrom;
}
DateTime _safeEnd(DateTime start, DateTime? end) {
if (end == null || !end.isAfter(start)) {
return start.add(const Duration(days: 1));
}
return end;
}
class _TimelineModel {
final List<_AxisSegment> axisSegments;
final Map<String, List<_RowCell>> attrRows;
final String endLabel;
_TimelineModel({
required this.axisSegments,
required this.attrRows,
required this.endLabel,
});
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in entries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
}
final now = DateTime.now();
DateTime? minStart;
DateTime? maxEnd;
final attrSegments = <String, List<_ValueSegment>>{};
grouped.forEach((attr, items) {
items.sort(
(a, b) => (_effectiveStart(a) ?? now)
.compareTo(_effectiveStart(b) ?? now),
);
final segments = <_ValueSegment>[];
for (int i = 0; i < items.length; i++) {
final entry = items[i];
final start = _effectiveStart(entry) ?? now;
final nextStart = i < items.length - 1
? _effectiveStart(items[i + 1])
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
final end = _safeEnd(start, rawEnd);
segments.add(
_ValueSegment(
start: start,
end: end,
value: entry.valueLabel,
entry: entry,
),
);
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd;
}
attrSegments[attr] = segments;
});
minStart ??= now.subtract(const Duration(days: 1));
maxEnd ??= now;
final boundaryDates = <DateTime>{};
for (final segments in attrSegments.values) {
for (final seg in segments) {
boundaryDates.add(seg.start);
}
}
boundaryDates.add(maxEnd);
var boundaries = boundaryDates.toList()..sort();
if (boundaries.length < 2) {
boundaries = [minStart!, maxEnd];
}
final axisSegments = <_AxisSegment>[];
const pxPerDay = 4.0;
for (int i = 0; i < boundaries.length - 1; i++) {
final start = boundaries[i];
final end = boundaries[i + 1];
final days = (end.difference(start).inDays).clamp(1, 365);
final width = (days * pxPerDay) + 30;
axisSegments.add(
_AxisSegment(
start: start,
end: end,
width: width,
label: _formatDate(start) ?? '',
),
);
}
final attrRows = <String, List<_RowCell>>{};
for (final entry in attrSegments.entries) {
final cells = <_RowCell>[];
for (final axis in axisSegments) {
final seg = entry.value
.firstWhere(
(s) => s.overlaps(axis.start, axis.end),
orElse: () => _ValueSegment.empty(axis.start, axis.end),
);
cells.add(_RowCell.fromSegment(seg));
}
attrRows[entry.key] = cells;
}
final endLabel = _formatDate(maxEnd) ?? 'Now';
return _TimelineModel(
axisSegments: axisSegments,
attrRows: attrRows,
endLabel: endLabel,
);
}
}
class _AxisSegment {
final DateTime start;
final DateTime end;
final double width;
final String label;
_AxisSegment({
required this.start,
required this.end,
required this.width,
required this.label,
});
}
class _ValueSegment {
final DateTime start;
final DateTime end;
final String value;
final LocoAttrVersion? entry;
_ValueSegment({
required this.start,
required this.end,
required this.value,
this.entry,
});
factory _ValueSegment.empty(DateTime start, DateTime end) => _ValueSegment(
start: start,
end: end,
value: '',
entry: null,
);
bool overlaps(DateTime s, DateTime e) {
return start.isBefore(e) && end.isAfter(s);
}
}
class _RowCell {
final String value;
final String rangeLabel;
final Color color;
const _RowCell({
required this.value,
required this.rangeLabel,
required this.color,
});
factory _RowCell.fromSegment(_ValueSegment seg) {
if (seg.value.isEmpty) {
return const _RowCell(
value: '',
rangeLabel: '',
color: Colors.transparent,
);
}
final displayStart = _formatDate(seg.start) ?? '';
final displayEnd = _formatDate(seg.entry?.validTo ?? seg.end) ?? 'Now';
final range = '$displayStart$displayEnd';
return _RowCell(
value: seg.value,
rangeLabel: range,
color: _colorForValue(seg.value),
);
}
}
Color _colorForValue(String value) {
final hue = (value.hashCode % 360).toDouble();
final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55);
return hsl.toColor();
}

View File

@@ -574,6 +574,12 @@ class _TractionPageState extends State<TractionPage> {
icon: const Icon(Icons.info_outline),
label: const Text('Details'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _openTimeline(loco),
icon: const Icon(Icons.timeline),
label: const Text('Timeline'),
),
const Spacer(),
if (widget.selectionMode)
TextButton.icon(
@@ -692,6 +698,14 @@ class _TractionPageState extends State<TractionPage> {
return (background, foreground);
}
void _openTimeline(LocoSummary loco) {
final label = '${loco.locoClass} ${loco.number}'.trim();
context.push(
'/traction/${loco.id}/timeline',
extra: {'label': label},
);
}
Future<void> _showLocoInfo(LocoSummary loco) async {
await showModalBottomSheet(
context: context,