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: 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 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> attrRows; final String endLabel; _TimelineModel({ required this.axisSegments, required this.attrRows, required this.endLabel, }); 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); 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 = {}; 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 = >{}; 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(); }