diff --git a/lib/features/front_hall/presentation/widgets/dashboard_content.dart b/lib/features/front_hall/presentation/widgets/dashboard_content.dart index b30befe..f034c26 100644 --- a/lib/features/front_hall/presentation/widgets/dashboard_content.dart +++ b/lib/features/front_hall/presentation/widgets/dashboard_content.dart @@ -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, + ), + ), + ], ); } } diff --git a/lib/shared/widgets/air_quality_widget.dart b/lib/shared/widgets/air_quality_widget.dart new file mode 100644 index 0000000..96f00f9 --- /dev/null +++ b/lib/shared/widgets/air_quality_widget.dart @@ -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 pollutants; + + /// Create from index value. + factory AirQualityData.fromIndex(int index, {List? 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'), + ], + ); + } +} diff --git a/lib/shared/widgets/gauge_widget.dart b/lib/shared/widgets/gauge_widget.dart new file mode 100644 index 0000000..6995a3e --- /dev/null +++ b/lib/shared/widgets/gauge_widget.dart @@ -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 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; +} diff --git a/lib/shared/widgets/weather_widget.dart b/lib/shared/widgets/weather_widget.dart new file mode 100644 index 0000000..348eb08 --- /dev/null +++ b/lib/shared/widgets/weather_widget.dart @@ -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, + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart new file mode 100644 index 0000000..27c89f9 --- /dev/null +++ b/lib/shared/widgets/widgets.dart @@ -0,0 +1,6 @@ +/// Shared widgets for Tatlock UI. +library; + +export 'air_quality_widget.dart'; +export 'gauge_widget.dart'; +export 'weather_widget.dart';