Files
tatlock-ui/lib/shared/widgets/air_quality_widget.dart
T
Jeroen SchweitzerandClaude Opus 4.5 fffc3d5baf
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m12s
chore: release v1.5.0
feat: add dynamic environment widgets

Add live weather, sun position, and forecast widgets to Front Hall dashboard,
powered by data from the Qdrant volatile collection via core-api.

New widgets:
- SunPositionWidget: Animated arc showing sun/moon position with gradient colors
- ForecastWidget: Multi-day weather outlook
- Updated WeatherWidget and AirQualityWidget to accept API data

Infrastructure:
- Environment datasource calling GET /tools/environment
- Environment provider with 5-minute auto-refresh
- Freezed models for environment data

Tests:
- 12 widget tests for environment section
- Updated existing tests with givenEnvironment() harness method

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:05:24 +01:00

354 lines
9.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart'
as api;
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
/// Air Quality Index widget displaying current AQI.
///
/// Displays air quality data from the Core API environment endpoint.
/// Only render this widget when air quality data is available.
class AirQualityWidget extends StatelessWidget {
const AirQualityWidget({
super.key,
this.apiData,
this.data,
this.compact = false,
});
/// Air quality data from API. Takes priority over legacy data.
final api.AirQualityData? apiData;
/// Legacy 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;
// Convert API data to local model, or use legacy data
final aqi = apiData != null
? _fromApiData(apiData!)
: (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,
),
),
],
);
}
}
/// Converts API air quality data to local AirQualityData model.
AirQualityData _fromApiData(api.AirQualityData data) {
final index = data.aqi ?? 0;
final pollutants = <Pollutant>[];
if (data.pm25 != null) {
pollutants.add(Pollutant(name: 'PM2.5', value: data.pm25!, unit: 'µg/m³'));
}
if (data.pm10 != null) {
pollutants.add(Pollutant(name: 'PM10', value: data.pm10!, unit: 'µg/m³'));
}
if (data.o3 != null) {
pollutants.add(Pollutant(name: 'O₃', value: data.o3!, unit: 'ppb'));
}
if (data.no2 != null) {
pollutants.add(Pollutant(name: 'NO₂', value: data.no2!, unit: 'ppb'));
}
return AirQualityData(
index: index,
level: AqiLevel.fromIndex(index),
pollutants: pollutants,
);
}
/// 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'),
],
);
}
}