diff --git a/lib/src/chart/pie_chart/pie_chart_data.dart b/lib/src/chart/pie_chart/pie_chart_data.dart index 816e36c08..02c7d0349 100644 --- a/lib/src/chart/pie_chart/pie_chart_data.dart +++ b/lib/src/chart/pie_chart/pie_chart_data.dart @@ -132,6 +132,99 @@ class PieChartData extends BaseChartData with EquatableMixin { ]; } +/// Defines individual border sides for a [PieChartSectionData]. +/// +/// A pie chart section has 4 sides: +/// - [outer]: the outer arc (furthest from center) +/// - [inner]: the inner arc (closest to center) +/// - [left]: the left radial line (start of the section) +/// - [right]: the right radial line (end of the section) +/// +/// This allows you to control which borders are visible, useful when +/// sections are adjacent and you don't want doubled borders. +class PieChartSectionBorder with EquatableMixin { + /// Creates a border configuration for a pie chart section. + /// + /// By default, all sides use [BorderSide.none]. + const PieChartSectionBorder({ + this.outer = BorderSide.none, + this.inner = BorderSide.none, + this.left = BorderSide.none, + this.right = BorderSide.none, + }); + + /// Creates a border with the same [BorderSide] on all sides. + const PieChartSectionBorder.all(BorderSide side) + : outer = side, + inner = side, + left = side, + right = side; + + /// Creates a border with only the arcs (outer and inner) visible. + /// Useful for adjacent sections where you don't want doubled radial borders. + const PieChartSectionBorder.arcsOnly(BorderSide side) + : outer = side, + inner = side, + left = BorderSide.none, + right = BorderSide.none; + + /// Creates a border with only the outer arc visible. + const PieChartSectionBorder.outerOnly(BorderSide side) + : outer = side, + inner = BorderSide.none, + left = BorderSide.none, + right = BorderSide.none; + + /// The border on the outer arc (furthest from center). + final BorderSide outer; + + /// The border on the inner arc (closest to center). + final BorderSide inner; + + /// The border on the left radial line (start of the section). + final BorderSide left; + + /// The border on the right radial line (end of the section). + final BorderSide right; + + /// Copies this border with the given fields replaced. + PieChartSectionBorder copyWith({ + BorderSide? outer, + BorderSide? inner, + BorderSide? left, + BorderSide? right, + }) => + PieChartSectionBorder( + outer: outer ?? this.outer, + inner: inner ?? this.inner, + left: left ?? this.left, + right: right ?? this.right, + ); + + /// Linearly interpolates between two [PieChartSectionBorder]s. + static PieChartSectionBorder lerp( + PieChartSectionBorder a, + PieChartSectionBorder b, + double t, + ) => + PieChartSectionBorder( + outer: BorderSide.lerp(a.outer, b.outer, t), + inner: BorderSide.lerp(a.inner, b.inner, t), + left: BorderSide.lerp(a.left, b.left, t), + right: BorderSide.lerp(a.right, b.right, t), + ); + + /// Returns true if any border side is visible. + bool get hasVisibleBorder => + (outer.width > 0 && outer.color.a > 0) || + (inner.width > 0 && inner.color.a > 0) || + (left.width > 0 && left.color.a > 0) || + (right.width > 0 && right.color.a > 0); + + @override + List get props => [outer, inner, left, right]; +} + /// Holds data related to drawing each [PieChart] section. class PieChartSectionData with EquatableMixin { /// [PieChart] draws section from right side of the circle (0 degrees), @@ -161,7 +254,9 @@ class PieChartSectionData with EquatableMixin { bool? showTitle, this.titleStyle, String? title, + @Deprecated('Use border instead for individual border control') BorderSide? borderSide, + PieChartSectionBorder? border, this.badgeWidget, double? titlePositionPercentageOffset, double? badgePositionPercentageOffset, @@ -171,6 +266,7 @@ class PieChartSectionData with EquatableMixin { showTitle = showTitle ?? true, title = title ?? (value == null ? '' : value.toString()), borderSide = borderSide ?? const BorderSide(width: 0), + border = border ?? const PieChartSectionBorder(), titlePositionPercentageOffset = titlePositionPercentageOffset ?? 0.5, badgePositionPercentageOffset = badgePositionPercentageOffset ?? 0.5; @@ -200,9 +296,23 @@ class PieChartSectionData with EquatableMixin { /// Defines text of showing title at the middle of section. final String title; - /// Defines border stroke around the section + /// Defines border stroke around the section. + /// + /// @deprecated Use [border] instead for individual border control. final BorderSide borderSide; + /// Defines individual border sides for the section. + /// + /// This allows you to control which borders are visible on each side: + /// - [PieChartSectionBorder.outer]: the outer arc + /// - [PieChartSectionBorder.inner]: the inner arc + /// - [PieChartSectionBorder.left]: the left radial line + /// - [PieChartSectionBorder.right]: the right radial line + /// + /// Useful when sections are adjacent and you don't want doubled borders. + /// For example, use [PieChartSectionBorder.arcsOnly] to only show arc borders. + final PieChartSectionBorder border; + /// Defines a widget that represents the section. /// /// This can be anything from a text, an image, an animation, and even a combination of widgets. @@ -233,7 +343,8 @@ class PieChartSectionData with EquatableMixin { bool? showTitle, TextStyle? titleStyle, String? title, - BorderSide? borderSide, + @Deprecated('Use border instead') BorderSide? borderSide, + PieChartSectionBorder? border, Widget? badgeWidget, double? titlePositionPercentageOffset, double? badgePositionPercentageOffset, @@ -247,6 +358,7 @@ class PieChartSectionData with EquatableMixin { titleStyle: titleStyle ?? this.titleStyle, title: title ?? this.title, borderSide: borderSide ?? this.borderSide, + border: border ?? this.border, badgeWidget: badgeWidget ?? this.badgeWidget, titlePositionPercentageOffset: titlePositionPercentageOffset ?? this.titlePositionPercentageOffset, @@ -269,6 +381,7 @@ class PieChartSectionData with EquatableMixin { titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), title: b.title, borderSide: BorderSide.lerp(a.borderSide, b.borderSide, t), + border: PieChartSectionBorder.lerp(a.border, b.border, t), badgeWidget: b.badgeWidget, titlePositionPercentageOffset: lerpDouble( a.titlePositionPercentageOffset, @@ -293,6 +406,7 @@ class PieChartSectionData with EquatableMixin { titleStyle, title, borderSide, + border, badgeWidget, titlePositionPercentageOffset, badgePositionPercentageOffset, diff --git a/lib/src/chart/pie_chart/pie_chart_painter.dart b/lib/src/chart/pie_chart/pie_chart_painter.dart index 86d885764..d8aad571a 100644 --- a/lib/src/chart/pie_chart/pie_chart_painter.dart +++ b/lib/src/chart/pie_chart/pie_chart_painter.dart @@ -142,8 +142,36 @@ class PieChartPainter extends BaseChartPainter { ) ..restore(); _sectionPaint.blendMode = BlendMode.srcOver; - if (section.borderSide.width != 0.0 && + + // Draw borders for full circle section + final border = section.border; + if (border.hasVisibleBorder) { + // Outer circle border + if (border.outer.width > 0 && border.outer.color.a > 0) { + _sectionStrokePaint + ..strokeWidth = border.outer.width + ..color = border.outer.color; + canvasWrapper.drawCircle( + center, + centerRadius + section.radius - (border.outer.width / 2), + _sectionStrokePaint, + ); + } + + // Inner circle border + if (border.inner.width > 0 && border.inner.color.a > 0) { + _sectionStrokePaint + ..strokeWidth = border.inner.width + ..color = border.inner.color; + canvasWrapper.drawCircle( + center, + centerRadius + (border.inner.width / 2), + _sectionStrokePaint, + ); + } + } else if (section.borderSide.width != 0.0 && section.borderSide.color.a != 0.0) { + // Legacy borderSide support _sectionStrokePaint ..strokeWidth = section.borderSide.width ..color = section.borderSide.color; @@ -154,7 +182,6 @@ class PieChartPainter extends BaseChartPainter { centerRadius + section.radius - (section.borderSide.width / 2), _sectionStrokePaint, ) - // Inner ..drawCircle( center, @@ -176,6 +203,15 @@ class PieChartPainter extends BaseChartPainter { drawSection(section, sectionPath, canvasWrapper); drawSectionStroke(section, sectionPath, canvasWrapper, viewSize); + drawSectionIndividualBorders( + section, + data.sectionsSpace, + tempAngle, + sectionDegree, + center, + centerRadius, + canvasWrapper, + ); tempAngle += sectionDegree; } } @@ -329,7 +365,10 @@ class PieChartPainter extends BaseChartPainter { CanvasWrapper canvasWrapper, Size viewSize, ) { - if (section.borderSide.width != 0.0 && section.borderSide.color.a != 0.0) { + // Legacy support: if borderSide is set and border is default, use borderSide + if (section.borderSide.width != 0.0 && + section.borderSide.color.a != 0.0 && + !section.border.hasVisibleBorder) { canvasWrapper ..saveLayer( Rect.fromLTWH(0, 0, viewSize.width, viewSize.height), @@ -349,6 +388,89 @@ class PieChartPainter extends BaseChartPainter { } } + @visibleForTesting + void drawSectionIndividualBorders( + PieChartSectionData section, + double sectionSpace, + double tempAngle, + double sectionDegree, + Offset center, + double centerRadius, + CanvasWrapper canvasWrapper, + ) { + final border = section.border; + if (!border.hasVisibleBorder) return; + + final startRadians = Utils().radians(tempAngle); + final sweepRadians = Utils().radians(sectionDegree); + final endRadians = startRadians + sweepRadians; + + final outerRadius = centerRadius + section.radius; + + // Draw outer arc + if (border.outer.width > 0 && border.outer.color.a > 0) { + _sectionStrokePaint + ..strokeWidth = border.outer.width + ..color = border.outer.color + ..style = PaintingStyle.stroke; + + final outerRect = Rect.fromCircle(center: center, radius: outerRadius); + canvasWrapper.drawArc( + outerRect, + startRadians, + sweepRadians, + false, + _sectionStrokePaint, + ); + } + + // Draw inner arc + if (border.inner.width > 0 && border.inner.color.a > 0) { + _sectionStrokePaint + ..strokeWidth = border.inner.width + ..color = border.inner.color + ..style = PaintingStyle.stroke; + + final innerRect = Rect.fromCircle(center: center, radius: centerRadius); + canvasWrapper.drawArc( + innerRect, + startRadians, + sweepRadians, + false, + _sectionStrokePaint, + ); + } + + // Draw left radial line (start of section) + if (border.left.width > 0 && border.left.color.a > 0) { + _sectionStrokePaint + ..strokeWidth = border.left.width + ..color = border.left.color + ..style = PaintingStyle.stroke; + + final startDirection = + Offset(math.cos(startRadians), math.sin(startRadians)); + final startFrom = center + startDirection * centerRadius; + final startTo = center + startDirection * outerRadius; + + canvasWrapper.drawLine(startFrom, startTo, _sectionStrokePaint); + } + + // Draw right radial line (end of section) + if (border.right.width > 0 && border.right.color.a > 0) { + _sectionStrokePaint + ..strokeWidth = border.right.width + ..color = border.right.color + ..style = PaintingStyle.stroke; + + final endDirection = Offset(math.cos(endRadians), math.sin(endRadians)); + final endFrom = center + endDirection * centerRadius; + final endTo = center + endDirection * outerRadius; + + canvasWrapper.drawLine(endFrom, endTo, _sectionStrokePaint); + } + } + /// Calculates layout of overlaying elements, includes: /// - title text /// - badge widget positions diff --git a/test/chart/pie_chart/pie_chart_data_test.dart b/test/chart/pie_chart/pie_chart_data_test.dart index c39349b92..f2977269c 100644 --- a/test/chart/pie_chart/pie_chart_data_test.dart +++ b/test/chart/pie_chart/pie_chart_data_test.dart @@ -165,4 +165,157 @@ void main() { expect(sample1 == zeroLongPressDuration, false); }); }); + + group('PieChartSectionBorder', () { + test('default constructor creates border with all sides none', () { + const border = PieChartSectionBorder(); + expect(border.outer, BorderSide.none); + expect(border.inner, BorderSide.none); + expect(border.left, BorderSide.none); + expect(border.right, BorderSide.none); + expect(border.hasVisibleBorder, false); + }); + + test('.all constructor creates border with same side on all', () { + const side = BorderSide(color: Colors.red, width: 2); + const border = PieChartSectionBorder.all(side); + expect(border.outer, side); + expect(border.inner, side); + expect(border.left, side); + expect(border.right, side); + expect(border.hasVisibleBorder, true); + }); + + test('.arcsOnly constructor creates border with only arcs', () { + const side = BorderSide(color: Colors.blue, width: 3); + const border = PieChartSectionBorder.arcsOnly(side); + expect(border.outer, side); + expect(border.inner, side); + expect(border.left, BorderSide.none); + expect(border.right, BorderSide.none); + expect(border.hasVisibleBorder, true); + }); + + test('.outerOnly constructor creates border with only outer arc', () { + const side = BorderSide(color: Colors.green, width: 1); + const border = PieChartSectionBorder.outerOnly(side); + expect(border.outer, side); + expect(border.inner, BorderSide.none); + expect(border.left, BorderSide.none); + expect(border.right, BorderSide.none); + expect(border.hasVisibleBorder, true); + }); + + test('hasVisibleBorder returns false for zero width', () { + const border = PieChartSectionBorder( + outer: BorderSide(color: Colors.red, width: 0), + ); + expect(border.hasVisibleBorder, false); + }); + + test('hasVisibleBorder returns false for transparent color', () { + const border = PieChartSectionBorder( + outer: BorderSide(color: Colors.transparent, width: 2), + ); + expect(border.hasVisibleBorder, false); + }); + + test('copyWith creates new instance with replaced values', () { + const original = PieChartSectionBorder( + outer: BorderSide(color: Colors.red, width: 1), + inner: BorderSide(color: Colors.blue, width: 2), + ); + final copied = original.copyWith( + outer: const BorderSide(color: Colors.green, width: 3), + ); + expect(copied.outer, const BorderSide(color: Colors.green, width: 3)); + expect(copied.inner, const BorderSide(color: Colors.blue, width: 2)); + expect(copied.left, BorderSide.none); + expect(copied.right, BorderSide.none); + }); + + test('lerp interpolates between two borders', () { + const a = PieChartSectionBorder( + outer: BorderSide(color: Colors.red, width: 0), + ); + const b = PieChartSectionBorder( + outer: BorderSide(color: Colors.red, width: 10), + ); + final result = PieChartSectionBorder.lerp(a, b, 0.5); + expect(result.outer.width, 5); + }); + + test('equality check works correctly', () { + const border1 = PieChartSectionBorder( + outer: BorderSide(color: Colors.red, width: 2), + ); + const border2 = PieChartSectionBorder( + outer: BorderSide(color: Colors.red, width: 2), + ); + const border3 = PieChartSectionBorder( + outer: BorderSide(color: Colors.blue, width: 2), + ); + expect(border1 == border2, true); + expect(border1 == border3, false); + }); + }); + + group('PieChartSectionData with border', () { + test('section with border has correct equality', () { + final section1 = PieChartSectionData( + value: 10, + border: const PieChartSectionBorder.all( + BorderSide(color: Colors.red, width: 2), + ), + ); + final section2 = PieChartSectionData( + value: 10, + border: const PieChartSectionBorder.all( + BorderSide(color: Colors.red, width: 2), + ), + ); + final section3 = PieChartSectionData( + value: 10, + border: const PieChartSectionBorder.all( + BorderSide(color: Colors.blue, width: 2), + ), + ); + expect(section1 == section2, true); + expect(section1 == section3, false); + }); + + test('copyWith border works correctly', () { + final section = PieChartSectionData( + value: 10, + border: const PieChartSectionBorder.all( + BorderSide(color: Colors.red, width: 2), + ), + ); + final copied = section.copyWith( + border: const PieChartSectionBorder.outerOnly( + BorderSide(color: Colors.blue, width: 3), + ), + ); + expect(copied.border.outer.color, Colors.blue); + expect(copied.border.outer.width, 3); + expect(copied.border.inner, BorderSide.none); + }); + + test('lerp interpolates border correctly', () { + final a = PieChartSectionData( + value: 10, + border: const PieChartSectionBorder( + outer: BorderSide(color: Colors.red, width: 0), + ), + ); + final b = PieChartSectionData( + value: 10, + border: const PieChartSectionBorder( + outer: BorderSide(color: Colors.red, width: 10), + ), + ); + final result = PieChartSectionData.lerp(a, b, 0.5); + expect(result.border.outer.width, 5); + }); + }); } diff --git a/test/chart/pie_chart/pie_chart_painter_test.dart b/test/chart/pie_chart/pie_chart_painter_test.dart index 2bfa5c826..7bb17bdce 100644 --- a/test/chart/pie_chart/pie_chart_painter_test.dart +++ b/test/chart/pie_chart/pie_chart_painter_test.dart @@ -1263,4 +1263,189 @@ void main() { ); }); }); + + group('drawSectionIndividualBorders()', () { + test('does nothing when border has no visible sides', () { + const viewSize = Size(200, 200); + final section = PieChartSectionData( + color: MockData.color1, + value: 1, + radius: 40, + border: const PieChartSectionBorder(), + ); + + final pieChartPainter = PieChartPainter(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + pieChartPainter.drawSectionIndividualBorders( + section, + 0, + 0, + 90, + const Offset(100, 100), + 30, + mockCanvasWrapper, + ); + + verifyNever(mockCanvasWrapper.drawArc(any, any, any, any, any)); + verifyNever(mockCanvasWrapper.drawLine(any, any, any)); + }); + + test('draws outer arc when outer border is set', () { + const viewSize = Size(200, 200); + final section = PieChartSectionData( + color: MockData.color1, + value: 1, + radius: 40, + border: const PieChartSectionBorder( + outer: BorderSide(color: MockData.color2, width: 2), + ), + ); + + final pieChartPainter = PieChartPainter(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + pieChartPainter.drawSectionIndividualBorders( + section, + 0, + 0, + 90, + const Offset(100, 100), + 30, + mockCanvasWrapper, + ); + + verify(mockCanvasWrapper.drawArc(any, any, any, false, any)).called(1); + verifyNever(mockCanvasWrapper.drawLine(any, any, any)); + }); + + test('draws inner arc when inner border is set', () { + const viewSize = Size(200, 200); + final section = PieChartSectionData( + color: MockData.color1, + value: 1, + radius: 40, + border: const PieChartSectionBorder( + inner: BorderSide(color: MockData.color2, width: 2), + ), + ); + + final pieChartPainter = PieChartPainter(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + pieChartPainter.drawSectionIndividualBorders( + section, + 0, + 0, + 90, + const Offset(100, 100), + 30, + mockCanvasWrapper, + ); + + verify(mockCanvasWrapper.drawArc(any, any, any, false, any)).called(1); + verifyNever(mockCanvasWrapper.drawLine(any, any, any)); + }); + + test('draws left and right lines when radial borders are set', () { + const viewSize = Size(200, 200); + final section = PieChartSectionData( + color: MockData.color1, + value: 1, + radius: 40, + border: const PieChartSectionBorder( + left: BorderSide(color: MockData.color3, width: 2), + right: BorderSide(color: MockData.color4, width: 2), + ), + ); + + final pieChartPainter = PieChartPainter(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + pieChartPainter.drawSectionIndividualBorders( + section, + 0, + 0, + 90, + const Offset(100, 100), + 30, + mockCanvasWrapper, + ); + + verifyNever(mockCanvasWrapper.drawArc(any, any, any, any, any)); + verify(mockCanvasWrapper.drawLine(any, any, any)).called(2); + }); + + test('draws all borders when .all() is used', () { + const viewSize = Size(200, 200); + final section = PieChartSectionData( + color: MockData.color1, + value: 1, + radius: 40, + border: const PieChartSectionBorder.all( + BorderSide(color: MockData.color2, width: 2), + ), + ); + + final pieChartPainter = PieChartPainter(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + pieChartPainter.drawSectionIndividualBorders( + section, + 0, + 0, + 90, + const Offset(100, 100), + 30, + mockCanvasWrapper, + ); + + // 2 arcs (outer + inner) + verify(mockCanvasWrapper.drawArc(any, any, any, false, any)).called(2); + // 2 lines (left + right) + verify(mockCanvasWrapper.drawLine(any, any, any)).called(2); + }); + + test('draws only arcs when .arcsOnly() is used', () { + const viewSize = Size(200, 200); + final section = PieChartSectionData( + color: MockData.color1, + value: 1, + radius: 40, + border: const PieChartSectionBorder.arcsOnly( + BorderSide(color: MockData.color2, width: 2), + ), + ); + + final pieChartPainter = PieChartPainter(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + pieChartPainter.drawSectionIndividualBorders( + section, + 0, + 0, + 90, + const Offset(100, 100), + 30, + mockCanvasWrapper, + ); + + // 2 arcs (outer + inner) + verify(mockCanvasWrapper.drawArc(any, any, any, false, any)).called(2); + // No lines + verifyNever(mockCanvasWrapper.drawLine(any, any, any)); + }); + }); }