part of 'package:mileograph_flutter/components/pages/loco_timeline.dart'; final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); enum _PendingModerationAction { approve, reject } class _TimelineGrid extends StatefulWidget { const _TimelineGrid({ required this.entries, this.onEditEntry, this.onDeleteEntry, this.onModeratePending, this.pendingActionEventIds = const {}, this.expandedPendingAttrs = const {}, this.onTogglePendingAttr, }); final List entries; final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry; final Future Function( LocoAttrVersion entry, _PendingModerationAction action, )? onModeratePending; final Set pendingActionEventIds; final Set expandedPendingAttrs; final void Function(String attrCode)? onTogglePendingAttr; @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, expandedAttrCodes: widget.expandedPendingAttrs, ); final axisSegments = model.axisSegments; const labelWidth = 110.0; const rowHeight = 52.0; const double axisHeight = 48; final rows = model.rows; final totalRowsHeight = rows.length * rowHeight; final axisWidth = math.max(model.axisTotalWidth, 120.0); final double viewHeight = totalRowsHeight + axisHeight + 8; 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 row = rows[index]; final label = row.isPrimary ? _formatAttrLabel(row.attrCode) : (row.pendingUser?.trim().isNotEmpty == true ? row.pendingUser!.trim() : 'Unknown'); 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: Row( children: [ if (!row.isPrimary) ...[ Icon( Icons.subdirectory_arrow_right, size: 16, color: Theme.of(context).hintColor, ), const SizedBox(width: 6), ], Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .labelLarge ?.copyWith( fontWeight: row.isPrimary ? FontWeight.w700 : FontWeight.w600, ), ), ), if (row.showExpandToggle) IconButton( onPressed: widget.onTogglePendingAttr == null ? null : () => widget.onTogglePendingAttr?.call( row.attrCode, ), icon: Icon( row.isExpanded ? Icons.expand_less : Icons.expand_more, ), tooltip: row.isExpanded ? 'Collapse pending rows' : 'Expand pending rows', visualDensity: VisualDensity.compact, ), ], ), ); }, ), ), ), ], ), ), 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].blocks; return Padding( padding: const EdgeInsets.symmetric(vertical: 2.0), child: _AttrRow( rowHeight: rowHeight, blocks: blocks, model: model, scrollOffset: _scrollOffset, viewportWidth: axisWidth, onEditEntry: widget.onEditEntry, onDeleteEntry: widget.onDeleteEntry, onModeratePending: widget.onModeratePending, pendingActionEventIds: widget.pendingActionEventIds, ), ); }, ), ), ], ), ), ), ), ), ], ), ), ], ); } } 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, this.onEditEntry, this.onDeleteEntry, this.onModeratePending, this.pendingActionEventIds = const {}, }); final double rowHeight; final List<_ValueBlock> blocks; final _TimelineModel model; final double scrollOffset; final double viewportWidth; final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry; final Future Function( LocoAttrVersion entry, _PendingModerationAction action, )? onModeratePending; final Set pendingActionEventIds; @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: _ValueBlockMenu( block: block, onEditEntry: onEditEntry, onDeleteEntry: onDeleteEntry, onModeratePending: onModeratePending, pendingActionEventIds: pendingActionEventIds, ), ), 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, pendingActionEventIds: pendingActionEventIds, ), ), ), ), ], ), ); } _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, this.pendingActionEventIds = const {}, }); final _ValueBlock block; final bool clipLeftEdge; final Set pendingActionEventIds; @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = block.cell.color; final textColor = ThemeData.estimateBrightnessForColor(color) == Brightness.dark ? Colors.white : Colors.black87; final entry = block.entry; final eventId = entry?.sourceEventId; final isPendingAction = entry?.isPending == true && eventId != null && pendingActionEventIds.contains(eventId); 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: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ if (block.cell.isPending) Padding( padding: const EdgeInsets.only(right: 6), child: SizedBox( width: 16, height: 16, child: isPendingAction ? CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(textColor), ) : Icon( Icons.pending, size: 16, color: textColor, ), ), ), 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.withValues(alpha: 0.9), ) ?? TextStyle( color: textColor.withValues(alpha: 0.9), ), ), ], ), ], ), ), ), ), ); } } enum _TimelineBlockAction { edit, delete, approve, reject } class _ValueBlockMenu extends StatelessWidget { const _ValueBlockMenu({ required this.block, this.onEditEntry, this.onDeleteEntry, this.onModeratePending, this.pendingActionEventIds = const {}, }); final _ValueBlock block; final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry; final Future Function( LocoAttrVersion entry, _PendingModerationAction action, )? onModeratePending; final Set pendingActionEventIds; bool get _hasActions { final canModerate = block.entry?.isPending == true && block.entry?.canModeratePending == true && onModeratePending != null; final canEdit = onEditEntry != null && block.entry?.isPending != true; return onDeleteEntry != null || canModerate || canEdit; } @override Widget build(BuildContext context) { if (!_hasActions || block.entry == null) { return _ValueBlockView( block: block, ); } final canModerate = block.entry?.isPending == true && block.entry?.canModeratePending == true && onModeratePending != null; final canEdit = onEditEntry != null && block.entry?.isPending != true; final eventId = block.entry?.sourceEventId; final isPendingAction = eventId != null && pendingActionEventIds.contains(eventId); Future showContextMenuAt(Offset globalPosition) async { final overlay = Overlay.of(context); final renderBox = overlay.context.findRenderObject() as RenderBox?; if (renderBox == null) return; // Translate from global screen coordinates into the overlay's local space // so the menu appears where the gesture happened. final localPosition = renderBox.globalToLocal(globalPosition); final position = RelativeRect.fromRect( Rect.fromLTWH(localPosition.dx, localPosition.dy, 1, 1), Offset.zero & renderBox.size, ); final action = await showMenu<_TimelineBlockAction>( context: context, position: position, items: [ if (canEdit) const PopupMenuItem( value: _TimelineBlockAction.edit, child: Text('Edit'), ), if (canModerate) PopupMenuItem( value: _TimelineBlockAction.approve, enabled: !isPendingAction, child: const Text('Approve pending'), ), if (canModerate) PopupMenuItem( value: _TimelineBlockAction.reject, enabled: !isPendingAction, child: const Text('Reject pending'), ), if (onDeleteEntry != null) const PopupMenuItem( value: _TimelineBlockAction.delete, child: Text('Delete'), ), ], ); final entry = block.entry; if (action == null || entry == null) return; switch (action) { case _TimelineBlockAction.edit: onEditEntry?.call(entry); break; case _TimelineBlockAction.delete: onDeleteEntry?.call(entry); break; case _TimelineBlockAction.approve: onModeratePending?.call(entry, _PendingModerationAction.approve); break; case _TimelineBlockAction.reject: onModeratePending?.call(entry, _PendingModerationAction.reject); break; } } return GestureDetector( behavior: HitTestBehavior.deferToChild, onLongPressStart: (details) async { if (defaultTargetPlatform == TargetPlatform.android) { HapticFeedback.lightImpact(); } await showContextMenuAt(details.globalPosition); }, onSecondaryTapDown: (details) async { await showContextMenuAt(details.globalPosition); }, child: _ValueBlockView( block: block, pendingActionEventIds: pendingActionEventIds, ), ); } } 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; final direct = DateTime.tryParse(value); if (direct != null) return direct; final maskedMatch = RegExp(r'^(\\d{4})-(\\d{2}|xx|XX)-(\\d{2}|xx|XX)\$').firstMatch(value); if (maskedMatch != null) { final year = int.tryParse(maskedMatch.group(1) ?? ''); if (year == null) return null; String normalize(String part, int fallback) { final lower = part.toLowerCase(); if (lower == 'xx') return fallback.toString().padLeft(2, '0'); return part; } final month = int.tryParse(normalize(maskedMatch.group(2) ?? '01', 1)) ?? 1; final day = int.tryParse(normalize(maskedMatch.group(3) ?? '01', 1)) ?? 1; try { return DateTime(year, month.clamp(1, 12), day.clamp(1, 31)); } catch (_) { return null; } } return null; } 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; } int _startKey(DateTime date) => date.year * 10000 + date.month * 100 + date.day; bool _isOverlappingStart(LocoAttrVersion entry, Set approvedStartKeys) { final start = _effectiveStart(entry); if (start == null) return false; return approvedStartKeys.contains(_startKey(start)); } List<_ValueSegment> _segmentsForEntries( List items, DateTime now, { bool? clampToNextStart, }) { if (items.isEmpty) return const []; final hasPending = items.any((e) => e.isPending); final hasApproved = items.any((e) => !e.isPending); final shouldClamp = clampToNextStart ?? (hasPending && hasApproved); final sorted = [...items]; sorted.sort( (a, b) => (_effectiveStart(a) ?? now) .compareTo(_effectiveStart(b) ?? now), ); final segments = <_ValueSegment>[]; for (int i = 0; i < sorted.length; i++) { final entry = sorted[i]; final start = _effectiveStart(entry) ?? now; final nextStart = i < sorted.length - 1 ? _effectiveStart(sorted[i + 1]) : null; DateTime? rawEnd = entry.validTo; if (nextStart != null) { if (rawEnd == null || (shouldClamp && nextStart.isBefore(rawEnd))) { rawEnd = nextStart; } } rawEnd ??= now; final end = _safeEnd(start, rawEnd); segments.add( _ValueSegment( start: start, end: end, value: _formatValueWithUnits(entry), entry: entry, ), ); } return segments; } List _applyPendingOverrides( List approved, List pending, ) { if (pending.isEmpty) return approved; final pendingByStart = {}; final extraPending = []; for (final entry in pending) { final start = _effectiveStart(entry); if (start == null) continue; final key = _startKey(start); pendingByStart[key] = entry; } final applied = []; final seenKeys = {}; for (final entry in approved) { final start = _effectiveStart(entry); if (start == null) continue; final key = _startKey(start); if (pendingByStart.containsKey(key)) { if (!seenKeys.contains(key)) { applied.add(pendingByStart[key]!); seenKeys.add(key); } } else { applied.add(entry); seenKeys.add(key); } } for (final entry in pendingByStart.values) { final start = _effectiveStart(entry); if (start == null) continue; final key = _startKey(start); if (!seenKeys.contains(key)) { extraPending.add(entry); seenKeys.add(key); } } if (extraPending.isNotEmpty) { applied.addAll(extraPending); } return applied; } List _buildBoundaries( List<_ValueSegment> segments, DateTime now, ) { DateTime? minStart; DateTime? maxEnd; final boundaryDates = {}; for (final seg in segments) { boundaryDates.add(seg.start); boundaryDates.add(seg.end); minStart = minStart == null || seg.start.isBefore(minStart!) ? seg.start : minStart; maxEnd = maxEnd == null || seg.end.isAfter(maxEnd!) ? seg.end : maxEnd; } minStart ??= now.subtract(const Duration(days: 1)); final effectiveMaxEnd = maxEnd ?? now; boundaryDates.add(effectiveMaxEnd); var boundaries = boundaryDates.toList()..sort(); if (boundaries.length < 2) { boundaries = [minStart!, effectiveMaxEnd]; } return boundaries; } class _TimelineRowSpec { final String id; final String attrCode; final List<_ValueSegment> segments; final bool isPrimary; final bool showExpandToggle; final bool isExpanded; final String? userLabel; const _TimelineRowSpec._({ required this.id, required this.attrCode, required this.segments, required this.isPrimary, required this.showExpandToggle, required this.isExpanded, this.userLabel, }); factory _TimelineRowSpec.primary({ required String attrCode, required List<_ValueSegment> segments, required bool showExpandToggle, required bool isExpanded, }) { return _TimelineRowSpec._( id: attrCode, attrCode: attrCode, segments: segments, isPrimary: true, showExpandToggle: showExpandToggle, isExpanded: isExpanded, ); } factory _TimelineRowSpec.pending({ required String attrCode, required String userLabel, required List<_ValueSegment> segments, }) { return _TimelineRowSpec._( id: '$attrCode::$userLabel', attrCode: attrCode, segments: segments, isPrimary: false, showExpandToggle: false, isExpanded: false, userLabel: userLabel, ); } } class _TimelineRowData { final String id; final String attrCode; final List<_ValueBlock> blocks; final bool isPrimary; final bool showExpandToggle; final bool isExpanded; final String? pendingUser; const _TimelineRowData({ required this.id, required this.attrCode, required this.blocks, required this.isPrimary, required this.showExpandToggle, required this.isExpanded, this.pendingUser, }); } class _TimelineModel { final List<_AxisSegment> axisSegments; final List<_TimelineRowData> rows; final String endLabel; final List boundaries; final double axisTotalWidth; _TimelineModel({ required this.axisSegments, required this.rows, required this.endLabel, required this.boundaries, required this.axisTotalWidth, }); factory _TimelineModel.fromEntries( List entries, { Set expandedAttrCodes = const {}, }) { final effectiveEntries = entries .where((e) => _effectiveStart(e) != null) .toList(); final grouped = >{}; final attrOrder = []; for (final entry in effectiveEntries) { final key = entry.attrCode; if (!grouped.containsKey(key)) { attrOrder.add(key); } grouped.putIfAbsent(key, () => []).add(entry); } final now = DateTime.now(); final allSegments = <_ValueSegment>[]; final rowSpecs = <_TimelineRowSpec>[]; for (final attr in attrOrder) { final items = grouped[attr] ?? const []; final approved = items.where((e) => !e.isPending).toList(); final pending = items.where((e) => e.isPending).toList(); final approvedSegments = _segmentsForEntries(approved, now); final approvedStartKeys = {}; for (final entry in approved) { final start = _effectiveStart(entry); if (start == null) continue; approvedStartKeys.add(_startKey(start)); } final pendingByUser = >{}; final overlapByUser = >{}; for (final entry in pending) { final user = (entry.suggestedBy ?? '').trim().isEmpty ? 'Unknown' : entry.suggestedBy!.trim(); pendingByUser.putIfAbsent(user, () => []).add(entry); final start = _effectiveStart(entry); if (start == null) continue; if (approvedStartKeys.contains(_startKey(start))) { overlapByUser.putIfAbsent(user, () => []).add(entry); } } final hasOverlap = overlapByUser.isNotEmpty; final canToggle = pending.isNotEmpty && !hasOverlap; final isExpanded = expandedAttrCodes.contains(attr); final shouldShowPendingRows = isExpanded || hasOverlap; final nonOverlapPending = pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList(); final baseEntries = shouldShowPendingRows ? approved : [...approved, ...nonOverlapPending]; final baseSegments = shouldShowPendingRows ? approvedSegments : _segmentsForEntries(baseEntries, now); rowSpecs.add( _TimelineRowSpec.primary( attrCode: attr, segments: baseSegments, showExpandToggle: canToggle, isExpanded: isExpanded, ), ); allSegments.addAll(baseSegments); if (shouldShowPendingRows) { final users = isExpanded ? pendingByUser.keys.toList() : overlapByUser.keys.toList(); users.sort(); for (final user in users) { final pendingEntries = isExpanded ? (pendingByUser[user] ?? const []) : (overlapByUser[user] ?? const []); if (pendingEntries.isEmpty) continue; final appliedEntries = _applyPendingOverrides(approved, pendingEntries); final combinedSegments = _segmentsForEntries(appliedEntries, now); rowSpecs.add( _TimelineRowSpec.pending( attrCode: attr, userLabel: user, segments: combinedSegments, ), ); allSegments.addAll(combinedSegments); } } } final boundaries = _buildBoundaries(allSegments, now); 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]; const 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 rows = <_TimelineRowData>[]; for (final spec in rowSpecs) { final blocks = <_ValueBlock>[]; for (final seg in spec.segments) { 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), entry: seg.entry, ), ); } rows.add( _TimelineRowData( id: spec.id, attrCode: spec.attrCode, blocks: blocks, isPrimary: spec.isPrimary, showExpandToggle: spec.showExpandToggle, isExpanded: spec.isExpanded, pendingUser: spec.userLabel, ), ); } final endLabel = boundaries.isNotEmpty ? _formatDate(boundaries.last) ?? 'Now' : 'Now'; return _TimelineModel( axisSegments: axisSegments, rows: rows, 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; } } 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; 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; final bool isPending; const _RowCell({ required this.value, required this.rangeLabel, required this.color, this.isPending = false, }); factory _RowCell.fromSegment(_ValueSegment seg) { if (seg.value.isEmpty) { return const _RowCell( value: '', rangeLabel: '', color: Colors.transparent, isPending: false, ); } 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, color: _colorForValue(seg.value), isPending: entry?.isPending ?? false, ); } } class _ValueBlock { final double left; final double width; final _RowCell cell; final LocoAttrVersion? entry; const _ValueBlock({ required this.left, required this.width, required this.cell, required this.entry, }); double get right => left + width; _ValueBlock copyWith({ double? left, double? width, _RowCell? cell, LocoAttrVersion? entry, }) { return _ValueBlock( left: left ?? this.left, width: width ?? this.width, cell: cell ?? this.cell, entry: entry ?? this.entry, ); } } Color _colorForValue(String value) { final hue = (value.hashCode % 360).toDouble(); final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55); return hsl.toColor(); }