feat(dashboard): wire system stats to Core API with live gauges
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>
This commit is contained in:
Jeroen Schweitzer
2026-01-03 18:24:04 +01:00
co-authored by Claude Opus 4.5
parent bdced8739c
commit 67fed18cb0
5 changed files with 330 additions and 121 deletions
+30 -27
View File
@@ -2,7 +2,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
/// A circular gauge widget for displaying percentage values.
/// A speedometer-style gauge widget for displaying percentage values.
///
/// Commonly used for system stats like CPU, Memory, Disk usage.
class GaugeWidget extends StatelessWidget {
@@ -52,15 +52,19 @@ class GaugeWidget extends StatelessWidget {
// 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: size + 24, // Extra space for label
height: gaugeHeight + 32 + iconSpace, // Space for label + icon
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
height: gaugeHeight,
child: CustomPaint(
painter: _GaugePainter(
value: clampedValue,
@@ -68,32 +72,21 @@ class GaugeWidget extends StatelessWidget {
backgroundColor: effectiveBackgroundColor,
strokeWidth: strokeWidth,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: size * 0.2,
color: effectiveColor,
),
SizedBox(height: size * 0.02),
],
if (showPercentage)
Text(
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.18,
fontSize: size * 0.2,
),
),
],
),
)
: const SizedBox.shrink(),
),
),
),
const SizedBox(height: 8),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -103,6 +96,14 @@ class GaugeWidget extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (icon != null) ...[
const SizedBox(height: 8),
Icon(
icon,
size: size * 0.28,
color: effectiveColor,
),
],
],
),
);
@@ -124,12 +125,13 @@ class _GaugePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
// Center at bottom of widget for speedometer style
final center = Offset(size.width / 2, size.height);
final radius = (size.width - strokeWidth) / 2;
// Start from top (-90 degrees) and sweep clockwise
const startAngle = -math.pi / 2;
const sweepAngle = 2 * math.pi;
// 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()
@@ -187,7 +189,7 @@ class GaugeRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
spacing: 24,
runSpacing: 16,
alignment: WrapAlignment.center,
children: gauges
@@ -196,6 +198,7 @@ class GaugeRow extends StatelessWidget {
value: data.value,
label: data.label,
size: gaugeSize,
strokeWidth: gaugeSize * 0.1,
color: data.color,
icon: data.icon,
),
+61 -62
View File
@@ -36,15 +36,15 @@ class WeatherWidget extends StatelessWidget {
Row(
children: [
Icon(
Icons.location_on,
size: 16,
Icons.wb_sunny_outlined,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
const SizedBox(width: 8),
Expanded(
child: Text(
weather.location,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
@@ -53,18 +53,36 @@ class WeatherWidget extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 16),
// Main weather display
// Main weather display (matching AQI layout)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
weather.icon,
size: 48,
color: weather.iconColor ?? colorScheme.primary,
// Weather icon in box (like AQI number box)
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: (weather.iconColor ?? colorScheme.primary)
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: (weather.iconColor ?? colorScheme.primary)
.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Icon(
weather.icon,
size: 32,
color: weather.iconColor ?? colorScheme.primary,
),
),
),
const SizedBox(width: 12),
const SizedBox(width: 16),
// Temperature and condition
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -72,15 +90,18 @@ class WeatherWidget extends StatelessWidget {
Text(
'${weather.temperature.round()}°${weather.unit.symbol}',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
weather.condition,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
@@ -90,36 +111,27 @@ class WeatherWidget extends StatelessWidget {
// Details
if (weather.humidity != null || weather.windSpeed != null) ...[
const SizedBox(height: 12),
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 12),
Row(
Wrap(
spacing: 16,
runSpacing: 8,
children: [
if (weather.humidity != null)
Expanded(
child: _DetailItem(
icon: Icons.water_drop_outlined,
label: 'Humidity',
value: '${weather.humidity}%',
),
_DetailChip(
label: 'Humidity',
value: '${weather.humidity}%',
),
if (weather.windSpeed != null)
Expanded(
child: _DetailItem(
icon: Icons.air,
label: 'Wind',
value:
'${weather.windSpeed!.round()} ${weather.windUnit}',
),
_DetailChip(
label: 'Wind',
value: '${weather.windSpeed!.round()} ${weather.windUnit}',
),
if (weather.feelsLike != null)
Expanded(
child: _DetailItem(
icon: Icons.thermostat,
label: 'Feels like',
value:
'${weather.feelsLike!.round()}°${weather.unit.symbol}',
),
_DetailChip(
label: 'Feels',
value: '${weather.feelsLike!.round()}°${weather.unit.symbol}',
),
],
),
@@ -160,14 +172,12 @@ class WeatherWidget extends StatelessWidget {
}
}
class _DetailItem extends StatelessWidget {
const _DetailItem({
required this.icon,
class _DetailChip extends StatelessWidget {
const _DetailChip({
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@@ -178,29 +188,18 @@ class _DetailItem extends StatelessWidget {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: colorScheme.onSurfaceVariant,
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.outline,
),
),
const SizedBox(width: 4),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.outline,
fontSize: 10,
),
),
],
Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
);