Build and Push / build (release) Successful in 2m59s
- Add SystemStats freezed models matching Core API response - Add systemStatsProvider to fetch stats from /tools/system/stats - Update dashboard to display CPU, RAM, VRAM, and disk usage gauges - Implement color-coded gauges: green ≤50%, orange 51-75%, red >75% - Add auto-refresh every 30 seconds - Improve gauge widget: speedometer style, icons below labels - Update weather widget to match air quality vertical layout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
225 lines
5.8 KiB
Dart
225 lines
5.8 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// A speedometer-style gauge widget for displaying percentage values.
|
|
///
|
|
/// Commonly used for system stats like CPU, Memory, Disk usage.
|
|
class GaugeWidget extends StatelessWidget {
|
|
const GaugeWidget({
|
|
super.key,
|
|
required this.value,
|
|
required this.label,
|
|
this.size = 120,
|
|
this.strokeWidth = 10,
|
|
this.color,
|
|
this.backgroundColor,
|
|
this.showPercentage = true,
|
|
this.icon,
|
|
});
|
|
|
|
/// The value to display (0.0 - 1.0).
|
|
final double value;
|
|
|
|
/// Label displayed below the gauge.
|
|
final String label;
|
|
|
|
/// Size of the gauge widget.
|
|
final double size;
|
|
|
|
/// Width of the gauge arc stroke.
|
|
final double strokeWidth;
|
|
|
|
/// Color of the filled arc. Uses primary color if not specified.
|
|
final Color? color;
|
|
|
|
/// Color of the background arc. Uses surfaceVariant if not specified.
|
|
final Color? backgroundColor;
|
|
|
|
/// Whether to show the percentage text in the center.
|
|
final bool showPercentage;
|
|
|
|
/// Optional icon to show above the percentage.
|
|
final IconData? icon;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final effectiveColor = color ?? colorScheme.primary;
|
|
final effectiveBackgroundColor =
|
|
backgroundColor ?? colorScheme.surfaceContainerHighest;
|
|
|
|
// Clamp value between 0 and 1
|
|
final clampedValue = value.clamp(0.0, 1.0);
|
|
|
|
// Height is smaller since we only draw half circle
|
|
final gaugeHeight = size * 0.6;
|
|
final iconSpace = icon != null ? size * 0.36 : 0.0;
|
|
|
|
return SizedBox(
|
|
width: size,
|
|
height: gaugeHeight + 32 + iconSpace, // Space for label + icon
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: size,
|
|
height: gaugeHeight,
|
|
child: CustomPaint(
|
|
painter: _GaugePainter(
|
|
value: clampedValue,
|
|
color: effectiveColor,
|
|
backgroundColor: effectiveBackgroundColor,
|
|
strokeWidth: strokeWidth,
|
|
),
|
|
child: Align(
|
|
alignment: const Alignment(0, 0.6),
|
|
child: showPercentage
|
|
? Text(
|
|
'${(clampedValue * 100).round()}%',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: size * 0.2,
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (icon != null) ...[
|
|
const SizedBox(height: 8),
|
|
Icon(
|
|
icon,
|
|
size: size * 0.28,
|
|
color: effectiveColor,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GaugePainter extends CustomPainter {
|
|
_GaugePainter({
|
|
required this.value,
|
|
required this.color,
|
|
required this.backgroundColor,
|
|
required this.strokeWidth,
|
|
});
|
|
|
|
final double value;
|
|
final Color color;
|
|
final Color backgroundColor;
|
|
final double strokeWidth;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
// Center at bottom of widget for speedometer style
|
|
final center = Offset(size.width / 2, size.height);
|
|
final radius = (size.width - strokeWidth) / 2;
|
|
|
|
// Speedometer arc: starts from left (180°) and sweeps 180° to right
|
|
const startAngle = math.pi; // 180 degrees (left side)
|
|
const sweepAngle = math.pi; // 180 degrees sweep (semicircle)
|
|
|
|
// Background arc
|
|
final backgroundPaint = Paint()
|
|
..color = backgroundColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = strokeWidth
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: center, radius: radius),
|
|
startAngle,
|
|
sweepAngle,
|
|
false,
|
|
backgroundPaint,
|
|
);
|
|
|
|
// Value arc
|
|
if (value > 0) {
|
|
final valuePaint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = strokeWidth
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: center, radius: radius),
|
|
startAngle,
|
|
sweepAngle * value,
|
|
false,
|
|
valuePaint,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_GaugePainter oldDelegate) {
|
|
return oldDelegate.value != value ||
|
|
oldDelegate.color != color ||
|
|
oldDelegate.backgroundColor != backgroundColor ||
|
|
oldDelegate.strokeWidth != strokeWidth;
|
|
}
|
|
}
|
|
|
|
/// A row of gauge widgets with consistent sizing.
|
|
class GaugeRow extends StatelessWidget {
|
|
const GaugeRow({
|
|
super.key,
|
|
required this.gauges,
|
|
this.gaugeSize = 100,
|
|
});
|
|
|
|
final List<GaugeData> gauges;
|
|
final double gaugeSize;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Wrap(
|
|
spacing: 24,
|
|
runSpacing: 16,
|
|
alignment: WrapAlignment.center,
|
|
children: gauges
|
|
.map(
|
|
(data) => GaugeWidget(
|
|
value: data.value,
|
|
label: data.label,
|
|
size: gaugeSize,
|
|
strokeWidth: gaugeSize * 0.1,
|
|
color: data.color,
|
|
icon: data.icon,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Data class for gauge configuration.
|
|
class GaugeData {
|
|
const GaugeData({
|
|
required this.value,
|
|
required this.label,
|
|
this.color,
|
|
this.icon,
|
|
});
|
|
|
|
final double value;
|
|
final String label;
|
|
final Color? color;
|
|
final IconData? icon;
|
|
}
|