From 2b4d2623fcf54a89a5b08101fcadcb848e9da6c1 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Tue, 16 Dec 2025 12:24:53 +0000 Subject: [PATCH] add loco timeline view --- lib/components/pages/loco_timeline.dart | 714 ++++++++++++++++-------- 1 file changed, 486 insertions(+), 228 deletions(-) diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index 13aa0fc..fe2cd50 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:mileograph_flutter/objects/objects.dart'; @@ -45,116 +47,258 @@ class _LocoTimelinePageState extends State { ), 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'), - ), - ], + child: LayoutBuilder( + builder: (context, constraints) { + if (isLoading && timeline.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (timeline.isEmpty) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: 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), - ], + ); + } + return _TimelineGrid( + entries: timeline, + maxHeight: constraints.maxHeight, + ); + }, ), ), ); } } -class _TimelineGrid extends StatelessWidget { - const _TimelineGrid({required this.entries}); +class _TimelineGrid extends StatefulWidget { + const _TimelineGrid({ + required this.entries, + required this.maxHeight, + }); final List entries; + final double maxHeight; + + @override + State<_TimelineGrid> createState() => _TimelineGridState(); +} + +class _TimelineGridState extends State<_TimelineGrid> { + final ScrollController _horizontalController = ScrollController(); + final ScrollController _rightVerticalController = ScrollController(); + final ScrollController _leftVerticalController = ScrollController(); + bool _isSyncingScroll = false; + double _scrollOffset = 0; + + @override + void initState() { + super.initState(); + _rightVerticalController.addListener(_syncVerticalScroll); + _horizontalController.addListener(_onHorizontalScroll); + } + + @override + void dispose() { + _rightVerticalController.removeListener(_syncVerticalScroll); + _horizontalController.removeListener(_onHorizontalScroll); + _horizontalController.dispose(); + _rightVerticalController.dispose(); + _leftVerticalController.dispose(); + super.dispose(); + } + + void _syncVerticalScroll() { + if (_isSyncingScroll) return; + if (!_leftVerticalController.hasClients || + !_rightVerticalController.hasClients) { + return; + } + _isSyncingScroll = true; + _leftVerticalController.jumpTo( + _rightVerticalController.offset.clamp( + 0.0, + _leftVerticalController.position.maxScrollExtent, + ), + ); + _isSyncingScroll = false; + } + + void _onHorizontalScroll() { + if (!mounted) return; + setState(() { + _scrollOffset = _horizontalController.offset; + }); + } @override Widget build(BuildContext context) { - final model = _TimelineModel.fromEntries(entries); + final filteredEntries = widget.entries.where((e) { + final code = e.attrCode.toLowerCase(); + return !{ + 'operational', + 'gettable', + 'build_prec', + 'build_year', + 'build_month', + 'build_day', + }.contains(code); + }).toList(); + final model = _TimelineModel.fromEntries(filteredEntries); final axisSegments = model.axisSegments; - const labelWidth = 150.0; + const labelWidth = 110.0; const rowHeight = 52.0; + const double axisHeight = 48; + final rows = model.attrRows.entries.toList(); + final totalRowsHeight = rows.length * rowHeight; + final axisWidth = math.max(model.axisTotalWidth, 120.0); + final paddingTop = MediaQuery.of(context).padding.top; + final double constraintHeight = widget.maxHeight.isFinite + ? widget.maxHeight + : MediaQuery.of(context).size.height; + final double availableHeight = + (constraintHeight - paddingTop - 24).clamp(axisHeight + 40, double.infinity); + final double viewHeight = availableHeight; 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, + SizedBox( + height: viewHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: labelWidth, + child: Column( + children: [ + SizedBox( + height: axisHeight, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Attribute', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + Expanded( + child: Scrollbar( + controller: _leftVerticalController, + thumbVisibility: true, + child: ListView.builder( + controller: _leftVerticalController, + physics: const NeverScrollableScrollPhysics(), + itemExtent: rowHeight, + itemCount: rows.length, + itemBuilder: (_, index) { + final label = _formatAttrLabel(rows[index].key); + return Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric( + horizontal: 10, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + border: Border( + bottom: BorderSide( + color: + Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + child: Text( + label, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ); + }, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _horizontalController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: axisWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AxisRow( + segments: axisSegments, + totalWidth: axisWidth, + endLabel: model.endLabel, + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + controller: _rightVerticalController, + itemExtent: rowHeight, + itemCount: rows.length, + itemBuilder: (_, index) { + final blocks = rows[index].value; + return Padding( + padding: + const EdgeInsets.symmetric(vertical: 2.0), + child: _AttrRow( + rowHeight: rowHeight, + blocks: blocks, + model: model, + scrollOffset: _scrollOffset, + viewportWidth: axisWidth, + ), + ); + }, + ), + ), + ], ), ), ), - ], + ), ), - ), + ], ), ), - 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, - ), ], ); } @@ -162,139 +306,178 @@ class _TimelineGrid extends StatelessWidget { class _AxisRow extends StatelessWidget { const _AxisRow({ - required this.labelWidth, required this.segments, required this.endLabel, + required this.totalWidth, }); - final double labelWidth; final List<_AxisSegment> segments; final String endLabel; + final double totalWidth; @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, + const double axisHeight = 48; + return SizedBox( + width: totalWidth, + height: axisHeight, + child: Stack( + children: [ + for (int i = 0; i < segments.length; i++) ...[ + Positioned( + left: segments[i].offset, + width: segments[i].width, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + segments[i].label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: theme.textTheme.labelSmall, + ), + ), + ), + ], + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerRight, + child: Text( + endLabel, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: theme.textTheme.labelSmall, ), ), ), - ), - ...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, + required this.blocks, + required this.model, + required this.scrollOffset, + required this.viewportWidth, }); - final double labelWidth; final double rowHeight; - final String attrLabel; - final List<_RowCell> cells; - final List<_AxisSegment> segments; + final List<_ValueBlock> blocks; + final _TimelineModel model; + final double scrollOffset; + final double viewportWidth; + + @override + Widget build(BuildContext context) { + final width = math.max(model.axisTotalWidth, 120.0); + final activeBlock = _activeBlock(blocks, scrollOffset); + final double stickyWidth = activeBlock == null + ? 0 + : (activeBlock.right - scrollOffset).clamp(20.0, viewportWidth); + + return SizedBox( + width: width, + height: rowHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + for (final block in blocks) + Positioned( + left: block.left, + width: block.width, + top: 0, + bottom: 0, + child: _ValueBlockView(block: block), + ), + if (activeBlock != null) + Positioned( + left: scrollOffset, + width: stickyWidth, + top: 0, + bottom: 0, + child: IgnorePointer( + child: ClipRect( + child: _ValueBlockView( + block: activeBlock.copyWith( + left: scrollOffset, + width: stickyWidth, + ), + clipLeftEdge: scrollOffset > activeBlock.left + 0.1, + ), + ), + ), + ), + ], + ), + ); + } + + _ValueBlock? _activeBlock(List<_ValueBlock> blocks, double offset) { + for (final block in blocks) { + if (offset >= block.left && offset < block.right) return block; + } + return null; + } +} + +class _ValueBlockView extends StatelessWidget { + const _ValueBlockView({ + required this.block, + this.clipLeftEdge = false, + }); + + final _ValueBlock block; + final bool clipLeftEdge; @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, - ), - ), + final color = block.cell.color.withOpacity(0.9); + final textColor = ThemeData.estimateBrightnessForColor(color) == + Brightness.dark + ? Colors.white + : Colors.black87; + + final radius = BorderRadius.only( + topLeft: Radius.circular(clipLeftEdge ? 0 : 12), + bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12), + topRight: const Radius.circular(12), + bottomRight: const Radius.circular(12), + ); + + return ClipRRect( + borderRadius: radius, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: block.cell.value.isEmpty + ? theme.colorScheme.surfaceContainerHighest + : color, + borderRadius: BorderRadius.zero, + border: Border.all(color: theme.colorScheme.outlineVariant), ), - ...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( + child: block.cell.value.isEmpty + ? const SizedBox.shrink() + : FittedBox( + alignment: Alignment.topLeft, + fit: BoxFit.scaleDown, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 1, minHeight: 1), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - cell.value, + block.cell.value, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( @@ -308,7 +491,7 @@ class _AttrRow extends StatelessWidget { ), const SizedBox(height: 4), Text( - cell.rangeLabel, + block.cell.rangeLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( @@ -317,15 +500,13 @@ class _AttrRow extends StatelessWidget { TextStyle(color: textColor.withOpacity(0.9)), ), ], - ) - : const SizedBox.shrink(), - ); - }), - ], + ), + ), + ), + ), ); } } - final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); String? _formatDate(DateTime? date) { @@ -364,13 +545,17 @@ DateTime _safeEnd(DateTime start, DateTime? end) { class _TimelineModel { final List<_AxisSegment> axisSegments; - final Map> attrRows; + final Map> attrRows; final String endLabel; + final List boundaries; + final double axisTotalWidth; _TimelineModel({ required this.axisSegments, required this.attrRows, required this.endLabel, + required this.boundaries, + required this.axisTotalWidth, }); factory _TimelineModel.fromEntries(List entries) { @@ -397,14 +582,23 @@ class _TimelineModel { : null; final rawEnd = entry.validTo ?? nextStart ?? now; final end = _safeEnd(start, rawEnd); - segments.add( - _ValueSegment( - start: start, - end: end, - value: entry.valueLabel, - entry: entry, - ), - ); + if (segments.isNotEmpty && segments.last.value == entry.valueLabel) { + final last = segments.removeLast(); + segments.add( + last.copyWith( + end: end.isAfter(last.end) ? end : last.end, + ), + ); + } else { + segments.add( + _ValueSegment( + start: start, + end: end, + value: entry.valueLabel, + entry: entry, + ), + ); + } minStart = minStart == null || start.isBefore(minStart!) ? start : minStart; @@ -414,70 +608,108 @@ class _TimelineModel { }); minStart ??= now.subtract(const Duration(days: 1)); - maxEnd ??= now; + final effectiveMaxEnd = maxEnd ?? now; final boundaryDates = {}; for (final segments in attrSegments.values) { for (final seg in segments) { boundaryDates.add(seg.start); + boundaryDates.add(seg.end); } } - boundaryDates.add(maxEnd); + boundaryDates.add(effectiveMaxEnd); var boundaries = boundaryDates.toList()..sort(); if (boundaries.length < 2) { - boundaries = [minStart!, maxEnd]; + boundaries = [minStart!, effectiveMaxEnd]; } final axisSegments = <_AxisSegment>[]; - const pxPerDay = 4.0; + const double yearWidth = 240.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; + final days = end.difference(start).inDays; + final width = days >= 365 ? yearWidth : yearWidth / 2; + final double offset = axisSegments.isEmpty + ? 0.0 + : axisSegments.last.offset + axisSegments.last.width; axisSegments.add( _AxisSegment( start: start, end: end, width: width, + offset: offset, label: _formatDate(start) ?? '', ), ); } + final axisTotalWidth = + axisSegments.fold(0, (sum, seg) => sum + seg.width); - final attrRows = >{}; + final attrRows = >{}; 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)); + final blocks = <_ValueBlock>[]; + for (final seg in entry.value) { + final left = _positionForDate(seg.start, boundaries, axisSegments); + final right = _positionForDate(seg.end, boundaries, axisSegments); + final span = right - left; + final width = span < 2.0 ? 2.0 : span; + blocks.add( + _ValueBlock( + left: left, + width: width, + cell: _RowCell.fromSegment(seg), + ), + ); } - attrRows[entry.key] = cells; + attrRows[entry.key] = blocks; } - final endLabel = _formatDate(maxEnd) ?? 'Now'; + final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now'; return _TimelineModel( axisSegments: axisSegments, attrRows: attrRows, endLabel: endLabel, + boundaries: boundaries, + axisTotalWidth: axisTotalWidth, ); } + + static double _positionForDate( + DateTime date, + List boundaries, + List<_AxisSegment> segments, + ) { + for (int i = 0; i < boundaries.length - 1; i++) { + final start = boundaries[i]; + final end = boundaries[i + 1]; + if (!date.isAfter(end)) { + final seg = segments[i]; + final span = end.difference(start).inMilliseconds; + final elapsed = date.difference(start).inMilliseconds.clamp(0, span); + if (span <= 0) return seg.offset; + final fraction = elapsed / span; + return seg.offset + (seg.width * fraction); + } + } + return segments.isNotEmpty + ? segments.last.offset + segments.last.width + : 0.0; + } } class _AxisSegment { final DateTime start; final DateTime end; final double width; + final double offset; final String label; _AxisSegment({ required this.start, required this.end, required this.width, + required this.offset, required this.label, }); } @@ -495,16 +727,18 @@ class _ValueSegment { 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); } + + _ValueSegment copyWith({DateTime? start, DateTime? end, String? value}) { + return _ValueSegment( + start: start ?? this.start, + end: end ?? this.end, + value: value ?? this.value, + entry: entry, + ); + } } class _RowCell { @@ -527,16 +761,40 @@ class _RowCell { ); } 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, + rangeLabel: displayStart, color: _colorForValue(seg.value), ); } } +class _ValueBlock { + final double left; + final double width; + final _RowCell cell; + + const _ValueBlock({ + required this.left, + required this.width, + required this.cell, + }); + + double get right => left + width; + + _ValueBlock copyWith({ + double? left, + double? width, + _RowCell? cell, + }) { + return _ValueBlock( + left: left ?? this.left, + width: width ?? this.width, + cell: cell ?? this.cell, + ); + } +} + Color _colorForValue(String value) { final hue = (value.hashCode % 360).toDouble(); final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55);