flutterdartsvgchartsgraphics

any ideas to create a border radius on a strokecap.butt or strokecap.square in flutter?


Our goal is to go from this this semi donut chart to that one. As you can see we want to maintain the

strokecap.sqaure 

or

.butt 

and then implement the rounded edges on just the corners of the cap.

this doesn't seem to be directly possible, our last option now is to somehow program the svg into a real chart or somehow bake the border radius into the flutter path but this is all ugly and none of us has experience with programming an svg.

the implemented version so far :

import 'package:flutter/material.dart';
import 'dart:math' as math;
import '../../models/recipe.dart';

class RecipeCaloriesChart extends StatelessWidget {
  final Recipe recipe;
  final double size;

  const RecipeCaloriesChart({
    super.key,
    required this.recipe,
    this.size = 200,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        SizedBox(
          width: size,
          height: size,
          child: CustomPaint(
            painter: _CaloriesArcPainter(
              fatsPercentage: recipe.fatsPerc,
              carbsPercentage: recipe.carbsPerc,
              proteinPercentage: recipe.proteinPerc,
              calories: recipe.calories,
            ),
          ),
        ),
        _buildLegend(),
      ],
    );
  }

  Widget _buildLegend() {
    final textStyle = TextStyle(
      fontSize: 14,
      color: Colors.grey[600],
    );

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24),
      child: Column(
        children: [
          _LegendItem(
            color: const Color(0xFF808DA0),
            label: 'Fats',
            percentage: recipe.fatsPerc,
            grams: recipe.fatG.round(),
            textStyle: textStyle,
          ),
          const SizedBox(height: 8),
          _LegendItem(
            color: const Color(0xFFA9B3C7),
            label: 'Carbs',
            percentage: recipe.carbsPerc,
            grams: recipe.carbsG.round(),
            textStyle: textStyle,
          ),
          const SizedBox(height: 8),
          _LegendItem(
            color: const Color(0xFFAFC3DF),
            label: 'Proteins',
            percentage: recipe.proteinPerc,
            grams: recipe.proteinG.round(),
            textStyle: textStyle,
          ),
        ],
      ),
    );
  }
}

class _LegendItem extends StatelessWidget {
  final Color color;
  final String label;
  final double percentage;
  final int grams;
  final TextStyle textStyle;

  const _LegendItem({
    required this.color,
    required this.label,
    required this.percentage,
    required this.grams,
    required this.textStyle,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Container(
          width: 12,
          height: 12,
          decoration: BoxDecoration(
            color: color,
            shape: BoxShape.circle,
          ),
        ),
        const SizedBox(width: 8),
        Text(label, style: textStyle),
        const Spacer(),
        Text('${percentage.round()}%', style: textStyle),
        const SizedBox(width: 16),
        Text('${grams}g', style: textStyle),
      ],
    );
  }
}

class _CaloriesArcPainter extends CustomPainter {
  final double fatsPercentage;
  final double carbsPercentage;
  final double proteinPercentage;
  final double calories;

