feat(dashboard): add gauge, weather, and air quality widgets

- Create shared GaugeWidget with circular progress and customizable colors
- Add GaugeRow for displaying multiple gauges in a responsive layout
- Create WeatherWidget with temperature, condition, and details
- Create AirQualityWidget with AQI levels and pollutant readings
- Update DashboardContent with System Stats gauges and Environment section
- Both widgets use mock data, ready for API integration

🤖 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-02 23:09:15 +01:00
co-authored by Claude Opus 4.5
parent 40db92d9d5
commit 9c9ec472ef
5 changed files with 889 additions and 65 deletions
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/shared/widgets/widgets.dart';
import 'package:tatlock_ui/version.g.dart';
/// Dashboard content shown in Front Hall when mode is dashboard.
///
/// Displays system stats, welcome message, and version info.
/// Will be expanded with weather and air quality widgets in Phase 3.
/// Displays system stats with gauges, weather, air quality, and version info.
class DashboardContent extends StatelessWidget {
const DashboardContent({super.key});
@@ -40,7 +40,7 @@ class DashboardContent extends StatelessWidget {
),
const SizedBox(height: 12),
Text(
'Your homelab dashboard is ready. More features coming soon.',
'Your homelab dashboard is ready.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -51,39 +51,70 @@ class DashboardContent extends StatelessWidget {
),
const SizedBox(height: 16),
// Quick stats placeholder
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: 160,
child: _StatCard(
icon: Icons.memory,
label: 'CPU',
value: '--',
color: colorScheme.primary,
),
// System Stats - Gauges
_SectionHeader(title: 'System Stats', icon: Icons.monitor_heart),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: GaugeRow(
gaugeSize: 90,
gauges: [
GaugeData(
value: 0.35,
label: 'CPU',
icon: Icons.memory,
color: colorScheme.primary,
),
GaugeData(
value: 0.62,
label: 'Memory',
icon: Icons.storage,
color: colorScheme.secondary,
),
GaugeData(
value: 0.78,
label: 'Disk',
icon: Icons.disc_full,
color: colorScheme.tertiary,
),
GaugeData(
value: 0.12,
label: 'Network',
icon: Icons.wifi,
color: Colors.teal,
),
],
),
SizedBox(
width: 160,
child: _StatCard(
icon: Icons.storage,
label: 'Memory',
value: '--',
color: colorScheme.secondary,
),
),
SizedBox(
width: 160,
child: _StatCard(
icon: Icons.dns,
label: 'Containers',
value: '--',
color: colorScheme.tertiary,
),
),
],
),
),
const SizedBox(height: 24),
// Environment - Weather & Air Quality
_SectionHeader(title: 'Environment', icon: Icons.eco),
const SizedBox(height: 8),
LayoutBuilder(
builder: (context, constraints) {
// Responsive layout: side-by-side on wider screens
if (constraints.maxWidth > 500) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: WeatherWidget()),
const SizedBox(width: 12),
Expanded(child: AirQualityWidget()),
],
);
}
// Stack on narrow screens
return Column(
children: [
WeatherWidget(),
const SizedBox(height: 12),
AirQualityWidget(),
],
);
},
),
const SizedBox(height: 24),
@@ -101,44 +132,36 @@ class DashboardContent extends StatelessWidget {
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
/// Section header with icon and title.
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.title,
required this.icon,
required this.label,
required this.value,
required this.color,
});
final String title;
final IconData icon;
final String label;
final String value;
final Color color;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(icon, size: 32, color: color),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
);
}
}
+316
View File
@@ -0,0 +1,316 @@
import 'package:flutter/material.dart';
/// Air Quality Index widget displaying current AQI.
///
/// Currently uses mock data. Will be connected to air quality API in future.
class AirQualityWidget extends StatelessWidget {
const AirQualityWidget({
super.key,
this.data,
this.compact = false,
});
/// Air quality data to display. Uses mock data if null.
final AirQualityData? data;
/// Whether to use compact layout.
final bool compact;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final aqi = data ?? AirQualityData.mock();
if (compact) {
return _buildCompact(context, colorScheme, aqi);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
Icon(
Icons.air,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Air Quality',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 16),
// AQI Display
Row(
children: [
// AQI number with colored background
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: aqi.level.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: aqi.level.color.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Text(
'${aqi.index}',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: aqi.level.color,
),
),
),
),
const SizedBox(width: 16),
// Level info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
aqi.level.label,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: aqi.level.color,
),
),
const SizedBox(height: 4),
Text(
aqi.level.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
// Pollutants
if (aqi.pollutants.isNotEmpty) ...[
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 8,
children: aqi.pollutants
.map((p) => _PollutantChip(pollutant: p))
.toList(),
),
],
],
),
),
);
}
Widget _buildCompact(
BuildContext context,
ColorScheme colorScheme,
AirQualityData aqi,
) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: aqi.level.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
'${aqi.index}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: aqi.level.color,
),
),
),
),
const SizedBox(width: 8),
Text(
'AQI',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
class _PollutantChip extends StatelessWidget {
const _PollutantChip({required this.pollutant});
final Pollutant pollutant;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
pollutant.name,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.outline,
),
),
const SizedBox(width: 4),
Text(
'${pollutant.value.round()} ${pollutant.unit}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
);
}
}
/// Air Quality Index levels based on US EPA standard.
enum AqiLevel {
good(
label: 'Good',
description: 'Air quality is satisfactory',
color: Colors.green,
minIndex: 0,
maxIndex: 50,
),
moderate(
label: 'Moderate',
description: 'Acceptable for most people',
color: Colors.amber,
minIndex: 51,
maxIndex: 100,
),
unhealthySensitive(
label: 'Unhealthy for Sensitive',
description: 'May affect sensitive groups',
color: Colors.orange,
minIndex: 101,
maxIndex: 150,
),
unhealthy(
label: 'Unhealthy',
description: 'Health effects for everyone',
color: Colors.red,
minIndex: 151,
maxIndex: 200,
),
veryUnhealthy(
label: 'Very Unhealthy',
description: 'Serious health effects',
color: Color(0xFF8B008B),
minIndex: 201,
maxIndex: 300,
),
hazardous(
label: 'Hazardous',
description: 'Health emergency conditions',
color: Color(0xFF800000),
minIndex: 301,
maxIndex: 500,
);
const AqiLevel({
required this.label,
required this.description,
required this.color,
required this.minIndex,
required this.maxIndex,
});
final String label;
final String description;
final Color color;
final int minIndex;
final int maxIndex;
static AqiLevel fromIndex(int index) {
for (final level in AqiLevel.values) {
if (index >= level.minIndex && index <= level.maxIndex) {
return level;
}
}
return AqiLevel.hazardous;
}
}
/// Pollutant data.
class Pollutant {
const Pollutant({
required this.name,
required this.value,
required this.unit,
});
final String name;
final double value;
final String unit;
}
/// Air quality data model.
class AirQualityData {
const AirQualityData({
required this.index,
required this.level,
this.pollutants = const [],
});
final int index;
final AqiLevel level;
final List<Pollutant> pollutants;
/// Create from index value.
factory AirQualityData.fromIndex(int index, {List<Pollutant>? pollutants}) {
return AirQualityData(
index: index,
level: AqiLevel.fromIndex(index),
pollutants: pollutants ?? const [],
);
}
/// Mock air quality data for development.
factory AirQualityData.mock() {
return const AirQualityData(
index: 42,
level: AqiLevel.good,
pollutants: [
Pollutant(name: 'PM2.5', value: 8.5, unit: 'µg/m³'),
Pollutant(name: 'PM10', value: 15, unit: 'µg/m³'),
Pollutant(name: 'O₃', value: 32, unit: 'ppb'),
],
);
}
}
+221
View File
@@ -0,0 +1,221 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// A circular 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);
return SizedBox(
width: size,
height: size + 24, // Extra space for label
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _GaugePainter(
value: clampedValue,
color: effectiveColor,
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(
'${(clampedValue * 100).round()}%',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
fontSize: size * 0.18,
),
),
],
),
),
),
),
const SizedBox(height: 8),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
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) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
// Start from top (-90 degrees) and sweep clockwise
const startAngle = -math.pi / 2;
const sweepAngle = 2 * math.pi;
// 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: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: gauges
.map(
(data) => GaugeWidget(
value: data.value,
label: data.label,
size: gaugeSize,
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;
}
+258
View File
@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
/// Weather widget displaying current conditions.
///
/// Currently uses mock data. Will be connected to weather API in future.
class WeatherWidget extends StatelessWidget {
const WeatherWidget({
super.key,
this.data,
this.compact = false,
});
/// Weather data to display. Uses mock data if null.
final WeatherData? data;
/// Whether to use compact layout.
final bool compact;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final weather = data ?? WeatherData.mock();
if (compact) {
return _buildCompact(context, colorScheme, weather);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
Icon(
Icons.location_on,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
weather.location,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
// Main weather display
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
weather.icon,
size: 48,
color: weather.iconColor ?? colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${weather.temperature.round()}°${weather.unit.symbol}',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
weather.condition,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
// Details
if (weather.humidity != null || weather.windSpeed != null) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
Row(
children: [
if (weather.humidity != null)
Expanded(
child: _DetailItem(
icon: Icons.water_drop_outlined,
label: 'Humidity',
value: '${weather.humidity}%',
),
),
if (weather.windSpeed != null)
Expanded(
child: _DetailItem(
icon: Icons.air,
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}',
),
),
],
),
],
],
),
),
);
}
Widget _buildCompact(
BuildContext context,
ColorScheme colorScheme,
WeatherData weather,
) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
weather.icon,
size: 24,
color: weather.iconColor ?? colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'${weather.temperature.round()}°${weather.unit.symbol}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}
class _DetailItem extends StatelessWidget {
const _DetailItem({
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: colorScheme.onSurfaceVariant,
),
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,
),
),
],
),
],
);
}
}
/// Temperature unit for weather display.
enum TemperatureUnit {
celsius('C'),
fahrenheit('F');
const TemperatureUnit(this.symbol);
final String symbol;
}
/// Weather data model.
class WeatherData {
const WeatherData({
required this.location,
required this.temperature,
required this.condition,
required this.icon,
this.unit = TemperatureUnit.celsius,
this.humidity,
this.windSpeed,
this.windUnit = 'km/h',
this.feelsLike,
this.iconColor,
});
final String location;
final double temperature;
final String condition;
final IconData icon;
final TemperatureUnit unit;
final int? humidity;
final double? windSpeed;
final String windUnit;
final double? feelsLike;
final Color? iconColor;
/// Mock weather data for development.
factory WeatherData.mock() {
return const WeatherData(
location: 'Rotterdam, NL',
temperature: 8,
condition: 'Partly Cloudy',
icon: Icons.cloud,
humidity: 72,
windSpeed: 18,
feelsLike: 5,
iconColor: Colors.blueGrey,
);
}
}
+6
View File
@@ -0,0 +1,6 @@
/// Shared widgets for Tatlock UI.
library;
export 'air_quality_widget.dart';
export 'gauge_widget.dart';
export 'weather_widget.dart';