part of 'package:mileograph_flutter/components/pages/loco_timeline.dart'; final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); class _TimelineGrid extends StatefulWidget { const _TimelineGrid({ required this.entries, this.onEditEntry, this.onDeleteEntry, }); final List entries; final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry; @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 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 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, onEditEntry: widget.onEditEntry, onDeleteEntry: widget.onDeleteEntry, ), ); }, ), ), ], ), ), ), ), ), ], ), ), ], ); } } 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, }); 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; @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, ), ), 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.withValues(alpha: 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.withValues(alpha: 0.9), ) ?? TextStyle(color: textColor.withValues(alpha: 0.9)), ), ], ), ), ), ), ); } } enum _TimelineBlockAction { edit, delete } class _ValueBlockMenu extends StatelessWidget { const _ValueBlockMenu({ required this.block, this.onEditEntry, this.onDeleteEntry, }); final _ValueBlock block; final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry; bool get _hasActions => onEditEntry != null || onDeleteEntry != null; @override Widget build(BuildContext context) { if (!_hasActions || block.entry == null) { return _ValueBlockView(block: block); } return GestureDetector( behavior: HitTestBehavior.opaque, onLongPressStart: (details) async { final overlay = Overlay.of(context); final renderBox = overlay.context.findRenderObject() as RenderBox?; if (renderBox == null) return; if (defaultTargetPlatform == TargetPlatform.android) { HapticFeedback.lightImpact(); } final anchor = details.globalPosition + const Offset(0, -8); final position = RelativeRect.fromRect( Rect.fromLTWH( anchor.dx, anchor.dy, 1, 1, ), Offset.zero & renderBox.size, ); final action = await showMenu<_TimelineBlockAction>( context: context, position: position, items: [ if (onEditEntry != null) const PopupMenuItem( value: _TimelineBlockAction.edit, child: Text('Edit'), ), 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; } }, child: _ValueBlockView(block: block), ); } } 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); 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]; 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 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), entry: seg.entry, ), ); } 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; 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(); }