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:
co-authored by
Claude Opus 4.5
parent
40db92d9d5
commit
9c9ec472ef
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Shared widgets for Tatlock UI.
|
||||
library;
|
||||
|
||||
export 'air_quality_widget.dart';
|
||||
export 'gauge_widget.dart';
|
||||
export 'weather_widget.dart';
|
||||
Reference in New Issue
Block a user