  _CaloriesArcPainter({
    required this.fatsPercentage,
    required this.carbsPercentage,
    required this.proteinPercentage,
    required this.calories,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width * 0.45;
    final strokeWidth = size.width * 0.18;
    const borderWidth = 10.0;

    final rect = Rect.fromCircle(center: center, radius: radius);
    const startAngle = -math.pi;
    const gapAngle = math.pi / 48;
    const totalGaps = gapAngle * 2;

    const availableAngle = math.pi - totalGaps;

    final fatsAngle = (fatsPercentage / 100) * availableAngle;
    final carbsAngle = (carbsPercentage / 100) * availableAngle;
    final proteinsAngle = (proteinPercentage / 100) * availableAngle;

    _drawArcWithBorder(
      canvas, 
      rect, 
      startAngle, 
      fatsAngle,
      const Color(0xFF808DA0),
      strokeWidth,
      borderWidth,
    );
    _drawArcWithBorder(
      canvas, 
      rect, 
      startAngle + fatsAngle + gapAngle, 
      carbsAngle,
      const Color(0xFFA9B3C7),
      strokeWidth,
      borderWidth,
    );
    _drawArcWithBorder(
      canvas, 
      rect, 
      startAngle + fatsAngle + carbsAngle + (2 * gapAngle), 
      proteinsAngle,
      const Color(0xFFAFC3DF),
      strokeWidth,
      borderWidth,
    );

    final textPainter = TextPainter(
      text: TextSpan(
        text: calories.round().toString(),
        style: TextStyle(
          color: Colors.black,
          fontSize: size.width * 0.15,
          fontWeight: FontWeight.bold,
        ),
      ),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();
    textPainter.paint(
      canvas,
      Offset(
        center.dx - textPainter.width / 2,
        center.dy - textPainter.height / 2,
      ),
    );
  }

  void _drawArcWithBorder(
    Canvas canvas,
    Rect rect,
    double startAngle,
    double sweepAngle,
    Color color,
    double strokeWidth,
    double borderWidth,
  ) {
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.butt;

    canvas.drawArc(rect, startAngle, sweepAngle, false, paint);

    final borderPaint = Paint()
      ..color = const Color(0x33FFFFFF)
      ..style = PaintingStyle.stroke
      ..strokeWidth = borderWidth
      ..strokeCap = StrokeCap.butt;

    final innerRadius = rect.width / 2 - (strokeWidth / 2);
    final outerRadius = rect.width / 2 + (strokeWidth / 2);
    final center = Offset(rect.center.dx, rect.center.dy);

    final innerBorderRect = Rect.fromCircle(center: center, radius: innerRadius);
    final outerBorderRect = Rect.fromCircle(center: center, radius: outerRadius);

    canvas.drawArc(innerBorderRect, startAngle, sweepAngle, false, borderPaint);
    canvas.drawArc(outerBorderRect, startAngle, sweepAngle, false, borderPaint);

    final startInnerPoint = Offset(
      center.dx + innerRadius * math.cos(startAngle),
      center.dy + innerRadius * math.sin(startAngle),
    );
    final startOuterPoint = Offset(
      center.dx + outerRadius * math.cos(startAngle),
      center.dy + outerRadius * math.sin(startAngle),
    );
    
    final endInnerPoint = Offset(
      center.dx + innerRadius * math.cos(startAngle + sweepAngle),
      center.dy + innerRadius * math.sin(startAngle + sweepAngle),
    );
    final endOuterPoint = Offset(
      center.dx + outerRadius * math.cos(startAngle + sweepAngle),
      center.dy + outerRadius * math.sin(startAngle + sweepAngle),
    );

    canvas.drawLine(startInnerPoint, startOuterPoint, borderPaint);
    canvas.drawLine(endInnerPoint, endOuterPoint, borderPaint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

the svg we are trying to implement :

<svg width="1000" height="475" viewBox="0 0 1000 475" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_2076_12939" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="1000" height="475">
<rect width="1000" height="474.081" rx="10" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_2076_12939)">
<mask id="path-2-inside-1_2076_12939" fill="white">
<path d="M16.6066 471.986C11.0913 471.697 6.84376 466.99 7.24418 461.482C12.6017 387.78 34.4621 316.179 71.2588 251.998C108.055 187.817 158.801 132.777 219.698 90.9167C224.249 87.7882 230.457 89.0757 233.493 93.6895L307.473 206.133C310.509 210.746 309.22 216.935 304.698 220.106C264.039 248.612 230.116 285.742 205.377 328.891C180.639 372.041 165.735 420.075 161.677 469.565C161.225 475.07 156.536 479.308 151.02 479.02L16.6066 471.986Z"/>
</mask>
<path d="M16.6066 471.986C11.0913 471.697 6.84376 466.99 7.24418 461.482C12.6017 387.78 34.4621 316.179 71.2588 251.998C108.055 187.817 158.801 132.777 219.698 90.9167C224.249 87.7882 230.457 89.0757 233.493 93.6895L307.473 206.133C310.509 210.746 309.22 216.935 304.698 220.106C264.039 248.612 230.116 285.742 205.377 328.891C180.639 372.041 165.735 420.075 161.677 469.565C161.225 475.07 156.536 479.308 151.02 479.02L16.6066 471.986Z" fill="#808DA0" stroke="white" stroke-opacity="0.2" stroke-width="10" mask="url(#path-2-inside-1_2076_12939)"/>
<mask id="path-3-inside-2_2076_12939" fill="white">
<path d="M252.414 81.8575C249.595 77.1085 251.155 70.9636 255.96 68.2412C309.343 37.9979 367.929 17.9691 428.715 9.21496C492.802 -0.0145766 558.079 3.46915 620.82 19.4672C683.561 35.4653 742.536 63.6644 794.379 102.455C843.551 139.247 885.393 184.883 917.776 236.996C920.691 241.687 919.118 247.828 914.369 250.648L792.138 323.209C787.389 326.028 781.267 324.454 778.305 319.793C756.822 285.976 729.361 256.317 697.238 232.282C662.445 206.248 622.864 187.323 580.757 176.586C538.649 165.849 494.839 163.511 451.828 169.705C412.118 175.424 373.808 188.312 338.755 207.713C333.923 210.388 327.795 208.837 324.975 204.088L252.414 81.8575Z"/>
</mask>
<path d="M252.414 81.8575C249.595 77.1085 251.155 70.9636 255.96 68.2412C309.343 37.9979 367.929 17.9691 428.715 9.21496C492.802 -0.0145766 558.079 3.46915 620.82 19.4672C683.561 35.4653 742.536 63.6644 794.379 102.455C843.551 139.247 885.393 184.883 917.776 236.996C920.691 241.687 919.118 247.828 914.369 250.648L792.138 323.209C787.389 326.028 781.267 324.454 778.305 319.793C756.822 285.976 729.361 256.317 697.238 232.282C662.445 206.248 622.864 187.323 580.757 176.586C538.649 165.849 494.839 163.511 451.828 169.705C412.118 175.424 373.808 188.312 338.755 207.713C333.923 210.388 327.795 208.837 324.975 204.088L252.414 81.8575Z" fill="#A9B3C7" stroke="white" stroke-opacity="0.2" stroke-width="10" mask="url(#path-3-inside-2_2076_12939)"/>
<mask id="path-4-inside-3_2076_12939" fill="white">
<path d="M928.71 276.586C933.623 274.063 939.661 275.997 942.083 280.961C969.543 337.219 986.038 398.194 990.686 460.624C991.096 466.132 986.856 470.846 981.342 471.144L846.94 478.413C841.426 478.711 836.729 474.48 836.268 468.976C832.856 428.254 822.098 388.485 804.514 351.597C802.138 346.611 804.061 340.589 808.974 338.067L928.71 276.586Z"/>
</mask>
<path d="M928.71 276.586C933.623 274.063 939.661 275.997 942.083 280.961C969.543 337.219 986.038 398.194 990.686 460.624C991.096 466.132 986.856 470.846 981.342 471.144L846.94 478.413C841.426 478.711 836.729 474.48 836.268 468.976C832.856 428.254 822.098 388.485 804.514 351.597C802.138 346.611 804.061 340.589 808.974 338.067L928.71 276.586Z" fill="#AFC3DF" stroke="white" stroke-opacity="0.2" stroke-width="10" mask="url(#path-4-inside-3_2076_12939)"/>
</g>
</svg>

any ideas would be extremely appreciated, this is taking us way longer than it should and our design team is not so flexible about it.

We tried baking the rounded edges into the path (using dots), but we can't seem to get this right (it's affecting other parts of the shape). We also tried to use cliprect but we still can't get this right. I believe this isn't about what we tried as its more a lack of experience.


Solution

  • This has been solved thanks to pskink's comment on my question:

    this could be a good starting point: pastebin.com/YTyCPVZd – pskink

    Here the code as per his answer:

    class Foo1Painter extends CustomPainter {
      @override
      void paint(Canvas canvas, Size size) {
        final R = size.shortestSide / 2 - 32;
        const r = 12.0;
        final center = size.center(Offset.zero);
        drawArc(canvas, Colors.orange, center, pi * 0.5, pi * 0.75, R, r);
        drawArc(canvas, Colors.red, center, pi * 0.75, pi * 1.0, R, r);
        drawArc(canvas, Colors.green, center, pi * 1.0, pi * 1.25, R, r);
        drawArc(canvas, Colors.blue, center, pi * 1.25, pi * 1.5, R, r);
      }
     
      drawArc(Canvas canvas, Color color, Offset center, double angle1, double angle2, double R, double r) {
        final delta = atan(r / (R - r));
     
        final rp1 = center + Offset.fromDirection(angle1, R - r);
        final rcp1 = center + Offset.fromDirection(angle1, R);
        final ap1 = center + Offset.fromDirection(angle1 + delta, R);
     
        final ap2 = center + Offset.fromDirection(angle2 - delta, R);
        final rp2 = center + Offset.fromDirection(angle2, R - r);
        final rcp2 = center + Offset.fromDirection(angle2, R);
     
        final path = Path()
          ..moveTo(center.dx, center.dy)
          ..lineTo(rp1.dx, rp1.dy)
          ..quadraticBezierTo(rcp1.dx, rcp1.dy, ap1.dx, ap1.dy)
          ..arcToPoint(ap2, radius: Radius.circular(R))
          ..quadraticBezierTo(rcp2.dx, rcp2.dy, rp2.dx, rp2.dy)
          ..lineTo(center.dx, center.dy);
        canvas.drawPath(path, Paint()..color = color);
      }
     
      @override
      bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
    }