import 'dart:math' as math; import 'package:flutter/material.dart'; import '../about/internal/application/TextType.dart'; import '../utils/Colors.dart'; import 'text/Text.dart'; /// Job-statistics style card: a donut gauge with the total in the centre, plus /// labelled progress bars underneath. class StatsDonut extends StatelessWidget { final int success; final int failed; final int reverted; const StatsDonut({ super.key, required this.success, required this.failed, required this.reverted, }); int get _total => success + failed + reverted; @override Widget build(BuildContext context) { final jp = context.jp; final total = _total; double pct(int n) => total == 0 ? 0 : n / total; return Container( padding: const EdgeInsets.all(18), decoration: PeachTheme.panel(context), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ text( 'Deploy statistics', 16, TextType.Bold, color: jp.text, weight: FontWeight.w800, ), const Spacer(), Container( width: 30, height: 30, decoration: BoxDecoration( color: jp.surface2, borderRadius: BorderRadius.circular(9), ), child: Icon(Icons.open_in_full, size: 14, color: jp.textDim), ), ], ), const SizedBox(height: 14), Center( child: SizedBox( width: 176, height: 176, child: CustomPaint( painter: _DonutPainter( track: jp.surface2, segments: [ (pct(success), PeachTheme.seed), (pct(failed), PeachTheme.danger), (pct(reverted), PeachTheme.slate), ], ), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ text( '$total', 38, TextType.Bold, color: jp.text, height: 1, weight: FontWeight.w800, ), const SizedBox(height: 2), text( 'Total deploys', 12, TextType.Regular, color: jp.textFaint, weight: FontWeight.w400, ), ], ), ), ), ), ), const SizedBox(height: 18), _bar(jp, 'Successful', success, pct(success), PeachTheme.seed), const SizedBox(height: 14), _bar(jp, 'Failed', failed, pct(failed), PeachTheme.danger), const SizedBox(height: 14), _bar(jp, 'Reverted', reverted, pct(reverted), PeachTheme.slate), ], ), ); } Widget _bar(JpColors jp, String label, int n, double frac, Color color) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ text( label, 13, TextType.Regular, color: jp.textDim, weight: FontWeight.w600, ), const Spacer(), text( '${(frac * 100).round()}%', 13, TextType.Regular, color: jp.text, weight: FontWeight.w800, ), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(6), child: Stack( children: [ Container(height: 8, color: jp.surface2), FractionallySizedBox( widthFactor: frac.clamp(0.0, 1.0), child: Container(height: 8, color: color), ), ], ), ), ], ); } } class _DonutPainter extends CustomPainter { final List<(double, Color)> segments; final Color track; _DonutPainter({required this.segments, required this.track}); @override void paint(Canvas canvas, Size size) { const stroke = 18.0; final rect = Offset(stroke / 2, stroke / 2) & Size(size.width - stroke, size.height - stroke); final trackPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = stroke ..color = track; canvas.drawArc(rect, 0, 2 * math.pi, false, trackPaint); const gap = 0.06; var start = -math.pi / 2; for (final (frac, color) in segments) { if (frac <= 0) continue; final full = frac * 2 * math.pi; final sweep = (full - gap).clamp(0.02, 2 * math.pi); final p = Paint() ..style = PaintingStyle.stroke ..strokeWidth = stroke ..strokeCap = StrokeCap.round ..color = color; canvas.drawArc(rect, start + gap / 2, sweep, false, p); start += full; } } @override bool shouldRepaint(_DonutPainter old) => old.segments != segments || old.track != track; }