- Add NewsTickerWidget with horizontal auto-scrolling at 40px/sec - Add NewsData and NewsHeadline Freezed models - Add news datasource fetching from /tools/news endpoint - Add news provider with 30-minute auto-refresh - Place ticker between Welcome card and System Stats on dashboard - Show placeholder headlines when no data (italic, muted style) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
476 lines
14 KiB
Dart
476 lines
14 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;
|
|
|
|
// Show "no data" state when no API data and no legacy data
|
|
if (apiData == null && data == null) {
|
|
if (compact) {
|
|
return _buildCompactNoData(context, colorScheme);
|
|
}
|
|
return _buildNoData(context, colorScheme);
|
|
}
|
|
|
|
// Convert API data to local model, or use legacy data
|
|
final aqi = apiData != null ? _fromApiData(apiData!) : data!;
|
|
|
|
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: 8),
|
|
|
|
// Content area with fixed height, horizon line at 50px from bottom
|
|
SizedBox(
|
|
height: 170,
|
|
child: Stack(
|
|
children: [
|
|
// Main content positioned above horizon (8px gap)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 58,
|
|
child: 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(
|
|
mainAxisSize: MainAxisSize.min,
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Horizon divider at fixed position (matches Sun Position horizonY)
|
|
if (aqi.pollutants.isNotEmpty)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 50,
|
|
child: const Divider(height: 1),
|
|
),
|
|
// Footer below horizon
|
|
if (aqi.pollutants.isNotEmpty)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNoData(BuildContext context, ColorScheme colorScheme) {
|
|
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: 8),
|
|
// Match sun position card height (170px content area)
|
|
SizedBox(
|
|
height: 170,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.air_outlined,
|
|
size: 32,
|
|
color: colorScheme.outline,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'No data available',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.outline,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCompactNoData(BuildContext context, ColorScheme colorScheme) {
|
|
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: colorScheme.outline.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'--',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: colorScheme.outline,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'AQI',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.outline,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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'),
|
|
],
|
|
);
|
|
}
|
|
}
|