import 'dart:math' as math; 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 createState() => _LocoTimelinePageState(); } class _LocoTimelinePageState extends State { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } Future _load() { return context.read().fetchLocoTimeline(widget.locoId); } @override Widget build(BuildContext context) { final data = context.watch(); 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: 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'), ), ], ), ), ), ); } return _TimelineGrid( entries: timeline, maxHeight: constraints.maxHeight, ); }, ), ), ); } } 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 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 = 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: [ 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, ), ); }, ), ), ], ), ), ), ), ), ], ), ), ], ); } } class _AxisRow extends StatelessWidget { const _AxisRow({ required this.segments, required this.endLabel, required this.totalWidth, }); final List<_AxisSegment> segments; final String endLabel; final double totalWidth; @override Widget build(BuildContext context) { final theme = Theme.of(context); 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, ), ), ), ], ), ); } } class _AttrRow extends StatelessWidget { const _AttrRow({ required this.rowHeight, required this.blocks, required this.model, required this.scrollOffset, required this.viewportWidth, }); final double rowHeight; 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); 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), ), 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( block.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( block.cell.rangeLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( color: textColor.withOpacity(0.9), ) ?? TextStyle(color: textColor.withOpacity(0.9)), ), ], ), ), ), ), ); } } 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> 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) { final grouped = >{}; for (final entry in entries) { grouped.putIfAbsent(entry.attrCode, () => []).add(entry); } final now = DateTime.now(); DateTime? minStart; DateTime? maxEnd; final attrSegments = >{}; 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); 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; maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd; } attrSegments[attr] = segments; }); minStart ??= now.subtract(const Duration(days: 1)); 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(effectiveMaxEnd); var boundaries = boundaryDates.toList()..sort(); if (boundaries.length < 2) { boundaries = [minStart!, effectiveMaxEnd]; } final axisSegments = <_AxisSegment>[]; const double yearWidth = 240.0; for (int i = 0; i < boundaries.length - 1; i++) { final start = boundaries[i]; final end = boundaries[i + 1]; final width = yearWidth; 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 = >{}; for (final entry in attrSegments.entries) { 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] = blocks; } 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, }); } 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, }); 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 { 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) ?? ''; return _RowCell( value: seg.value, 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); return hsl.toColor(); }