diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 73a295ecb..5c59cf912 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -26,9 +26,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b PODFILE CHECKSUM: 0dbd5a87e0ace00c9610d2037ac22083a01f861d diff --git a/example/lib/presentation/samples/chart_samples.dart b/example/lib/presentation/samples/chart_samples.dart index 74d2ec7a0..f9029765f 100644 --- a/example/lib/presentation/samples/chart_samples.dart +++ b/example/lib/presentation/samples/chart_samples.dart @@ -10,6 +10,7 @@ import 'bar/bar_chart_sample6.dart'; import 'bar/bar_chart_sample7.dart'; import 'bar/bar_chart_sample8.dart'; import 'chart_sample.dart'; +import 'line/draggable_line_chart_sample.dart'; import 'line/line_chart_sample1.dart'; import 'line/line_chart_sample10.dart'; import 'line/line_chart_sample11.dart'; @@ -46,6 +47,7 @@ class ChartSamples { LineChartSample(11, (context) => const LineChartSample11()), LineChartSample(12, (context) => const LineChartSample12()), LineChartSample(13, (context) => const LineChartSample13()), + LineChartSample(14, (context) => const DraggableLineChartSample()), ], ChartType.bar: [ BarChartSample(1, (context) => BarChartSample1()), diff --git a/example/lib/presentation/samples/line/draggable_line_chart_sample.dart b/example/lib/presentation/samples/line/draggable_line_chart_sample.dart new file mode 100644 index 000000000..e66162db5 --- /dev/null +++ b/example/lib/presentation/samples/line/draggable_line_chart_sample.dart @@ -0,0 +1,241 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +/// Example demonstrating drag-to-edit functionality with DraggableLineChart +/// Launches a fullscreen demo to avoid scrollable context issues +class DraggableLineChartSample extends StatelessWidget { + const DraggableLineChartSample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.all(24.0), + child: Text( + 'Interactive Draggable Chart', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + 'Point dragging unsupported in scrollable context - Tap the button to try the draggable chart in fullscreen mode.', + style: TextStyle(fontSize: 14), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const _DraggableLineChartDemo(), + ), + ); + }, + icon: const Icon(Icons.touch_app), + label: const Text('Launch Interactive Demo'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + ), + ), + ], + ), + ); + } +} + +/// Fullscreen demo of the draggable chart +class _DraggableLineChartDemo extends StatefulWidget { + const _DraggableLineChartDemo(); + + @override + State<_DraggableLineChartDemo> createState() => + _DraggableLineChartDemoState(); +} + +class _DraggableLineChartDemoState extends State<_DraggableLineChartDemo> { + // Mutable data that can be edited by dragging + List dataPoints = [ + const FlSpot(0, 3), + const FlSpot(2, 5), + const FlSpot(4, 2), + const FlSpot(6, 7), + const FlSpot(8, 5), + const FlSpot(10, 8), + ]; + + int? selectedPointIndex; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Draggable Line Chart'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + setState(() { + // Reset to initial data + dataPoints = [ + const FlSpot(0, 3), + const FlSpot(2, 5), + const FlSpot(4, 2), + const FlSpot(6, 7), + const FlSpot(8, 5), + const FlSpot(10, 8), + ]; + selectedPointIndex = null; + }); + }, + tooltip: 'Reset Data', + ), + ], + ), + body: Column( + children: [ + const Padding( + padding: EdgeInsets.all(16.0), + child: Card( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Column( + children: [ + Text( + 'Try it out!', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + '• Tap any point to select it (turns red)\n' + '• Drag points to move them around\n' + '• Use the refresh button to reset', + style: TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: DraggableLineChart( + dragTolerance: 10.0, + // Add constraints to keep points in order and within bounds + constrainDrag: (barIndex, spotIndex, oldSpot, proposedSpot) { + // Get the previous and next points to constrain X movement + // Add padding to prevent points from getting too close + const minSpacing = 0.8; + final prevX = spotIndex > 0 + ? dataPoints[spotIndex - 1].x + minSpacing + : 0.0; + final nextX = spotIndex < dataPoints.length - 1 + ? dataPoints[spotIndex + 1].x - minSpacing + : 10.0; + + // Constrain X to stay between neighbors with spacing + final constrainedX = proposedSpot.x.clamp(prevX, nextX); + // Constrain Y within chart bounds + final constrainedY = proposedSpot.y.clamp(0.0, 10.0); + + return FlSpot(constrainedX, constrainedY); + }, + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + gridData: FlGridData( + show: true, + drawVerticalLine: true, + drawHorizontalLine: true, + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: const TextStyle(fontSize: 12), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: const TextStyle(fontSize: 12), + ); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: true), + lineBarsData: [ + LineChartBarData( + spots: dataPoints, + isCurved: true, + color: Colors.blue, + barWidth: 3, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) { + final isSelected = index == selectedPointIndex; + return FlDotCirclePainter( + radius: isSelected ? 10 : 6, + color: isSelected ? Colors.red : Colors.blue, + strokeWidth: 2, + strokeColor: Colors.white, + ); + }, + ), + ), + ], + ), + onSpotTap: (barIndex, spotIndex, spot) { + setState(() { + selectedPointIndex = spotIndex; + }); + }, + onDragStart: (barIndex, spotIndex, spot) { + setState(() { + selectedPointIndex = spotIndex; + }); + }, + onDragUpdate: (barIndex, spotIndex, oldSpot, newSpot) { + setState(() { + dataPoints[spotIndex] = FlSpot( + newSpot.x.clamp(0.0, 10.0), + newSpot.y.clamp(0.0, 10.0), + ); + }); + }, + onDragEnd: () { + // Drag complete + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/fl_chart.dart b/lib/fl_chart.dart index efc2e1e9e..cd0b9f054 100644 --- a/lib/fl_chart.dart +++ b/lib/fl_chart.dart @@ -8,6 +8,7 @@ export 'src/chart/base/base_chart/base_chart_data.dart'; export 'src/chart/base/base_chart/fl_touch_event.dart'; export 'src/chart/candlestick_chart/candlestick_chart.dart'; export 'src/chart/candlestick_chart/candlestick_chart_data.dart'; +export 'src/chart/line_chart/draggable_line_chart.dart'; export 'src/chart/line_chart/line_chart.dart'; export 'src/chart/line_chart/line_chart_data.dart'; export 'src/chart/pie_chart/pie_chart.dart'; diff --git a/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart b/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart index d0f67603f..10c1b956e 100644 --- a/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart +++ b/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart @@ -28,8 +28,7 @@ class SideTitlesWidget extends StatefulWidget { } class _SideTitlesWidgetState extends State { - bool get isHorizontal => - widget.side == AxisSide.top || widget.side == AxisSide.bottom; + bool get isHorizontal => widget.side == AxisSide.top || widget.side == AxisSide.bottom; bool get isVertical => !isHorizontal; @@ -45,19 +44,42 @@ class _SideTitlesWidgetState extends State { double get baselineY => widget.axisChartData.baselineY; - double get axisMin => isHorizontal ? minX : minY; + // For LineChartData, use minRightY/maxRightY for the right axis + double get minRightY { + if (widget.axisChartData is LineChartData) { + return (widget.axisChartData as LineChartData).minRightY; + } + return minY; + } + + double get maxRightY { + if (widget.axisChartData is LineChartData) { + return (widget.axisChartData as LineChartData).maxRightY; + } + return maxY; + } + + double get axisMin { + if (isHorizontal) return minX; + // Use right Y-axis values for the right side + if (widget.side == AxisSide.right) return minRightY; + return minY; + } - double get axisMax => isHorizontal ? maxX : maxY; + double get axisMax { + if (isHorizontal) return maxX; + // Use right Y-axis values for the right side + if (widget.side == AxisSide.right) return maxRightY; + return maxY; + } double get axisBaseLine => isHorizontal ? baselineX : baselineY; FlTitlesData get titlesData => widget.axisChartData.titlesData; - bool get isLeftOrTop => - widget.side == AxisSide.left || widget.side == AxisSide.top; + bool get isLeftOrTop => widget.side == AxisSide.left || widget.side == AxisSide.top; - bool get isRightOrBottom => - widget.side == AxisSide.right || widget.side == AxisSide.bottom; + bool get isRightOrBottom => widget.side == AxisSide.right || widget.side == AxisSide.bottom; AxisTitles get axisTitles => switch (widget.side) { AxisSide.left => titlesData.leftTitles, @@ -83,12 +105,8 @@ class _SideTitlesWidgetState extends State { final titlesPadding = titlesData.allSidesPadding; final borderPadding = widget.axisChartData.borderData.allSidesPadding; return switch (widget.side) { - AxisSide.right || - AxisSide.left => - titlesPadding.onlyTopBottom + borderPadding.onlyTopBottom, - AxisSide.top || - AxisSide.bottom => - titlesPadding.onlyLeftRight + borderPadding.onlyLeftRight, + AxisSide.right || AxisSide.left => titlesPadding.onlyTopBottom + borderPadding.onlyTopBottom, + AxisSide.top || AxisSide.bottom => titlesPadding.onlyLeftRight + borderPadding.onlyLeftRight, }; } @@ -96,12 +114,8 @@ class _SideTitlesWidgetState extends State { final borderPadding = widget.axisChartData.borderData.allSidesPadding; final titlesPadding = titlesData.allSidesPadding; return switch (widget.side) { - AxisSide.right || - AxisSide.left => - titlesPadding.vertical + borderPadding.vertical, - AxisSide.top || - AxisSide.bottom => - titlesPadding.horizontal + borderPadding.horizontal, + AxisSide.right || AxisSide.left => titlesPadding.vertical + borderPadding.vertical, + AxisSide.top || AxisSide.bottom => titlesPadding.horizontal + borderPadding.horizontal, }; } @@ -111,8 +125,7 @@ class _SideTitlesWidgetState extends State { if (chartVirtualRect == null) { size = widget.parentSize; } else { - size = chartVirtualRect.size + - Offset(thisSidePaddingTotal, thisSidePaddingTotal); + size = chartVirtualRect.size + Offset(thisSidePaddingTotal, thisSidePaddingTotal); } return size.rotateByQuarterTurns( @@ -223,12 +236,8 @@ class _SideTitlesWidgetState extends State { return axisPositions.where((metaData) { final location = metaData.axisPixelLocation; return switch (side) { - AxisSide.left || - AxisSide.right => - chartRect.contains(Offset(0, location)), - AxisSide.top || - AxisSide.bottom => - chartRect.contains(Offset(location, 0)), + AxisSide.left || AxisSide.right => chartRect.contains(Offset(0, location)), + AxisSide.top || AxisSide.bottom => chartRect.contains(Offset(location, 0)), }; }).toList(); } diff --git a/lib/src/chart/line_chart/draggable_line_chart.dart b/lib/src/chart/line_chart/draggable_line_chart.dart new file mode 100644 index 000000000..9846daa09 --- /dev/null +++ b/lib/src/chart/line_chart/draggable_line_chart.dart @@ -0,0 +1,384 @@ +import 'dart:math'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:fl_chart/src/chart/line_chart/line_chart_helper.dart'; +import 'package:flutter/material.dart'; + +/// A callback for when a drag operation starts on a spot. +/// +/// Parameters: +/// - [barIndex]: The index of the bar (line) that was touched +/// - [spotIndex]: The index of the spot within that bar +/// - [spot]: The data coordinates of the touched spot (FlSpot with x and y values) +typedef DragStartCallback = void Function( + int barIndex, + int spotIndex, + FlSpot spot, +); + +/// A callback for when a spot is being dragged. +/// +/// Parameters: +/// - [barIndex]: The index of the bar (line) being dragged +/// - [spotIndex]: The index of the spot being dragged +/// - [oldSpot]: The previous data coordinates +/// - [newSpot]: The new data coordinates after the drag movement +typedef DragUpdateCallback = void Function( + int barIndex, + int spotIndex, + FlSpot oldSpot, + FlSpot newSpot, +); + +/// A callback for when a drag operation ends. +typedef DragEndCallback = void Function(); + +/// A callback for when a spot is tapped. +/// +/// Parameters: +/// - [barIndex]: The index of the bar (line) that was tapped +/// - [spotIndex]: The index of the spot that was tapped +/// - [spot]: The data coordinates of the tapped spot +typedef SpotTapCallback = void Function( + int barIndex, + int spotIndex, + FlSpot spot, +); + +/// A callback to determine if a specific bar/spot can be dragged. +/// +/// Return true to allow dragging, false to ignore. +/// If null, all spots are draggable. +typedef CanDragSpotCallback = bool Function( + int barIndex, + int spotIndex, +); + +/// A callback to constrain/modify the drag position. +/// +/// This allows enforcing business rules like: +/// - Keeping points in order +/// - Restricting movement to certain axes +/// - Clamping to valid ranges +/// +/// Return the constrained FlSpot position. +typedef ConstrainDragCallback = FlSpot Function( + int barIndex, + int spotIndex, + FlSpot oldSpot, + FlSpot proposedSpot, +); + +/// A LineChart widget that supports dragging spots by wrapping the chart +/// with a GestureDetector. This provides reliable drag behavior without +/// conflicting with the chart's tooltip system. +/// +/// This widget is useful for interactive chart editing where users need to +/// drag data points to new positions, such as in profile editors, +/// curve adjusters, or data visualization tools. +/// +/// Example usage: +/// ```dart +/// DraggableLineChart( +/// data: LineChartData( +/// lineBarsData: [ +/// LineChartBarData( +/// spots: [FlSpot(0, 1), FlSpot(1, 3), FlSpot(2, 2)], +/// ), +/// ], +/// minX: 0, +/// maxX: 10, +/// minY: 0, +/// maxY: 10, +/// ), +/// onDragStart: (barIndex, spotIndex, spot) { +/// print('Started dragging spot $spotIndex of bar $barIndex'); +/// }, +/// onDragUpdate: (barIndex, spotIndex, oldSpot, newSpot) { +/// // Update your data model with newSpot coordinates +/// print('Dragged to ${newSpot.x}, ${newSpot.y}'); +/// }, +/// onDragEnd: () { +/// print('Drag ended'); +/// }, +/// ) +/// ``` +class DraggableLineChart extends StatefulWidget { + const DraggableLineChart({ + super.key, + required this.data, + this.onDragStart, + this.onDragUpdate, + this.onDragEnd, + this.onSpotTap, + this.canDragSpot, + this.constrainDrag, + this.dragTolerance = 3.5, + this.dragThrottle = const Duration(milliseconds: 33), + }); + + /// The chart data to display. Note that [LineTouchData.enabled] will be + /// automatically set to false to prevent conflicts with drag gestures. + final LineChartData data; + + /// Called when a drag operation starts. + final DragStartCallback? onDragStart; + + /// Called repeatedly as a spot is dragged. Updates are throttled according + /// to [dragThrottle]. + final DragUpdateCallback? onDragUpdate; + + /// Called when a drag operation ends. + final DragEndCallback? onDragEnd; + + /// Called when a spot is tapped (not dragged). + final SpotTapCallback? onSpotTap; + + /// Callback to determine if a spot can be dragged. + /// Return true to allow dragging, false to ignore. + /// If null, all spots are draggable. + final CanDragSpotCallback? canDragSpot; + + /// Callback to constrain/modify drag positions. + /// This allows enforcing business rules like keeping points in order, + /// restricting movement to certain axes, or clamping to valid ranges. + /// If null, no constraints are applied. + final ConstrainDragCallback? constrainDrag; + + /// The maximum distance (in data units) from a spot for it to be considered + /// "hit" by a touch. Default is 3.5. + final double dragTolerance; + + /// Minimum time between drag update callbacks. Default is 33ms (~30 FPS). + final Duration dragThrottle; + + @override + State createState() => _DraggableLineChartState(); +} + +class _DraggableLineChartState extends State { + final GlobalKey _chartKey = GlobalKey(); + bool _isDragging = false; + int? _draggedBarIndex; + int? _draggedSpotIndex; + FlSpot? _draggedSpotInitial; + FlSpot? _lastDraggedSpot; // Track last position for accurate onPanEnd + DateTime? _lastUpdateTime; + + @override + Widget build(BuildContext context) { + // Ensure touch is disabled on the LineChart itself + final data = widget.data.copyWith( + lineTouchData: const LineTouchData(enabled: false), + ); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + _handleTapDown(details.localPosition); + }, + onPanDown: (details) { + _handlePanDown(details.localPosition); + }, + onPanStart: (details) { + _handlePanStart(details.localPosition); + }, + onPanUpdate: (details) { + if (_isDragging) { + _handlePanUpdate(details.localPosition); + } + }, + onPanEnd: (details) { + if (_isDragging) { + // Send final update with last known position to ensure accuracy + if (_draggedBarIndex != null && + _draggedSpotIndex != null && + _draggedSpotInitial != null && + _lastDraggedSpot != null) { + // Send final update (bypass throttle) with the last tracked position + widget.onDragUpdate?.call( + _draggedBarIndex!, + _draggedSpotIndex!, + _draggedSpotInitial!, + _lastDraggedSpot!, + ); + } + + _isDragging = false; + _draggedBarIndex = null; + _draggedSpotIndex = null; + _draggedSpotInitial = null; + _lastDraggedSpot = null; + _lastUpdateTime = null; + widget.onDragEnd?.call(); + } + }, + onPanCancel: () { + if (_isDragging) { + _isDragging = false; + _draggedBarIndex = null; + _draggedSpotIndex = null; + _draggedSpotInitial = null; + _lastDraggedSpot = null; + _lastUpdateTime = null; + widget.onDragEnd?.call(); + } + }, + child: Container( + key: _chartKey, + child: LineChart(data), + ), + ); + } + + void _handleTapDown(Offset position) { + if (widget.onSpotTap == null) return; + + final result = _findNearestSpot(position); + if (result != null) { + widget.onSpotTap?.call( + result.barIndex, + result.spotIndex, + result.spot, + ); + } + } + + void _handlePanDown(Offset position) { + // Check if we're near a draggable spot + final result = _findNearestSpot(position); + if (result != null && widget.onDragStart != null) { + _isDragging = true; + _draggedBarIndex = result.barIndex; + _draggedSpotIndex = result.spotIndex; + _draggedSpotInitial = result.spot; + + // CRITICAL: Call onDragStart immediately in onPanDown to claim gesture + // before scrolling can win the gesture arena + widget.onDragStart?.call( + result.barIndex, + result.spotIndex, + result.spot, + ); + } + } + + void _handlePanStart(Offset position) { + // If we didn't already start dragging in onPanDown, try here + if (!_isDragging && widget.onDragStart != null) { + final result = _findNearestSpot(position); + if (result != null) { + _isDragging = true; + _draggedBarIndex = result.barIndex; + _draggedSpotIndex = result.spotIndex; + _draggedSpotInitial = result.spot; + + widget.onDragStart?.call( + result.barIndex, + result.spotIndex, + result.spot, + ); + } + } + } + + void _handlePanUpdate(Offset position) { + if (_draggedBarIndex == null || _draggedSpotIndex == null || _draggedSpotInitial == null) { + return; + } + + var newSpot = screenToChartCoordinates(position); + if (newSpot == null) return; + + // Apply constraints if provided + if (widget.constrainDrag != null) { + newSpot = widget.constrainDrag!( + _draggedBarIndex!, + _draggedSpotIndex!, + _draggedSpotInitial!, + newSpot, + ); + } + + // Always track the last position (even if throttled) + _lastDraggedSpot = newSpot; + + // Throttle updates + final now = DateTime.now(); + if (_lastUpdateTime == null || now.difference(_lastUpdateTime!) >= widget.dragThrottle) { + _lastUpdateTime = now; + + widget.onDragUpdate?.call( + _draggedBarIndex!, + _draggedSpotIndex!, + _draggedSpotInitial!, + newSpot, + ); + } + } + + ({int barIndex, int spotIndex, FlSpot spot})? _findNearestSpot( + Offset position, + ) { + final renderBox = _chartKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return null; + + final size = renderBox.size; + + int? nearestBarIndex; + var nearestSpotIndex = 0; + var nearestSpot = FlSpot.zero; + var minDistance = double.infinity; + + for (var barIndex = 0; barIndex < widget.data.lineBarsData.length; barIndex++) { + final bar = widget.data.lineBarsData[barIndex]; + + for (var spotIndex = 0; spotIndex < bar.spots.length; spotIndex++) { + // Check if this spot can be dragged + if (widget.canDragSpot != null && !widget.canDragSpot!(barIndex, spotIndex)) { + continue; // Skip non-draggable spots + } + + final spot = bar.spots[spotIndex]; + + // Convert spot data coordinates to screen pixel coordinates using LineChartHelper + final spotScreen = LineChartHelper.dataToScreen(spot, size, widget.data); + + // Calculate pixel distance (Euclidean distance in screen space) + final dx = position.dx - spotScreen.dx; + final dy = position.dy - spotScreen.dy; + final pixelDistance = sqrt(dx * dx + dy * dy); + + if (pixelDistance < minDistance) { + minDistance = pixelDistance; + nearestBarIndex = barIndex; + nearestSpotIndex = spotIndex; + nearestSpot = spot; + } + } + } + + // dragTolerance is now in pixels (default 3.5 means 3.5 pixels) + // Multiply by a reasonable factor for easier tapping + final tolerancePixels = widget.dragTolerance * 10; // 35 pixels by default + + if (minDistance <= tolerancePixels && nearestBarIndex != null) { + return (barIndex: nearestBarIndex, spotIndex: nearestSpotIndex, spot: nearestSpot); + } + + return null; + } + + /// Converts screen pixel coordinates to chart data coordinates. + /// + /// Uses LineChartHelper for pixel-perfect coordinate conversion that matches + /// fl_chart's internal rendering logic. + FlSpot? screenToChartCoordinates(Offset screenPosition) { + final renderBox = _chartKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return null; + + final size = renderBox.size; + + // Use LineChartHelper for accurate conversion + return LineChartHelper.screenToData(screenPosition, size, widget.data); + } +} diff --git a/lib/src/chart/line_chart/line_chart_data.dart b/lib/src/chart/line_chart/line_chart_data.dart index 22ee479f5..1948777e4 100644 --- a/lib/src/chart/line_chart/line_chart_data.dart +++ b/lib/src/chart/line_chart/line_chart_data.dart @@ -56,15 +56,16 @@ class LineChartData extends AxisChartData with EquatableMixin { double? minY, double? maxY, super.baselineY, + double? minRightY, + double? maxRightY, + double? baselineRightY, super.clipData = const FlClipData.none(), super.backgroundColor, super.rotationQuarterTurns, - }) : super( - minX: minX ?? double.nan, - maxX: maxX ?? double.nan, - minY: minY ?? double.nan, - maxY: maxY ?? double.nan, - ); + }) : minRightY = minRightY ?? double.nan, + maxRightY = maxRightY ?? double.nan, + baselineRightY = baselineRightY ?? 0, + super(minX: minX ?? double.nan, maxX: maxX ?? double.nan, minY: minY ?? double.nan, maxY: maxY ?? double.nan); /// [LineChart] draws some lines in various shapes and overlaps them. final List lineBarsData; @@ -83,6 +84,18 @@ class LineChartData extends AxisChartData with EquatableMixin { /// to show the tooltip manually, see [LineTouchData.handleBuiltInTouches]. final List showingTooltipIndicators; + /// Minimum value for the right Y-axis (for series with useRightAxis = true) + final double minRightY; + + /// Maximum value for the right Y-axis (for series with useRightAxis = true) + final double maxRightY; + + /// Baseline value for the right Y-axis + final double baselineRightY; + + /// Difference of [maxRightY] and [minRightY] + double get verticalRightDiff => maxRightY - minRightY; + /// Lerps a [BaseChartData] based on [t] value, check [Tween.lerp]. @override LineChartData lerp(BaseChartData a, BaseChartData b, double t) { @@ -94,19 +107,18 @@ class LineChartData extends AxisChartData with EquatableMixin { minY: lerpDouble(a.minY, b.minY, t), maxY: lerpDouble(a.maxY, b.maxY, t), baselineY: lerpDouble(a.baselineY, b.baselineY, t), + minRightY: lerpDouble(a.minRightY, b.minRightY, t), + maxRightY: lerpDouble(a.maxRightY, b.maxRightY, t), + baselineRightY: lerpDouble(a.baselineRightY, b.baselineRightY, t), backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), borderData: FlBorderData.lerp(a.borderData, b.borderData, t), clipData: b.clipData, - extraLinesData: - ExtraLinesData.lerp(a.extraLinesData, b.extraLinesData, t), + extraLinesData: ExtraLinesData.lerp(a.extraLinesData, b.extraLinesData, t), gridData: FlGridData.lerp(a.gridData, b.gridData, t), titlesData: FlTitlesData.lerp(a.titlesData, b.titlesData, t), - rangeAnnotations: - RangeAnnotations.lerp(a.rangeAnnotations, b.rangeAnnotations, t), - lineBarsData: - lerpLineChartBarDataList(a.lineBarsData, b.lineBarsData, t)!, - betweenBarsData: - lerpBetweenBarsDataList(a.betweenBarsData, b.betweenBarsData, t)!, + rangeAnnotations: RangeAnnotations.lerp(a.rangeAnnotations, b.rangeAnnotations, t), + lineBarsData: lerpLineChartBarDataList(a.lineBarsData, b.lineBarsData, t)!, + betweenBarsData: lerpBetweenBarsDataList(a.betweenBarsData, b.betweenBarsData, t)!, lineTouchData: b.lineTouchData, showingTooltipIndicators: b.showingTooltipIndicators, rotationQuarterTurns: b.rotationQuarterTurns, @@ -134,54 +146,61 @@ class LineChartData extends AxisChartData with EquatableMixin { double? minY, double? maxY, double? baselineY, + double? minRightY, + double? maxRightY, + double? baselineRightY, FlClipData? clipData, Color? backgroundColor, int? rotationQuarterTurns, - }) => - LineChartData( - lineBarsData: lineBarsData ?? this.lineBarsData, - betweenBarsData: betweenBarsData ?? this.betweenBarsData, - titlesData: titlesData ?? this.titlesData, - rangeAnnotations: rangeAnnotations ?? this.rangeAnnotations, - extraLinesData: extraLinesData ?? this.extraLinesData, - lineTouchData: lineTouchData ?? this.lineTouchData, - showingTooltipIndicators: - showingTooltipIndicators ?? this.showingTooltipIndicators, - gridData: gridData ?? this.gridData, - borderData: borderData ?? this.borderData, - minX: minX ?? this.minX, - maxX: maxX ?? this.maxX, - baselineX: baselineX ?? this.baselineX, - minY: minY ?? this.minY, - maxY: maxY ?? this.maxY, - baselineY: baselineY ?? this.baselineY, - clipData: clipData ?? this.clipData, - backgroundColor: backgroundColor ?? this.backgroundColor, - rotationQuarterTurns: rotationQuarterTurns ?? this.rotationQuarterTurns, - ); + }) => LineChartData( + lineBarsData: lineBarsData ?? this.lineBarsData, + betweenBarsData: betweenBarsData ?? this.betweenBarsData, + titlesData: titlesData ?? this.titlesData, + rangeAnnotations: rangeAnnotations ?? this.rangeAnnotations, + extraLinesData: extraLinesData ?? this.extraLinesData, + lineTouchData: lineTouchData ?? this.lineTouchData, + showingTooltipIndicators: showingTooltipIndicators ?? this.showingTooltipIndicators, + gridData: gridData ?? this.gridData, + borderData: borderData ?? this.borderData, + minX: minX ?? this.minX, + maxX: maxX ?? this.maxX, + baselineX: baselineX ?? this.baselineX, + minY: minY ?? this.minY, + maxY: maxY ?? this.maxY, + baselineY: baselineY ?? this.baselineY, + minRightY: minRightY ?? this.minRightY, + maxRightY: maxRightY ?? this.maxRightY, + baselineRightY: baselineRightY ?? this.baselineRightY, + clipData: clipData ?? this.clipData, + backgroundColor: backgroundColor ?? this.backgroundColor, + rotationQuarterTurns: rotationQuarterTurns ?? this.rotationQuarterTurns, + ); /// Used for equality check, see [EquatableMixin]. @override List get props => [ - lineBarsData, - betweenBarsData, - titlesData, - extraLinesData, - lineTouchData, - showingTooltipIndicators, - gridData, - borderData, - rangeAnnotations, - minX, - maxX, - baselineX, - minY, - maxY, - baselineY, - clipData, - backgroundColor, - rotationQuarterTurns, - ]; + lineBarsData, + betweenBarsData, + titlesData, + extraLinesData, + lineTouchData, + showingTooltipIndicators, + gridData, + borderData, + rangeAnnotations, + minX, + maxX, + baselineX, + minY, + maxY, + baselineY, + minRightY, + maxRightY, + baselineRightY, + clipData, + backgroundColor, + rotationQuarterTurns, + ]; } enum LineChartGradientArea { @@ -190,7 +209,7 @@ enum LineChartGradientArea { rectAroundTheLine, /// The entire chart area will be used as the gradient area for the curve. - wholeChart; + wholeChart, } /// Holds data for drawing each individual line in the [LineChart] @@ -252,17 +271,16 @@ class LineChartBarData with EquatableMixin { BarAreaData? belowBarData, BarAreaData? aboveBarData, this.dotData = const FlDotData(), - this.errorIndicatorData = - const FlErrorIndicatorData(), + this.errorIndicatorData = const FlErrorIndicatorData(), this.showingIndicators = const [], this.dashArray, this.shadow = const Shadow(color: Colors.transparent), this.isStepLineChart = false, this.lineChartStepData = const LineChartStepData(), - }) : color = - color ?? ((color == null && gradient == null) ? Colors.cyan : null), - belowBarData = belowBarData ?? BarAreaData(), - aboveBarData = aboveBarData ?? BarAreaData() { + this.useRightAxis = false, + }) : color = color ?? ((color == null && gradient == null) ? Colors.cyan : null), + belowBarData = belowBarData ?? BarAreaData(), + aboveBarData = aboveBarData ?? BarAreaData() { FlSpot? mostLeft; FlSpot? mostTop; FlSpot? mostRight; @@ -270,8 +288,7 @@ class LineChartBarData with EquatableMixin { FlSpot? firstValidSpot; try { - firstValidSpot = - spots.firstWhere((element) => element != FlSpot.nullSpot); + firstValidSpot = spots.firstWhere((element) => element != FlSpot.nullSpot); } catch (_) { // There is no valid spot } @@ -372,8 +389,7 @@ class LineChartBarData with EquatableMixin { final FlDotData dotData; /// Holds data for showing error indicators on the spots in this line. - final FlErrorIndicatorData - errorIndicatorData; + final FlErrorIndicatorData errorIndicatorData; /// Show indicators based on provided indexes final List showingIndicators; @@ -390,44 +406,40 @@ class LineChartBarData with EquatableMixin { /// Holds data for representing a Step Line Chart, and works only if [isStepChart] is true. final LineChartStepData lineChartStepData; + /// Whether this series uses the right Y-axis instead of the left Y-axis. + /// When true, this series will be scaled according to [LineChartData.minRightY] and [LineChartData.maxRightY]. + /// When false (default), this series uses [LineChartData.minY] and [LineChartData.maxY]. + final bool useRightAxis; + /// Lerps a [LineChartBarData] based on [t] value, check [Tween.lerp]. - static LineChartBarData lerp( - LineChartBarData a, - LineChartBarData b, - double t, - ) => - LineChartBarData( - show: b.show, - barWidth: lerpDouble(a.barWidth, b.barWidth, t)!, - belowBarData: BarAreaData.lerp(a.belowBarData, b.belowBarData, t), - aboveBarData: BarAreaData.lerp(a.aboveBarData, b.aboveBarData, t), - curveSmoothness: b.curveSmoothness, - isCurved: b.isCurved, - isStrokeCapRound: b.isStrokeCapRound, - isStrokeJoinRound: b.isStrokeJoinRound, - preventCurveOverShooting: b.preventCurveOverShooting, - preventCurveOvershootingThreshold: lerpDouble( - a.preventCurveOvershootingThreshold, - b.preventCurveOvershootingThreshold, - t, - )!, - dotData: FlDotData.lerp(a.dotData, b.dotData, t), - errorIndicatorData: FlErrorIndicatorData.lerp( - a.errorIndicatorData, - b.errorIndicatorData, - t, - ), - dashArray: lerpIntList(a.dashArray, b.dashArray, t), - color: Color.lerp(a.color, b.color, t), - gradient: Gradient.lerp(a.gradient, b.gradient, t), - gradientArea: b.gradientArea, - spots: lerpFlSpotList(a.spots, b.spots, t)!, - showingIndicators: b.showingIndicators, - shadow: Shadow.lerp(a.shadow, b.shadow, t)!, - isStepLineChart: b.isStepLineChart, - lineChartStepData: - LineChartStepData.lerp(a.lineChartStepData, b.lineChartStepData, t), - ); + static LineChartBarData lerp(LineChartBarData a, LineChartBarData b, double t) => LineChartBarData( + show: b.show, + barWidth: lerpDouble(a.barWidth, b.barWidth, t)!, + belowBarData: BarAreaData.lerp(a.belowBarData, b.belowBarData, t), + aboveBarData: BarAreaData.lerp(a.aboveBarData, b.aboveBarData, t), + curveSmoothness: b.curveSmoothness, + isCurved: b.isCurved, + isStrokeCapRound: b.isStrokeCapRound, + isStrokeJoinRound: b.isStrokeJoinRound, + preventCurveOverShooting: b.preventCurveOverShooting, + preventCurveOvershootingThreshold: lerpDouble( + a.preventCurveOvershootingThreshold, + b.preventCurveOvershootingThreshold, + t, + )!, + dotData: FlDotData.lerp(a.dotData, b.dotData, t), + errorIndicatorData: FlErrorIndicatorData.lerp(a.errorIndicatorData, b.errorIndicatorData, t), + dashArray: lerpIntList(a.dashArray, b.dashArray, t), + color: Color.lerp(a.color, b.color, t), + gradient: Gradient.lerp(a.gradient, b.gradient, t), + gradientArea: b.gradientArea, + spots: lerpFlSpotList(a.spots, b.spots, t)!, + showingIndicators: b.showingIndicators, + shadow: Shadow.lerp(a.shadow, b.shadow, t)!, + isStepLineChart: b.isStepLineChart, + lineChartStepData: LineChartStepData.lerp(a.lineChartStepData, b.lineChartStepData, t), + useRightAxis: b.useRightAxis, + ); /// Copies current [LineChartBarData] to a new [LineChartBarData], /// and replaces provided values. @@ -447,65 +459,64 @@ class LineChartBarData with EquatableMixin { BarAreaData? belowBarData, BarAreaData? aboveBarData, FlDotData? dotData, - FlErrorIndicatorData? - errorIndicatorData, + FlErrorIndicatorData? errorIndicatorData, List? dashArray, List? showingIndicators, Shadow? shadow, bool? isStepLineChart, LineChartStepData? lineChartStepData, - }) => - LineChartBarData( - spots: spots ?? this.spots, - show: show ?? this.show, - color: color ?? this.color, - gradient: gradient ?? this.gradient, - gradientArea: gradientArea ?? this.gradientArea, - barWidth: barWidth ?? this.barWidth, - isCurved: isCurved ?? this.isCurved, - curveSmoothness: curveSmoothness ?? this.curveSmoothness, - preventCurveOverShooting: - preventCurveOverShooting ?? this.preventCurveOverShooting, - preventCurveOvershootingThreshold: preventCurveOvershootingThreshold ?? - this.preventCurveOvershootingThreshold, - isStrokeCapRound: isStrokeCapRound ?? this.isStrokeCapRound, - isStrokeJoinRound: isStrokeJoinRound ?? this.isStrokeJoinRound, - belowBarData: belowBarData ?? this.belowBarData, - aboveBarData: aboveBarData ?? this.aboveBarData, - dashArray: dashArray ?? this.dashArray, - dotData: dotData ?? this.dotData, - errorIndicatorData: errorIndicatorData ?? this.errorIndicatorData, - showingIndicators: showingIndicators ?? this.showingIndicators, - shadow: shadow ?? this.shadow, - isStepLineChart: isStepLineChart ?? this.isStepLineChart, - lineChartStepData: lineChartStepData ?? this.lineChartStepData, - ); + bool? useRightAxis, + }) => LineChartBarData( + spots: spots ?? this.spots, + show: show ?? this.show, + color: color ?? this.color, + gradient: gradient ?? this.gradient, + gradientArea: gradientArea ?? this.gradientArea, + barWidth: barWidth ?? this.barWidth, + isCurved: isCurved ?? this.isCurved, + curveSmoothness: curveSmoothness ?? this.curveSmoothness, + preventCurveOverShooting: preventCurveOverShooting ?? this.preventCurveOverShooting, + preventCurveOvershootingThreshold: preventCurveOvershootingThreshold ?? this.preventCurveOvershootingThreshold, + isStrokeCapRound: isStrokeCapRound ?? this.isStrokeCapRound, + isStrokeJoinRound: isStrokeJoinRound ?? this.isStrokeJoinRound, + belowBarData: belowBarData ?? this.belowBarData, + aboveBarData: aboveBarData ?? this.aboveBarData, + dashArray: dashArray ?? this.dashArray, + dotData: dotData ?? this.dotData, + errorIndicatorData: errorIndicatorData ?? this.errorIndicatorData, + showingIndicators: showingIndicators ?? this.showingIndicators, + shadow: shadow ?? this.shadow, + isStepLineChart: isStepLineChart ?? this.isStepLineChart, + lineChartStepData: lineChartStepData ?? this.lineChartStepData, + useRightAxis: useRightAxis ?? this.useRightAxis, + ); /// Used for equality check, see [EquatableMixin]. @override List get props => [ - spots, - show, - color, - gradient, - gradientArea, - barWidth, - isCurved, - curveSmoothness, - preventCurveOverShooting, - preventCurveOvershootingThreshold, - isStrokeCapRound, - isStrokeJoinRound, - belowBarData, - aboveBarData, - dotData, - errorIndicatorData, - showingIndicators, - dashArray, - shadow, - isStepLineChart, - lineChartStepData, - ]; + spots, + show, + color, + gradient, + gradientArea, + barWidth, + isCurved, + curveSmoothness, + preventCurveOverShooting, + preventCurveOvershootingThreshold, + isStrokeCapRound, + isStrokeJoinRound, + belowBarData, + aboveBarData, + dotData, + errorIndicatorData, + showingIndicators, + dashArray, + shadow, + isStepLineChart, + lineChartStepData, + useRightAxis, + ]; } /// Holds data for representing a Step Line Chart, and works only if [LineChartBarData.isStepChart] is true. @@ -526,14 +537,8 @@ class LineChartStepData with EquatableMixin { final double stepDirection; /// Lerps a [LineChartStepData] based on [t] value, check [Tween.lerp]. - static LineChartStepData lerp( - LineChartStepData a, - LineChartStepData b, - double t, - ) => - LineChartStepData( - stepDirection: lerpDouble(a.stepDirection, b.stepDirection, t)!, - ); + static LineChartStepData lerp(LineChartStepData a, LineChartStepData b, double t) => + LineChartStepData(stepDirection: lerpDouble(a.stepDirection, b.stepDirection, t)!); /// Used for equality check, see [EquatableMixin]. @override @@ -563,10 +568,7 @@ class BarAreaData with EquatableMixin { this.spotsLine = const BarAreaSpotsLine(), this.cutOffY = 0, this.applyCutOffY = false, - }) : color = color ?? - ((color == null && gradient == null) - ? Colors.blueGrey.withValues(alpha: 0.5) - : null); + }) : color = color ?? ((color == null && gradient == null) ? Colors.blueGrey.withValues(alpha: 0.5) : null); final bool show; @@ -590,39 +592,24 @@ class BarAreaData with EquatableMixin { final bool applyCutOffY; /// Lerps a [BarAreaData] based on [t] value, check [Tween.lerp]. - static BarAreaData lerp(BarAreaData a, BarAreaData b, double t) => - BarAreaData( - show: b.show, - spotsLine: BarAreaSpotsLine.lerp(a.spotsLine, b.spotsLine, t), - color: Color.lerp(a.color, b.color, t), - gradient: Gradient.lerp(a.gradient, b.gradient, t), - cutOffY: lerpDouble(a.cutOffY, b.cutOffY, t)!, - applyCutOffY: b.applyCutOffY, - ); + static BarAreaData lerp(BarAreaData a, BarAreaData b, double t) => BarAreaData( + show: b.show, + spotsLine: BarAreaSpotsLine.lerp(a.spotsLine, b.spotsLine, t), + color: Color.lerp(a.color, b.color, t), + gradient: Gradient.lerp(a.gradient, b.gradient, t), + cutOffY: lerpDouble(a.cutOffY, b.cutOffY, t)!, + applyCutOffY: b.applyCutOffY, + ); /// Used for equality check, see [EquatableMixin]. @override - List get props => [ - show, - color, - gradient, - spotsLine, - cutOffY, - applyCutOffY, - ]; + List get props => [show, color, gradient, spotsLine, cutOffY, applyCutOffY]; } /// Holds data about filling below or above space of the bar line, class BetweenBarsData with EquatableMixin { - BetweenBarsData({ - required this.fromIndex, - required this.toIndex, - Color? color, - this.gradient, - }) : color = color ?? - ((color == null && gradient == null) - ? Colors.blueGrey.withValues(alpha: 0.5) - : null); + BetweenBarsData({required this.fromIndex, required this.toIndex, Color? color, this.gradient}) + : color = color ?? ((color == null && gradient == null) ? Colors.blueGrey.withValues(alpha: 0.5) : null); /// The index of the lineBarsData from where the area has to be rendered final int fromIndex; @@ -652,12 +639,7 @@ class BetweenBarsData with EquatableMixin { /// Used for equality check, see [EquatableMixin]. @override - List get props => [ - fromIndex, - toIndex, - color, - gradient, - ]; + List get props => [fromIndex, toIndex, color, gradient]; } /// Holds data for drawing line on the spots under the [BarAreaData]. @@ -685,26 +667,16 @@ class BarAreaSpotsLine with EquatableMixin { final bool applyCutOffY; /// Lerps a [BarAreaSpotsLine] based on [t] value, check [Tween.lerp]. - static BarAreaSpotsLine lerp( - BarAreaSpotsLine a, - BarAreaSpotsLine b, - double t, - ) => - BarAreaSpotsLine( - show: b.show, - checkToShowSpotLine: b.checkToShowSpotLine, - flLineStyle: FlLine.lerp(a.flLineStyle, b.flLineStyle, t), - applyCutOffY: b.applyCutOffY, - ); + static BarAreaSpotsLine lerp(BarAreaSpotsLine a, BarAreaSpotsLine b, double t) => BarAreaSpotsLine( + show: b.show, + checkToShowSpotLine: b.checkToShowSpotLine, + flLineStyle: FlLine.lerp(a.flLineStyle, b.flLineStyle, t), + applyCutOffY: b.applyCutOffY, + ); /// Used for equality check, see [EquatableMixin]. @override - List get props => [ - show, - flLineStyle, - checkToShowSpotLine, - applyCutOffY, - ]; + List get props => [show, flLineStyle, checkToShowSpotLine, applyCutOffY]; } /// It used for determine showing or hiding [BarAreaSpotsLine]s @@ -728,29 +700,17 @@ typedef GetDotColorCallback = Color Function(FlSpot, double, LineChartBarData); /// otherwise it returns the color along the gradient colors based on the [xPercentage]. Color _defaultGetDotColor(FlSpot _, double xPercentage, LineChartBarData bar) { if (bar.gradient != null && bar.gradient is LinearGradient) { - return lerpGradient( - bar.gradient!.colors, - bar.gradient!.getSafeColorStops(), - xPercentage / 100, - ); + return lerpGradient(bar.gradient!.colors, bar.gradient!.getSafeColorStops(), xPercentage / 100); } return bar.gradient?.colors.first ?? bar.color ?? Colors.blueGrey; } /// If there is one color in [LineChartBarData.mainColors], it returns that color in a darker mode, /// otherwise it returns the color along the gradient colors based on the [xPercentage] in a darker mode. -Color _defaultGetDotStrokeColor( - FlSpot spot, - double xPercentage, - LineChartBarData bar, -) { +Color _defaultGetDotStrokeColor(FlSpot spot, double xPercentage, LineChartBarData bar) { Color color; if (bar.gradient != null && bar.gradient is LinearGradient) { - color = lerpGradient( - bar.gradient!.colors, - bar.gradient!.getSafeColorStops(), - xPercentage / 100, - ); + color = lerpGradient(bar.gradient!.colors, bar.gradient!.getSafeColorStops(), xPercentage / 100); } else { color = bar.gradient?.colors.first ?? bar.color ?? Colors.blueGrey; } @@ -763,20 +723,9 @@ Color _defaultGetDotStrokeColor( /// [LineChartBarData] is the chart's bar. /// [int] is the index position of the spot. /// It should return a [FlDotPainter] that needs to be used for drawing target. -typedef GetDotPainterCallback = FlDotPainter Function( - FlSpot, - double, - LineChartBarData, - int, -); - -FlDotPainter _defaultGetDotPainter( - FlSpot spot, - double xPercentage, - LineChartBarData bar, - int index, { - double? size, -}) => +typedef GetDotPainterCallback = FlDotPainter Function(FlSpot, double, LineChartBarData, int); + +FlDotPainter _defaultGetDotPainter(FlSpot spot, double xPercentage, LineChartBarData bar, int index, {double? size}) => FlDotCirclePainter( radius: size, color: _defaultGetDotColor(spot, xPercentage, bar), @@ -788,11 +737,7 @@ class FlDotData with EquatableMixin { /// set [show] false to prevent dots from drawing, /// if you want to show or hide dots in some spots, /// override [checkToShowDot] to handle it in your way. - const FlDotData({ - this.show = true, - this.checkToShowDot = showAllDots, - this.getDotPainter = _defaultGetDotPainter, - }); + const FlDotData({this.show = true, this.checkToShowDot = showAllDots, this.getDotPainter = _defaultGetDotPainter}); /// Determines show or hide all dots. final bool show; @@ -805,19 +750,12 @@ class FlDotData with EquatableMixin { final GetDotPainterCallback getDotPainter; /// Lerps a [FlDotData] based on [t] value, check [Tween.lerp]. - static FlDotData lerp(FlDotData a, FlDotData b, double t) => FlDotData( - show: b.show, - checkToShowDot: b.checkToShowDot, - getDotPainter: b.getDotPainter, - ); + static FlDotData lerp(FlDotData a, FlDotData b, double t) => + FlDotData(show: b.show, checkToShowDot: b.checkToShowDot, getDotPainter: b.getDotPainter); /// Used for equality check, see [EquatableMixin]. @override - List get props => [ - show, - checkToShowDot, - getDotPainter, - ]; + List get props => [show, checkToShowDot, getDotPainter]; } /// It determines showing or hiding [FlDotData] on the spots. @@ -863,13 +801,7 @@ abstract class FlLineLabel with EquatableMixin { /// Used for equality check, see [EquatableMixin]. @override - List get props => [ - show, - padding, - style, - alignment, - direction, - ]; + List get props => [show, padding, style, alignment, direction]; } /// Holds data to handle touch events, and touch responses in the [LineChart]. @@ -907,12 +839,7 @@ class LineTouchData extends FlTouchData with EquatableMixin { this.handleBuiltInTouches = true, this.getTouchLineStart = defaultGetTouchLineStart, this.getTouchLineEnd = defaultGetTouchLineEnd, - }) : super( - enabled, - touchCallback, - mouseCursorResolver, - longPressDuration, - ); + }) : super(enabled, touchCallback, mouseCursorResolver, longPressDuration); /// Configs of how touch tooltip popup. final LineTouchTooltipData touchTooltipData; @@ -952,37 +879,35 @@ class LineTouchData extends FlTouchData with EquatableMixin { GetTouchLineY? getTouchLineStart, GetTouchLineY? getTouchLineEnd, bool? handleBuiltInTouches, - }) => - LineTouchData( - enabled: enabled ?? this.enabled, - touchCallback: touchCallback ?? this.touchCallback, - mouseCursorResolver: mouseCursorResolver ?? this.mouseCursorResolver, - longPressDuration: longPressDuration ?? this.longPressDuration, - touchTooltipData: touchTooltipData ?? this.touchTooltipData, - getTouchedSpotIndicator: - getTouchedSpotIndicator ?? this.getTouchedSpotIndicator, - touchSpotThreshold: touchSpotThreshold ?? this.touchSpotThreshold, - distanceCalculator: distanceCalculator ?? this.distanceCalculator, - getTouchLineStart: getTouchLineStart ?? this.getTouchLineStart, - getTouchLineEnd: getTouchLineEnd ?? this.getTouchLineEnd, - handleBuiltInTouches: handleBuiltInTouches ?? this.handleBuiltInTouches, - ); + }) => LineTouchData( + enabled: enabled ?? this.enabled, + touchCallback: touchCallback ?? this.touchCallback, + mouseCursorResolver: mouseCursorResolver ?? this.mouseCursorResolver, + longPressDuration: longPressDuration ?? this.longPressDuration, + touchTooltipData: touchTooltipData ?? this.touchTooltipData, + getTouchedSpotIndicator: getTouchedSpotIndicator ?? this.getTouchedSpotIndicator, + touchSpotThreshold: touchSpotThreshold ?? this.touchSpotThreshold, + distanceCalculator: distanceCalculator ?? this.distanceCalculator, + getTouchLineStart: getTouchLineStart ?? this.getTouchLineStart, + getTouchLineEnd: getTouchLineEnd ?? this.getTouchLineEnd, + handleBuiltInTouches: handleBuiltInTouches ?? this.handleBuiltInTouches, + ); /// Used for equality check, see [EquatableMixin]. @override List get props => [ - enabled, - touchCallback, - mouseCursorResolver, - longPressDuration, - touchTooltipData, - getTouchedSpotIndicator, - touchSpotThreshold, - distanceCalculator, - handleBuiltInTouches, - getTouchLineStart, - getTouchLineEnd, - ]; + enabled, + touchCallback, + mouseCursorResolver, + longPressDuration, + touchTooltipData, + getTouchedSpotIndicator, + touchSpotThreshold, + distanceCalculator, + handleBuiltInTouches, + getTouchLineStart, + getTouchLineEnd, + ]; } /// Used for showing touch indicators (a thicker line and larger dot on the targeted spot). @@ -991,32 +916,20 @@ class LineTouchData extends FlTouchData with EquatableMixin { /// in the given [barData], you should return a list of [TouchedSpotIndicatorData], /// length of this list should be equal to the [spotIndexes.length], /// each [TouchedSpotIndicatorData] determines the look of showing indicator. -typedef GetTouchedSpotIndicator = List Function( - LineChartBarData barData, - List spotIndexes, -); +typedef GetTouchedSpotIndicator = + List Function(LineChartBarData barData, List spotIndexes); /// Used for determine the touch indicator line's starting/end point. -typedef GetTouchLineY = double Function( - LineChartBarData barData, - int spotIndex, -); +typedef GetTouchLineY = double Function(LineChartBarData barData, int spotIndex); /// Used to calculate the distance between coordinates of a touch event and a spot -typedef CalculateTouchDistance = double Function( - Offset touchPoint, - Offset spotPixelCoordinates, -); +typedef CalculateTouchDistance = double Function(Offset touchPoint, Offset spotPixelCoordinates); /// Default distanceCalculator only considers distance on x axis -double _xDistance(Offset touchPoint, Offset spotPixelCoordinates) => - (touchPoint.dx - spotPixelCoordinates.dx).abs(); +double _xDistance(Offset touchPoint, Offset spotPixelCoordinates) => (touchPoint.dx - spotPixelCoordinates.dx).abs(); /// Default presentation of touched indicators. -List defaultTouchedIndicators( - LineChartBarData barData, - List indicators, -) => +List defaultTouchedIndicators(LineChartBarData barData, List indicators) => indicators.map((int index) { /// Indicator Line var lineColor = barData.gradient?.colors.first ?? barData.color; @@ -1032,8 +945,7 @@ List defaultTouchedIndicators( } final dotData = FlDotData( - getDotPainter: (spot, percent, bar, index) => - _defaultGetDotPainter(spot, percent, bar, index, size: dotSize), + getDotPainter: (spot, percent, bar, index) => _defaultGetDotPainter(spot, percent, bar, index, size: dotSize), ); return TouchedSpotIndicatorData(flLine, dotData); @@ -1045,8 +957,7 @@ double defaultGetTouchLineStart(LineChartBarData barData, int spotIndex) { } /// By default line ends at the touched point. -double defaultGetTouchLineEnd(LineChartBarData barData, int spotIndex) => - barData.spots[spotIndex].y; +double defaultGetTouchLineEnd(LineChartBarData barData, int spotIndex) => barData.spots[spotIndex].y; /// Holds representation data for showing tooltip popup on top of spots. class LineTouchTooltipData with EquatableMixin { @@ -1065,8 +976,7 @@ class LineTouchTooltipData with EquatableMixin { /// also you can set [fitInsideVertically] true to force it to shift inside the chart vertically. const LineTouchTooltipData({ BorderRadius? tooltipBorderRadius, - this.tooltipPadding = - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + this.tooltipPadding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), this.tooltipMargin = 16, this.tooltipHorizontalAlignment = FLHorizontalAlignment.center, this.tooltipHorizontalOffset = 0, @@ -1084,8 +994,7 @@ class LineTouchTooltipData with EquatableMixin { final BorderRadius? _tooltipBorderRadius; /// Sets a rounded radius for the tooltip. - BorderRadius get tooltipBorderRadius => - _tooltipBorderRadius ?? BorderRadius.circular(4); + BorderRadius get tooltipBorderRadius => _tooltipBorderRadius ?? BorderRadius.circular(4); /// Applies a padding for showing contents inside the tooltip. final EdgeInsets tooltipPadding; @@ -1126,20 +1035,20 @@ class LineTouchTooltipData with EquatableMixin { /// Used for equality check, see [EquatableMixin]. @override List get props => [ - _tooltipBorderRadius, - tooltipPadding, - tooltipMargin, - tooltipHorizontalAlignment, - tooltipHorizontalOffset, - maxContentWidth, - getTooltipItems, - fitInsideHorizontally, - fitInsideVertically, - showOnTopOfTheChartBoxArea, - rotateAngle, - tooltipBorder, - getTooltipColor, - ]; + _tooltipBorderRadius, + tooltipPadding, + tooltipMargin, + tooltipHorizontalAlignment, + tooltipHorizontalOffset, + maxContentWidth, + getTooltipItems, + fitInsideHorizontally, + fitInsideVertically, + showOnTopOfTheChartBoxArea, + rotateAngle, + tooltipBorder, + getTooltipColor, + ]; } /// Provides a [LineTooltipItem] for showing content inside the [LineTouchTooltipData]. @@ -1149,17 +1058,13 @@ class LineTouchTooltipData with EquatableMixin { /// then you should and pass your custom [LineTooltipItem] list /// (length should be equal to the [touchedSpots.length]), /// to show inside the tooltip popup. -typedef GetLineTooltipItems = List Function( - List touchedSpots, -); +typedef GetLineTooltipItems = List Function(List touchedSpots); /// Default implementation for [LineTouchTooltipData.getTooltipItems]. List defaultLineTooltipItem(List touchedSpots) => touchedSpots.map((LineBarSpot touchedSpot) { final textStyle = TextStyle( - color: touchedSpot.bar.gradient?.colors.first ?? - touchedSpot.bar.color ?? - Colors.blueGrey, + color: touchedSpot.bar.gradient?.colors.first ?? touchedSpot.bar.color ?? Colors.blueGrey, fontWeight: FontWeight.bold, fontSize: 14, ); @@ -1172,13 +1077,10 @@ List defaultLineTooltipItem(List touchedSpots) => /// [touchedSpot] object that touch happened on, then you should and pass your custom [Color] list /// (length should be equal to the [touchedSpots.length]), to set background color /// of tooltip popup. -typedef GetLineTooltipColor = Color Function( - LineBarSpot touchedSpot, -); +typedef GetLineTooltipColor = Color Function(LineBarSpot touchedSpot); /// Default implementation for [LineTouchTooltipData.getTooltipColor]. -Color defaultLineTooltipColor(LineBarSpot touchedSpot) => - Colors.blueGrey.darken(15); +Color defaultLineTooltipColor(LineBarSpot touchedSpot) => Colors.blueGrey.darken(15); /// Represent a targeted spot inside a line bar. class LineBarSpot extends FlSpot with EquatableMixin { @@ -1186,12 +1088,7 @@ class LineBarSpot extends FlSpot with EquatableMixin { /// [barIndex] is the index of our [bar], in the [LineChartData.lineBarsData] list, /// [spot] is the targeted spot. /// [spotIndex] is the index this [FlSpot], in the [LineChartBarData.spots] list. - LineBarSpot( - this.bar, - this.barIndex, - FlSpot spot, - ) : spotIndex = bar.spots.indexOf(spot), - super(spot.x, spot.y); + LineBarSpot(this.bar, this.barIndex, FlSpot spot) : spotIndex = bar.spots.indexOf(spot), super(spot.x, spot.y); /// Is the [LineChartBarData] that this spot is inside of. final LineChartBarData bar; @@ -1204,23 +1101,12 @@ class LineBarSpot extends FlSpot with EquatableMixin { /// Used for equality check, see [EquatableMixin]. @override - List get props => [ - bar, - barIndex, - spotIndex, - x, - y, - ]; + List get props => [bar, barIndex, spotIndex, x, y]; } /// A [LineBarSpot] that holds information about the event that selected it class TouchLineBarSpot extends LineBarSpot { - TouchLineBarSpot( - super.bar, - super.barIndex, - super.spot, - this.distance, - ); + TouchLineBarSpot(super.bar, super.barIndex, super.spot, this.distance); /// Distance in pixels from where the user taped final double distance; @@ -1255,13 +1141,7 @@ class LineTooltipItem with EquatableMixin { /// Used for equality check, see [EquatableMixin]. @override - List get props => [ - text, - textStyle, - textAlign, - textDirection, - children, - ]; + List get props => [text, textStyle, textAlign, textDirection, children]; } /// details of showing indicator when touch happened on [LineChart] @@ -1273,10 +1153,7 @@ class TouchedSpotIndicatorData with EquatableMixin { /// otherwise you can show it manually using [LineChartBarData.showingIndicators]. /// [indicatorBelowLine] determines line's style, and /// [touchedSpotDotData] determines dot's style. - const TouchedSpotIndicatorData( - this.indicatorBelowLine, - this.touchedSpotDotData, - ); + const TouchedSpotIndicatorData(this.indicatorBelowLine, this.touchedSpotDotData); /// Determines line's style. final FlLine indicatorBelowLine; @@ -1286,10 +1163,7 @@ class TouchedSpotIndicatorData with EquatableMixin { /// Used for equality check, see [EquatableMixin]. @override - List get props => [ - indicatorBelowLine, - touchedSpotDotData, - ]; + List get props => [indicatorBelowLine, touchedSpotDotData]; } /// Holds data for showing tooltips over a line @@ -1314,11 +1188,7 @@ class LineTouchResponse extends AxisBaseTouchResponse { /// If touch happens, [LineChart] processes it internally and /// passes out a list of [lineBarSpots] it gives you information about the touched spot. /// They are sorted based on their distance to the touch event - LineTouchResponse({ - required super.touchLocation, - required super.touchChartCoordinate, - this.lineBarSpots, - }); + LineTouchResponse({required super.touchLocation, required super.touchChartCoordinate, this.lineBarSpots}); /// touch happened on these spots /// (if a single line provided on the chart, [lineBarSpots]'s length will be 1 always) @@ -1330,12 +1200,11 @@ class LineTouchResponse extends AxisBaseTouchResponse { Offset? touchLocation, Offset? touchChartCoordinate, List? lineBarSpots, - }) => - LineTouchResponse( - touchLocation: touchLocation ?? this.touchLocation, - touchChartCoordinate: touchChartCoordinate ?? this.touchChartCoordinate, - lineBarSpots: lineBarSpots ?? this.lineBarSpots, - ); + }) => LineTouchResponse( + touchLocation: touchLocation ?? this.touchLocation, + touchChartCoordinate: touchChartCoordinate ?? this.touchChartCoordinate, + lineBarSpots: lineBarSpots ?? this.lineBarSpots, + ); } /// It is the input of the [GetSpotRangeErrorPainter] callback in @@ -1344,24 +1213,15 @@ class LineTouchResponse extends AxisBaseTouchResponse { /// So it contains the information about the spot, and the bar that the spot /// is in. The callback should return a [FlSpotErrorRangePainter] that will draw /// the error bars -class LineChartSpotErrorRangeCallbackInput - extends FlSpotErrorRangeCallbackInput { - LineChartSpotErrorRangeCallbackInput({ - required this.spot, - required this.bar, - required this.spotIndex, - }); +class LineChartSpotErrorRangeCallbackInput extends FlSpotErrorRangeCallbackInput { + LineChartSpotErrorRangeCallbackInput({required this.spot, required this.bar, required this.spotIndex}); final FlSpot spot; final LineChartBarData bar; final int spotIndex; @override - List get props => [ - spot, - bar, - spotIndex, - ]; + List get props => [spot, bar, spotIndex]; } /// It lerps a [LineChartData] to another [LineChartData] (handles animation for updating values) diff --git a/lib/src/chart/line_chart/line_chart_helper.dart b/lib/src/chart/line_chart/line_chart_helper.dart index 63766adc1..eebc21eb9 100644 --- a/lib/src/chart/line_chart/line_chart_helper.dart +++ b/lib/src/chart/line_chart/line_chart_helper.dart @@ -1,4 +1,5 @@ import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; /// Contains anything that helps LineChart works class LineChartHelper { @@ -13,8 +14,7 @@ class LineChartHelper { final LineChartBarData lineBarData; try { - lineBarData = - lineBarsData.firstWhere((element) => element.spots.isNotEmpty); + lineBarData = lineBarsData.firstWhere((element) => element.spots.isNotEmpty); } catch (_) { // There is no lineBarData with at least one spot return (0, 0, 0, 0); @@ -22,8 +22,7 @@ class LineChartHelper { final FlSpot firstValidSpot; try { - firstValidSpot = - lineBarData.spots.firstWhere((element) => element != FlSpot.nullSpot); + firstValidSpot = lineBarData.spots.firstWhere((element) => element != FlSpot.nullSpot); } catch (_) { // There is no valid spot return (0, 0, 0, 0); @@ -58,4 +57,105 @@ class LineChartHelper { return (minX, maxX, minY, maxY); } + + /// Converts screen pixel coordinates to chart data coordinates. + /// + /// Returns null if the conversion fails or the chart data is invalid. + /// This replicates the exact logic from AxisChartPainter.getXForPixel/getYForPixel. + static FlSpot? screenToData( + Offset screenPosition, + Size chartSize, + LineChartData chartData, + ) { + try { + // Get usable chart size (this is what AxisChartPainter uses) + final usableSize = _getChartUsableSize(chartSize, chartData); + + // Get title sizes - screenPosition is relative to widget top-left, + // but AxisChartPainter's getXForPixel/getYForPixel expect coordinates + // relative to the usable area (after titles) + final leftTitleSize = chartData.titlesData.leftTitles.sideTitles.showTitles + ? chartData.titlesData.leftTitles.sideTitles.reservedSize + : 0.0; + final topTitleSize = chartData.titlesData.topTitles.sideTitles.showTitles + ? chartData.titlesData.topTitles.sideTitles.reservedSize + : 0.0; + + // Adjust to usable area coordinate space + final adjustedX = screenPosition.dx - leftTitleSize; + final adjustedY = screenPosition.dy - topTitleSize; + + // Now apply AxisChartPainter logic + final deltaX = chartData.maxX - chartData.minX; + if (deltaX == 0.0) return null; + + final deltaY = chartData.maxY - chartData.minY; + if (deltaY == 0.0) return null; + + final dataX = (adjustedX / usableSize.width) * deltaX + chartData.minX; + final dataY = chartData.maxY - (adjustedY / usableSize.height) * deltaY; + + return FlSpot( + dataX.clamp(chartData.minX, chartData.maxX), + dataY.clamp(chartData.minY, chartData.maxY), + ); + } catch (e) { + return null; + } + } + + /// Converts chart data coordinates to screen pixel coordinates. + /// This replicates the exact logic from AxisChartPainter.getPixelX/getPixelY. + static Offset dataToScreen( + FlSpot dataPoint, + Size chartSize, + LineChartData chartData, + ) { + // Get usable chart size (this is what AxisChartPainter uses) + final usableSize = _getChartUsableSize(chartSize, chartData); + + // Get title sizes - need to add these back to convert from usable area + // coordinate space to widget coordinate space + final leftTitleSize = chartData.titlesData.leftTitles.sideTitles.showTitles + ? chartData.titlesData.leftTitles.sideTitles.reservedSize + : 0.0; + final topTitleSize = chartData.titlesData.topTitles.sideTitles.showTitles + ? chartData.titlesData.topTitles.sideTitles.reservedSize + : 0.0; + + // This matches AxisChartPainter._getPixelX/_getPixelY + final deltaX = chartData.maxX - chartData.minX; + if (deltaX == 0.0) { + return Offset(leftTitleSize, topTitleSize); + } + + final deltaY = chartData.maxY - chartData.minY; + final pixelX = ((dataPoint.x - chartData.minX) / deltaX) * usableSize.width; + final pixelY = deltaY == 0.0 + ? usableSize.height + : usableSize.height - (((dataPoint.y - chartData.minY) / deltaY) * usableSize.height); + + // Add title offsets to convert to widget coordinate space + return Offset(pixelX + leftTitleSize, pixelY + topTitleSize); + } + + /// Gets the usable size of the chart (excluding titles). + /// This replicates the logic from BaseChartPainter.getChartUsableSize. + static Size _getChartUsableSize(Size viewSize, LineChartData chartData) { + final titlesData = chartData.titlesData; + + final leftTitleSize = + titlesData.leftTitles.sideTitles.showTitles ? titlesData.leftTitles.sideTitles.reservedSize : 0.0; + final rightTitleSize = + titlesData.rightTitles.sideTitles.showTitles ? titlesData.rightTitles.sideTitles.reservedSize : 0.0; + final topTitleSize = + titlesData.topTitles.sideTitles.showTitles ? titlesData.topTitles.sideTitles.reservedSize : 0.0; + final bottomTitleSize = + titlesData.bottomTitles.sideTitles.showTitles ? titlesData.bottomTitles.sideTitles.reservedSize : 0.0; + + return Size( + viewSize.width - leftTitleSize - rightTitleSize, + viewSize.height - topTitleSize - bottomTitleSize, + ); + } } diff --git a/lib/src/chart/line_chart/line_chart_painter.dart b/lib/src/chart/line_chart/line_chart_painter.dart index 6f47aee2d..5fc877a63 100644 --- a/lib/src/chart/line_chart/line_chart_painter.dart +++ b/lib/src/chart/line_chart/line_chart_painter.dart @@ -68,10 +68,7 @@ class LineChartPainter extends AxisChartPainter { final data = holder.data; if (holder.chartVirtualRect != null) { canvasWrapper - ..saveLayer( - Offset.zero & canvasWrapper.size, - _clipPaint, - ) + ..saveLayer(Offset.zero & canvasWrapper.size, _clipPaint) ..clipRect(Offset.zero & canvasWrapper.size); } super.paint(context, canvasWrapper, holder); @@ -118,8 +115,10 @@ class LineChartPainter extends AxisChartPainter { super.drawExtraLines(context, canvasWrapper, holder); } - final indicatorsData = data.lineTouchData - .getTouchedSpotIndicator(barData, barData.showingIndicators); + final indicatorsData = data.lineTouchData.getTouchedSpotIndicator( + barData, + barData.showingIndicators, + ); if (indicatorsData.length != barData.showingIndicators.length) { throw Exception( @@ -158,11 +157,7 @@ class LineChartPainter extends AxisChartPainter { continue; } - drawErrorIndicatorData( - canvasWrapper, - barData, - holder, - ); + drawErrorIndicatorData(canvasWrapper, barData, holder); } // Draw touch tooltip on most top spot @@ -245,8 +240,13 @@ class LineChartPainter extends AxisChartPainter { for (final bar in barList) { final barPath = generateBarPath(viewSize, barData, bar, holder); - final belowBarPath = - generateBelowBarPath(viewSize, barData, barPath, bar, holder); + final belowBarPath = generateBelowBarPath( + viewSize, + barData, + barPath, + bar, + holder, + ); final completelyFillBelowBarPath = generateBelowBarPath( viewSize, barData, @@ -255,8 +255,13 @@ class LineChartPainter extends AxisChartPainter { holder, fillCompletely: true, ); - final aboveBarPath = - generateAboveBarPath(viewSize, barData, barPath, bar, holder); + final aboveBarPath = generateAboveBarPath( + viewSize, + barData, + barPath, + bar, + holder, + ); final completelyFillAboveBarPath = generateAboveBarPath( viewSize, barData, @@ -363,10 +368,14 @@ class LineChartPainter extends AxisChartPainter { final spot = barData.spots[i]; if (spot.isNotNull() && barData.dotData.checkToShowDot(spot, barData)) { final x = getPixelX(spot.x, viewSize, holder); - final y = getPixelY(spot.y, viewSize, holder); + final y = getPixelYForBar(spot.y, viewSize, barData, holder); final xPercentInLine = (x / barXDelta) * 100; - final painter = - barData.dotData.getDotPainter(spot, xPercentInLine, barData, i); + final painter = barData.dotData.getDotPainter( + spot, + xPercentInLine, + barData, + i, + ); canvasWrapper.drawDot(painter, spot, Offset(x, y)); } @@ -390,7 +399,7 @@ class LineChartPainter extends AxisChartPainter { final spot = barData.spots[i]; if (spot.isNotNull()) { final x = getPixelX(spot.x, viewSize, holder); - final y = getPixelY(spot.y, viewSize, holder); + final y = getPixelYForBar(spot.y, viewSize, barData, holder); if (spot.xError == null && spot.yError == null) { continue; } @@ -406,16 +415,24 @@ class LineChartPainter extends AxisChartPainter { var top = 0.0; var bottom = 0.0; if (spot.yError != null) { - top = getPixelY(spot.y + spot.yError!.lowerBy, viewSize, holder) - y; + top = + getPixelYForBar( + spot.y + spot.yError!.lowerBy, + viewSize, + barData, + holder, + ) - + y; bottom = - getPixelY(spot.y - spot.yError!.upperBy, viewSize, holder) - y; + getPixelYForBar( + spot.y - spot.yError!.upperBy, + viewSize, + barData, + holder, + ) - + y; } - final relativeErrorPixelsRect = Rect.fromLTRB( - left, - top, - right, - bottom, - ); + final relativeErrorPixelsRect = Rect.fromLTRB(left, top, right, bottom); final painter = errorIndicatorData.painter( LineChartSpotErrorRangeCallbackInput( @@ -460,7 +477,7 @@ class LineChartPainter extends AxisChartPainter { final touchedSpot = Offset( getPixelX(spot.x, viewSize, holder), - getPixelY(spot.y, viewSize, holder), + getPixelYForBar(spot.y, viewSize, barData, holder), ); /// For drawing the dot @@ -470,8 +487,12 @@ class LineChartPainter extends AxisChartPainter { if (showingDots) { final xPercentInLine = (touchedSpot.dx / barXDelta) * 100; - dotPainter = indicatorData.touchedSpotDotData - .getDotPainter(spot, xPercentInLine, barData, index); + dotPainter = indicatorData.touchedSpotDotData.getDotPainter( + spot, + xPercentInLine, + barData, + index, + ); dotHeight = dotPainter.getSize(spot).height; } @@ -484,10 +505,14 @@ class LineChartPainter extends AxisChartPainter { data.maxY, max(data.minY, data.lineTouchData.getTouchLineEnd(barData, index)), ); - final lineStart = - Offset(touchedSpot.dx, getPixelY(lineStartY, viewSize, holder)); - var lineEnd = - Offset(touchedSpot.dx, getPixelY(lineEndY, viewSize, holder)); + final lineStart = Offset( + touchedSpot.dx, + getPixelY(lineStartY, viewSize, holder), + ); + var lineEnd = Offset( + touchedSpot.dx, + getPixelY(lineEndY, viewSize, holder), + ); /// If line end is inside the dot, adjust it so that it doesn't overlap with the dot. final dotMinY = touchedSpot.dy - dotHeight / 2; @@ -575,7 +600,7 @@ class LineChartPainter extends AxisChartPainter { var temp = Offset.zero; final x = getPixelX(barSpots[0].x, viewSize, holder); - final y = getPixelY(barSpots[0].y, viewSize, holder); + final y = getPixelYForBar(barSpots[0].y, viewSize, barData, holder); if (appendToPath == null) { path.moveTo(x, y); if (size == 1) { @@ -588,19 +613,24 @@ class LineChartPainter extends AxisChartPainter { /// CurrentSpot final current = Offset( getPixelX(barSpots[i].x, viewSize, holder), - getPixelY(barSpots[i].y, viewSize, holder), + getPixelYForBar(barSpots[i].y, viewSize, barData, holder), ); /// previous spot final previous = Offset( getPixelX(barSpots[i - 1].x, viewSize, holder), - getPixelY(barSpots[i - 1].y, viewSize, holder), + getPixelYForBar(barSpots[i - 1].y, viewSize, barData, holder), ); /// next point final next = Offset( getPixelX(barSpots[i + 1 < size ? i + 1 : i].x, viewSize, holder), - getPixelY(barSpots[i + 1 < size ? i + 1 : i].y, viewSize, holder), + getPixelYForBar( + barSpots[i + 1 < size ? i + 1 : i].y, + viewSize, + barData, + holder, + ), ); final controlPoint1 = previous + temp; @@ -653,7 +683,7 @@ class LineChartPainter extends AxisChartPainter { final size = barSpots.length; final x = getPixelX(barSpots[0].x, viewSize, holder); - final y = getPixelY(barSpots[0].y, viewSize, holder); + final y = getPixelYForBar(barSpots[0].y, viewSize, barData, holder); if (appendToPath == null) { path.moveTo(x, y); } else { @@ -663,13 +693,18 @@ class LineChartPainter extends AxisChartPainter { /// CurrentSpot final current = Offset( getPixelX(barSpots[i].x, viewSize, holder), - getPixelY(barSpots[i].y, viewSize, holder), + getPixelYForBar(barSpots[i].y, viewSize, barData, holder), ); /// next point final next = Offset( getPixelX(barSpots[i + 1 < size ? i + 1 : i].x, viewSize, holder), - getPixelY(barSpots[i + 1 < size ? i + 1 : i].y, viewSize, holder), + getPixelYForBar( + barSpots[i + 1 < size ? i + 1 : i].y, + viewSize, + barData, + holder, + ), ); final stepDirection = barData.lineChartStepData.stepDirection; @@ -844,10 +879,7 @@ class LineChartPainter extends AxisChartPainter { getPixelY(barData.belowBarData.cutOffY, viewSize, holder), ); } else { - to = Offset( - getPixelX(spot.x, viewSize, holder), - viewSize.height, - ); + to = Offset(getPixelX(spot.x, viewSize, holder), viewSize.height); } final lineStyle = barData.belowBarData.spotsLine.flLineStyle; @@ -938,10 +970,7 @@ class LineChartPainter extends AxisChartPainter { getPixelY(barData.aboveBarData.cutOffY, viewSize, holder), ); } else { - to = Offset( - getPixelX(spot.x, viewSize, holder), - 0, - ); + to = Offset(getPixelX(spot.x, viewSize, holder), 0); } final lineStyle = barData.aboveBarData.spotsLine.flLineStyle; @@ -1007,8 +1036,9 @@ class LineChartPainter extends AxisChartPainter { _barPaint ..strokeCap = barData.isStrokeCapRound ? StrokeCap.round : StrokeCap.butt - ..strokeJoin = - barData.isStrokeJoinRound ? StrokeJoin.round : StrokeJoin.miter + ..strokeJoin = barData.isStrokeJoinRound + ? StrokeJoin.round + : StrokeJoin.miter ..color = barData.shadow.color ..shader = null ..strokeWidth = barData.barWidth @@ -1022,10 +1052,7 @@ class LineChartPainter extends AxisChartPainter { barPath = barPath.shift(barData.shadow.offset); - canvasWrapper.drawPath( - barPath, - _barPaint, - ); + canvasWrapper.drawPath(barPath, _barPaint); } /// draw the main bar line by the [barPath] @@ -1043,8 +1070,9 @@ class LineChartPainter extends AxisChartPainter { _barPaint ..strokeCap = barData.isStrokeCapRound ? StrokeCap.round : StrokeCap.butt - ..strokeJoin = - barData.isStrokeJoinRound ? StrokeJoin.round : StrokeJoin.miter; + ..strokeJoin = barData.isStrokeJoinRound + ? StrokeJoin.round + : StrokeJoin.miter; final rectAroundTheLine = Rect.fromLTRB( getPixelX(barData.mostLeftSpot.x, viewSize, holder), @@ -1091,8 +1119,9 @@ class LineChartPainter extends AxisChartPainter { /// creating TextPainters to calculate the width and height of the tooltip final drawingTextPainters = []; - final tooltipItems = - tooltipData.getTooltipItems(showingTooltipSpots.showingSpots); + final tooltipItems = tooltipData.getTooltipItems( + showingTooltipSpots.showingSpots, + ); if (tooltipItems.length != showingTooltipSpots.showingSpots.length) { throw Exception('tooltipItems and touchedSpots size should be same'); } @@ -1245,12 +1274,16 @@ class LineChartPainter extends AxisChartPainter { _bgTouchTooltipPaint.color = tooltipData.getTooltipColor(topSpot); final rotateAngle = tooltipData.rotateAngle; - final rectRotationOffset = - Offset(0, Utils().calculateRotationOffset(rect.size, rotateAngle).dy); + final rectRotationOffset = Offset( + 0, + Utils().calculateRotationOffset(rect.size, rotateAngle).dy, + ); final rectDrawOffset = Offset(roundedRect.left, roundedRect.top); - final textRotationOffset = - Utils().calculateRotationOffset(rect.size, rotateAngle); + final textRotationOffset = Utils().calculateRotationOffset( + rect.size, + rotateAngle, + ); if (tooltipData.tooltipBorder != BorderSide.none) { _borderTouchTooltipPaint @@ -1273,7 +1306,8 @@ class LineChartPainter extends AxisChartPainter { /// draw the texts one by one in below of each other var topPosSeek = tooltipData.tooltipPadding.top; for (final tp in drawingTextPainters) { - final yOffset = rect.topCenter.dy + + final yOffset = + rect.topCenter.dy + topPosSeek - textRotationOffset.dy + rectRotationOffset.dy; @@ -1286,10 +1320,7 @@ class LineChartPainter extends AxisChartPainter { _ => rect.center.dx - (tp.width / 2), }; - final drawOffset = Offset( - xOffset, - yOffset, - ); + final drawOffset = Offset(xOffset, yOffset); final reverseQuarterTurnsAngle = -holder.data.rotationQuarterTurns * 90; canvasWrapper.drawRotated( @@ -1370,6 +1401,32 @@ class LineChartPainter extends AxisChartPainter { return touchedSpots.isEmpty ? null : touchedSpots; } + /// Gets the appropriate Y value in pixels, respecting the useRightAxis flag + @visibleForTesting + double getPixelYForBar( + double y, + Size viewSize, + LineChartBarData barData, + PaintHolder holder, + ) { + if (barData.useRightAxis) { + // Use right Y-axis scaling + final data = holder.data; + final minY = data.minRightY; + final maxY = data.maxRightY; + final verticalDiff = data.verticalRightDiff; + + if (verticalDiff == 0) { + return viewSize.height / 2; + } + + return viewSize.height - ((y - minY) / verticalDiff) * viewSize.height; + } else { + // Use left Y-axis scaling (default) + return getPixelY(y, viewSize, holder); + } + } + /// find the nearest spot base on the touched offset @visibleForTesting TouchLineBarSpot? getNearestTouchedSpot( @@ -1433,16 +1490,23 @@ class LineChartPainter extends AxisChartPainter { final lineData = holder.data.lineBarsData.elementAtOrNull(info.barIndex); if (lineData == null) continue; - final indicators = holder.data.lineTouchData - .getTouchedSpotIndicator(lineData, [info.spotIndex]); + final indicators = holder.data.lineTouchData.getTouchedSpotIndicator( + lineData, + [info.spotIndex], + ); final indicatorData = indicators.elementAtOrNull(0); if (indicatorData != null && indicatorData.touchedSpotDotData.show) { - final xPercentInLine = (getPixelX(info.x, viewSize, holder) / + final xPercentInLine = + (getPixelX(info.x, viewSize, holder) / getBarLineXLength(lineData, viewSize, holder)) * 100; - final dotPainter = indicatorData.touchedSpotDotData - .getDotPainter(info, xPercentInLine, lineData, info.spotIndex); + final dotPainter = indicatorData.touchedSpotDotData.getDotPainter( + info, + xPercentInLine, + lineData, + info.spotIndex, + ); final currentDotHeight = dotPainter.getSize(info).height; // Keep the largest dot height diff --git a/pubspec.yaml b/pubspec.yaml index 64f1c1cdd..075935dfb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fl_chart description: A highly customizable Flutter chart library that supports Line Chart, Bar Chart, Pie Chart, Scatter Chart, and Radar Chart. -version: 1.1.1 +version: 1.2.0 homepage: https://flchart.dev/ repository: https://github.com/imaNNeo/fl_chart issue_tracker: https://github.com/imaNNeo/fl_chart/issues diff --git a/repo_files/documentations/draggable_line_chart.md b/repo_files/documentations/draggable_line_chart.md new file mode 100644 index 000000000..e59e2b0e2 --- /dev/null +++ b/repo_files/documentations/draggable_line_chart.md @@ -0,0 +1,115 @@ +### DraggableLineChart + +A LineChart wrapper that enables drag-to-edit functionality for chart data points. + +### How to use +```dart +DraggableLineChart( + data: LineChartData( + lineBarsData: [ + LineChartBarData( + spots: [FlSpot(0, 1), FlSpot(1, 3), FlSpot(2, 2)], + ), + ], + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + ), + onDragStart: (barIndex, spotIndex, spot) { + // Handle drag start + }, + onDragUpdate: (barIndex, spotIndex, oldSpot, newSpot) { + // Update your data model + }, + onDragEnd: () { + // Handle drag end + }, +); +``` + +### Properties +|PropName|Description|default value| +|:-------|:----------|:------------| +|data|[LineChartData](line_chart.md#LineChartData) to display. LineTouchData.enabled is automatically set to false.|required| +|onDragStart|Called when dragging starts. Provides barIndex, spotIndex, and spot coordinates.|null| +|onDragUpdate|Called during dragging (throttled by dragThrottle). Provides barIndex, spotIndex, oldSpot, and newSpot.|null| +|onDragEnd|Called when dragging ends.|null| +|onSpotTap|Called when a spot is tapped (not dragged). Provides barIndex, spotIndex, and spot coordinates.|null| +|canDragSpot|Callback to determine if a spot can be dragged. Return true to allow, false to ignore.|null (all spots draggable)| +|constrainDrag|Callback to constrain/modify drag positions. Return the constrained FlSpot.|null (no constraints)| +|dragTolerance|Maximum distance (in normalized data space) for considering a touch as hitting a spot.|3.5| +|dragThrottle|Minimum time between onDragUpdate callbacks.|Duration(milliseconds: 33)| + +### Callbacks + +#### DragStartCallback +```dart +void Function(int barIndex, int spotIndex, FlSpot spot) +``` +Called when drag begins. Use this to disable scrolling or prepare for drag operations. + +#### DragUpdateCallback +```dart +void Function(int barIndex, int spotIndex, FlSpot oldSpot, FlSpot newSpot) +``` +Called during dragging. Updates are throttled according to `dragThrottle`. The final position is always sent on drag end (bypassing throttle). + +#### DragEndCallback +```dart +void Function() +``` +Called when drag ends. Use this to re-enable scrolling or finalize changes. + +#### SpotTapCallback +```dart +void Function(int barIndex, int spotIndex, FlSpot spot) +``` +Called when a spot is tapped without dragging. + +#### CanDragSpotCallback +```dart +bool Function(int barIndex, int spotIndex) +``` +Return true to allow dragging the specified spot, false to skip it. + +#### ConstrainDragCallback +```dart +FlSpot Function(int barIndex, int spotIndex, FlSpot oldSpot, FlSpot proposedSpot) +``` +Modify the proposed drag position to enforce constraints. Return the constrained FlSpot. + +### Usage Notes + +- Touch handling on the underlying LineChart is automatically disabled +- Drag gestures claim priority early to prevent scroll conflicts +- Use `canDragSpot` for selective dragging (e.g., only selected series) +- Use `constrainDrag` for business rules (e.g., keeping points in order, axis restrictions) +- Coordinate conversion accounts for axis labels and padding automatically + +### Example with Constraints +```dart +DraggableLineChart( + data: lineChartData, + canDragSpot: (barIndex, spotIndex) { + // Only allow dragging spots from bar 0 + return barIndex == 0; + }, + constrainDrag: (barIndex, spotIndex, oldSpot, proposedSpot) { + // Keep points between min and max + return FlSpot( + proposedSpot.x.clamp(0, 10), + proposedSpot.y.clamp(0, 100), + ); + }, + onDragUpdate: (barIndex, spotIndex, oldSpot, newSpot) { + setState(() { + spots[spotIndex] = newSpot; + }); + }, +); +``` + +### Sample +##### Draggable Line Chart ([Source Code](/example/lib/presentation/samples/line/draggable_line_chart_sample.dart)) +Interactive chart with draggable data points. diff --git a/test/chart/line_chart/draggable_line_chart_test.dart b/test/chart/line_chart/draggable_line_chart_test.dart new file mode 100644 index 000000000..f053fefe2 --- /dev/null +++ b/test/chart/line_chart/draggable_line_chart_test.dart @@ -0,0 +1,403 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget createTestWidget({ + required DraggableLineChart chart, + }) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 400, + child: chart, + ), + ), + ); + } + + group('DraggableLineChart', () { + testWidgets('builds correctly with basic data', (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + data: LineChartData( + lineBarsData: [ + LineChartBarData( + spots: [ + const FlSpot(0, 1), + const FlSpot(1, 2), + const FlSpot(2, 1.5), + ], + ), + ], + ), + ), + ), + ); + + expect(find.byType(DraggableLineChart), findsOneWidget); + expect(find.byType(LineChart), findsOneWidget); + }); + + testWidgets('disables touch on underlying LineChart', (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + data: LineChartData( + lineBarsData: [ + LineChartBarData( + spots: [const FlSpot(0, 1), const FlSpot(1, 2)], + ), + ], + ), + ), + ), + ); + + final lineChart = tester.widget(find.byType(LineChart)); + expect(lineChart.data.lineTouchData.enabled, false); + }); + + testWidgets('calls onSpotTap when spot is tapped', (WidgetTester tester) async { + int? tappedBarIndex; + int? tappedSpotIndex; + FlSpot? tappedSpot; + + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [ + const FlSpot(5, 5), + ], + ), + ], + ), + onSpotTap: (barIndex, spotIndex, spot) { + tappedBarIndex = barIndex; + tappedSpotIndex = spotIndex; + tappedSpot = spot; + }, + ), + ), + ); + + // Tap the center of the chart where the spot should be + await tester.tapAt(tester.getCenter(find.byType(DraggableLineChart))); + await tester.pump(); + + expect(tappedBarIndex, 0); + expect(tappedSpotIndex, 0); + expect(tappedSpot, const FlSpot(5, 5)); + }); + + testWidgets('calls onDragStart when drag begins', (WidgetTester tester) async { + int? draggedBarIndex; + int? draggedSpotIndex; + FlSpot? draggedSpot; + + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [ + const FlSpot(5, 5), + ], + ), + ], + ), + onDragStart: (barIndex, spotIndex, spot) { + draggedBarIndex = barIndex; + draggedSpotIndex = spotIndex; + draggedSpot = spot; + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(DraggableLineChart)); + await tester.startGesture(center); + await tester.pump(); + + expect(draggedBarIndex, 0); + expect(draggedSpotIndex, 0); + expect(draggedSpot, const FlSpot(5, 5)); + }); + + testWidgets('calls onDragUpdate during drag', (WidgetTester tester) async { + final updatedSpots = []; + + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + dragTolerance: 50, // High tolerance to ensure we hit the spot + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [ + const FlSpot(5, 5), + ], + ), + ], + ), + onDragStart: (barIndex, spotIndex, spot) {}, // Required for drag to work + onDragUpdate: (barIndex, spotIndex, oldSpot, newSpot) { + updatedSpots.add(newSpot); + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(DraggableLineChart)); + final gesture = await tester.startGesture(center); + await tester.pump(); + await gesture.moveTo(center + const Offset(50, 50)); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pump(); + + expect(updatedSpots.isNotEmpty, true); + }); + + testWidgets('calls onDragEnd when drag ends', (WidgetTester tester) async { + var dragEnded = false; + + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [ + const FlSpot(5, 5), + ], + ), + ], + ), + onDragStart: (barIndex, spotIndex, spot) {}, + onDragEnd: () { + dragEnded = true; + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(DraggableLineChart)); + final gesture = await tester.startGesture(center); + await tester.pump(); + await gesture.moveTo(center + const Offset(50, 50)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(dragEnded, true); + }); + + testWidgets('applies constrainDrag callback', (WidgetTester tester) async { + FlSpot? constrainedSpot; + + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + dragTolerance: 50, + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [ + const FlSpot(5, 5), + ], + ), + ], + ), + onDragStart: (barIndex, spotIndex, spot) {}, // Required for drag to work + constrainDrag: (barIndex, spotIndex, oldSpot, proposedSpot) { + // Constrain to only horizontal movement + return FlSpot(proposedSpot.x, oldSpot.y); + }, + onDragUpdate: (barIndex, spotIndex, oldSpot, newSpot) { + constrainedSpot = newSpot; + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(DraggableLineChart)); + final gesture = await tester.startGesture(center); + await tester.pump(); + await gesture.moveTo(center + const Offset(50, 50)); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pump(); + + // Y should remain at 5 due to constraint + expect(constrainedSpot?.y, 5); + }); + + testWidgets('respects canDragSpot callback', (WidgetTester tester) async { + var dragStarted = false; + + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [ + const FlSpot(5, 5), + ], + ), + ], + ), + canDragSpot: (barIndex, spotIndex) => false, // Disable dragging + onDragStart: (barIndex, spotIndex, spot) { + dragStarted = true; + }, + ), + ), + ); + + await tester.drag(find.byType(DraggableLineChart), const Offset(50, 50)); + await tester.pump(); + + expect(dragStarted, false); + }); + + testWidgets('does not trigger onSpotTap during drag', (WidgetTester tester) async { + var tapped = false; + var dragged = false; + + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [ + const FlSpot(5, 5), + ], + ), + ], + ), + onSpotTap: (barIndex, spotIndex, spot) { + tapped = true; + }, + onDragStart: (barIndex, spotIndex, spot) { + dragged = true; + }, + ), + ), + ); + + // Perform a drag (not a tap) + await tester.drag(find.byType(DraggableLineChart), const Offset(50, 0)); + await tester.pump(); + + expect(dragged, true); + expect(tapped, false); + }); + + testWidgets('handles multiple bars correctly', (WidgetTester tester) async { + int? draggedBarIndex; + + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + dragTolerance: 50, + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [const FlSpot(5, 5)], + ), + LineChartBarData( + spots: [const FlSpot(8, 8)], + ), + ], + ), + onDragStart: (barIndex, spotIndex, spot) { + draggedBarIndex = barIndex; + }, + ), + ), + ); + + // Should find the nearest spot + final center = tester.getCenter(find.byType(DraggableLineChart)); + await tester.startGesture(center); + await tester.pump(); + + expect(draggedBarIndex, isNotNull); + }); + + testWidgets('respects dragTolerance parameter', (WidgetTester tester) async { + var dragStartedWithHighTolerance = false; + + // Test with high tolerance (easier to hit) + await tester.pumpWidget( + createTestWidget( + chart: DraggableLineChart( + dragTolerance: 50, // Very high tolerance + data: LineChartData( + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + lineBarsData: [ + LineChartBarData( + spots: [const FlSpot(5, 5)], + ), + ], + ), + onDragStart: (barIndex, spotIndex, spot) { + dragStartedWithHighTolerance = true; + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(DraggableLineChart)); + await tester.startGesture(center + const Offset(50, 50)); + await tester.pump(); + + // With high tolerance, should still detect the spot + expect(dragStartedWithHighTolerance, true); + }); + }); +}