import "package:flutter/material.dart"; class TripDiagramEntry { const TripDiagramEntry({required this.label, this.labelIcon, this.subtitle, this.time}); final String label; final IconData? labelIcon; final String? subtitle; final String? time; } class TripDiagram extends StatelessWidget { const TripDiagram({ required this.entries, super.key, this.leftOffset = 0, this.rowHeight = 32, this.lineWidth = 7, this.lineColor, this.highlightLastEntry = false, this.labelOffset = 26, this.rightPadding = 16, this.oddRowColor, this.terminalRowColor, this.stationTextStyle, this.timeTextStyle, this.missingTimeTextStyle, this.emptyMessage = "No stations found.", this.emptyTextStyle, }); final List entries; final double leftOffset; final double rowHeight; final double lineWidth; final Color? lineColor; final bool highlightLastEntry; final double labelOffset; final double rightPadding; final Color? oddRowColor; final Color? terminalRowColor; final TextStyle? stationTextStyle; final TextStyle? timeTextStyle; final TextStyle? missingTimeTextStyle; final String emptyMessage; final TextStyle? emptyTextStyle; @override Widget build(BuildContext context) { final resolvedLineColor = lineColor ?? Theme.of(context).colorScheme.primary; final deduped = entries; if (deduped.isEmpty) { return Text( emptyMessage, style: emptyTextStyle ?? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ); } final totalHeight = deduped.length * rowHeight; return Stack( children: [ SizedBox( height: totalHeight, width: double.infinity, child: CustomPaint( painter: _TripDiagramPainter( count: deduped.length, rowHeight: rowHeight, lineWidth: lineWidth, lineColor: resolvedLineColor, leftOffset: leftOffset, ), ), ), Positioned.fill( child: Column( children: List.generate(deduped.length, (index) { final entry = deduped[index]; final time = entry.time; final isTerminalRow = highlightLastEntry && index == deduped.length - 1; return Container( height: rowHeight, color: isTerminalRow ? (terminalRowColor ?? resolvedLineColor.withValues(alpha: 0.12)) : index.isOdd ? (oddRowColor ?? Colors.white.withValues(alpha: 0.03)) : null, child: Padding( padding: EdgeInsets.only( left: leftOffset + labelOffset, right: rightPadding, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Text( entry.label, style: stationTextStyle ?? const TextStyle( fontSize: 15, color: Color(0xFFDDDDDD), fontWeight: FontWeight.w600, letterSpacing: 0.5, ), overflow: TextOverflow.ellipsis, ), if (entry.labelIcon != null) ...[ const SizedBox(width: 4), Icon( entry.labelIcon, size: 12, color: const Color(0xFFBBBBBB), ), ] ], ), if (entry.subtitle != null) Text( entry.subtitle!, style: stationTextStyle ?? const TextStyle( fontSize: 11, color: Color(0xFFDDDDDD), fontWeight: FontWeight.w600, letterSpacing: 0.5, ), overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 12), Text( time ?? "--:--", style: time != null ? (timeTextStyle ?? const TextStyle( fontSize: 15, fontFamily: "monospace", fontWeight: FontWeight.w700, color: Color(0xFFEEEEEE), )) : (missingTimeTextStyle ?? const TextStyle( fontSize: 15, fontFamily: "monospace", fontWeight: FontWeight.w700, color: Color(0xFF555555), )), ), ], ), ), ); }), ), ), ], ); } } class _TripDiagramPainter extends CustomPainter { const _TripDiagramPainter({ required this.count, required this.rowHeight, required this.lineWidth, required this.lineColor, this.leftOffset = 0, }); final int count; final double rowHeight; final double lineWidth; final Color lineColor; final double leftOffset; static const double _linePad = 4.0; static const double _blobPad = 4.0; static const double _centerSize = 4.0; static const double _cornerFactor = 0.3; @override void paint(Canvas canvas, Size size) { if (count <= 0) return; final strokeWidth = lineWidth / 2.0; final ringSize = _centerSize + strokeWidth; final paddingWidth = strokeWidth + _blobPad; final centerX = leftOffset + ringSize / 2 + paddingWidth / 2 + 2; final firstY = rowHeight / 2; final lastY = (count - 1) * rowHeight + rowHeight / 2; final nodeRects = List.generate(count, (index) { final centerY = index * rowHeight + rowHeight / 2; final rect = Rect.fromCenter( center: Offset(centerX, centerY), width: ringSize, height: ringSize, ); return RRect.fromRectAndRadius( rect, Radius.circular(ringSize * _cornerFactor), ); }); for (final rect in nodeRects) { canvas.drawRRect( rect, Paint() ..color = Colors.white ..strokeWidth = paddingWidth ..style = PaintingStyle.stroke, ); } canvas.drawLine( Offset(centerX, firstY), Offset(centerX, lastY), Paint() ..color = Colors.white ..strokeWidth = lineWidth + _linePad ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke, ); canvas.drawLine( Offset(centerX, firstY), Offset(centerX, lastY), Paint() ..color = lineColor ..strokeWidth = lineWidth ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke, ); for (final rect in nodeRects) { final centerRect = rect.deflate(strokeWidth / 2); canvas.drawRRect( centerRect, Paint() ..color = Colors.white ..style = PaintingStyle.fill, ); canvas.drawRRect( rect, Paint() ..color = lineColor ..strokeWidth = strokeWidth ..style = PaintingStyle.stroke, ); } } @override bool shouldRepaint(covariant _TripDiagramPainter oldDelegate) { return oldDelegate.count != count || oldDelegate.rowHeight != rowHeight || oldDelegate.lineWidth != lineWidth || oldDelegate.lineColor != lineColor || oldDelegate.leftOffset != leftOffset; } }