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:flutter/material.dart';
|
||||||
|
import 'package:tatlock_ui/shared/widgets/widgets.dart';
|
||||||
import 'package:tatlock_ui/version.g.dart';
|
import 'package:tatlock_ui/version.g.dart';
|
||||||
|
|
||||||
/// Dashboard content shown in Front Hall when mode is dashboard.
|
/// Dashboard content shown in Front Hall when mode is dashboard.
|
||||||
///
|
///
|
||||||
/// Displays system stats, welcome message, and version info.
|
/// Displays system stats with gauges, weather, air quality, and version info.
|
||||||
/// Will be expanded with weather and air quality widgets in Phase 3.
|
|
||||||
class DashboardContent extends StatelessWidget {
|
class DashboardContent extends StatelessWidget {
|
||||||
const DashboardContent({super.key});
|
const DashboardContent({super.key});
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ class DashboardContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Your homelab dashboard is ready. More features coming soon.',
|
'Your homelab dashboard is ready.',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -51,39 +51,70 @@ class DashboardContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Quick stats placeholder
|
// System Stats - Gauges
|
||||||
Wrap(
|
_SectionHeader(title: 'System Stats', icon: Icons.monitor_heart),
|
||||||
spacing: 12,
|
const SizedBox(height: 8),
|
||||||
runSpacing: 12,
|
Card(
|
||||||
children: [
|
child: Padding(
|
||||||
SizedBox(
|
padding: const EdgeInsets.all(16),
|
||||||
width: 160,
|
child: GaugeRow(
|
||||||
child: _StatCard(
|
gaugeSize: 90,
|
||||||
icon: Icons.memory,
|
gauges: [
|
||||||
label: 'CPU',
|
GaugeData(
|
||||||
value: '--',
|
value: 0.35,
|
||||||
color: colorScheme.primary,
|
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(
|
const SizedBox(height: 24),
|
||||||
icon: Icons.storage,
|
|
||||||
label: 'Memory',
|
// Environment - Weather & Air Quality
|
||||||
value: '--',
|
_SectionHeader(title: 'Environment', icon: Icons.eco),
|
||||||
color: colorScheme.secondary,
|
const SizedBox(height: 8),
|
||||||
),
|
LayoutBuilder(
|
||||||
),
|
builder: (context, constraints) {
|
||||||
SizedBox(
|
// Responsive layout: side-by-side on wider screens
|
||||||
width: 160,
|
if (constraints.maxWidth > 500) {
|
||||||
child: _StatCard(
|
return Row(
|
||||||
icon: Icons.dns,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
label: 'Containers',
|
children: [
|
||||||
value: '--',
|
Expanded(child: WeatherWidget()),
|
||||||
color: colorScheme.tertiary,
|
const SizedBox(width: 12),
|
||||||
),
|
Expanded(child: AirQualityWidget()),
|
||||||
),
|
],
|
||||||
],
|
);
|
||||||
|
}
|
||||||
|
// Stack on narrow screens
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
WeatherWidget(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AirQualityWidget(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@@ -101,44 +132,36 @@ class DashboardContent extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatCard extends StatelessWidget {
|
/// Section header with icon and title.
|
||||||
const _StatCard({
|
class _SectionHeader extends StatelessWidget {
|
||||||
|
const _SectionHeader({
|
||||||
|
required this.title,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
required this.color,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final Color color;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
return Row(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
Icon(
|
||||||
Icon(icon, size: 32, color: color),
|
icon,
|
||||||
const SizedBox(height: 8),
|
size: 18,
|
||||||
Text(
|
color: colorScheme.onSurfaceVariant,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
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