- Add StoplightColors class with green/orange/red pastel colors - Update dashboard gauges to use StoplightColors.forPercent() - Update air quality levels to use shared stoplight colors - Provides consistent color scheme for all threshold-based indicators 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
318 lines
8.5 KiB
Dart
318 lines
8.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:tatlock_ui/shared/theme/stoplight_colors.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: StoplightColors.green,
|
|
minIndex: 0,
|
|
maxIndex: 50,
|
|
),
|
|
moderate(
|
|
label: 'Moderate',
|
|
description: 'Acceptable for most people',
|
|
color: StoplightColors.orange,
|
|
minIndex: 51,
|
|
maxIndex: 100,
|
|
),
|
|
unhealthySensitive(
|
|
label: 'Unhealthy for Sensitive',
|
|
description: 'May affect sensitive groups',
|
|
color: StoplightColors.orange,
|
|
minIndex: 101,
|
|
maxIndex: 150,
|
|
),
|
|
unhealthy(
|
|
label: 'Unhealthy',
|
|
description: 'Health effects for everyone',
|
|
color: StoplightColors.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'),
|
|
],
|
|
);
|
|
}
|
|
}
|