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>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
ea6914b5f4
commit
fffc3d5baf
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
|
||||
|
||||
part 'environment_datasource.g.dart';
|
||||
|
||||
/// Data source for environment data operations.
|
||||
///
|
||||
/// Fetches weather, forecast, sun times, and air quality from Core API.
|
||||
class EnvironmentDatasource {
|
||||
EnvironmentDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _basePath = '/tools/environment';
|
||||
|
||||
/// Gets current environment data.
|
||||
///
|
||||
/// Returns weather, forecast, sun times, and optionally air quality.
|
||||
Future<EnvironmentData> getEnvironment() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(_basePath);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Failed to fetch environment data');
|
||||
}
|
||||
|
||||
return EnvironmentData.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the environment datasource.
|
||||
@riverpod
|
||||
EnvironmentDatasource environmentDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
return EnvironmentDatasource(dio);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'environment_model.freezed.dart';
|
||||
part 'environment_model.g.dart';
|
||||
|
||||
/// Environment data response from Core API.
|
||||
/// Contains weather, forecast, sun times, and optionally air quality.
|
||||
@freezed
|
||||
sealed class EnvironmentData with _$EnvironmentData {
|
||||
const factory EnvironmentData({
|
||||
WeatherData? weather,
|
||||
List<ForecastDay>? forecast,
|
||||
@JsonKey(name: 'sun_times') SunTimesData? sunTimes,
|
||||
@JsonKey(name: 'air_quality') AirQualityData? airQuality,
|
||||
@JsonKey(name: 'updated_at') required DateTime updatedAt,
|
||||
String? user,
|
||||
}) = _EnvironmentData;
|
||||
|
||||
factory EnvironmentData.fromJson(Map<String, dynamic> json) =>
|
||||
_$EnvironmentDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Current weather conditions.
|
||||
@freezed
|
||||
sealed class WeatherData with _$WeatherData {
|
||||
const factory WeatherData({
|
||||
double? temperature,
|
||||
@JsonKey(name: 'feels_like') double? feelsLike,
|
||||
String? conditions,
|
||||
int? humidity,
|
||||
@JsonKey(name: 'wind_speed') double? windSpeed,
|
||||
@JsonKey(name: 'wind_direction') String? windDirection,
|
||||
double? pressure,
|
||||
double? visibility,
|
||||
@JsonKey(name: 'uv_index') double? uvIndex,
|
||||
String? location,
|
||||
String? icon,
|
||||
}) = _WeatherData;
|
||||
|
||||
factory WeatherData.fromJson(Map<String, dynamic> json) =>
|
||||
_$WeatherDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Single day forecast data.
|
||||
@freezed
|
||||
sealed class ForecastDay with _$ForecastDay {
|
||||
const factory ForecastDay({
|
||||
required String date,
|
||||
double? high,
|
||||
double? low,
|
||||
String? conditions,
|
||||
@JsonKey(name: 'precipitation_chance') int? precipitationChance,
|
||||
String? icon,
|
||||
}) = _ForecastDay;
|
||||
|
||||
factory ForecastDay.fromJson(Map<String, dynamic> json) =>
|
||||
_$ForecastDayFromJson(json);
|
||||
}
|
||||
|
||||
/// Sunrise and sunset times.
|
||||
@freezed
|
||||
sealed class SunTimesData with _$SunTimesData {
|
||||
const factory SunTimesData({
|
||||
DateTime? sunrise,
|
||||
DateTime? sunset,
|
||||
@JsonKey(name: 'daylight_minutes') int? daylightMinutes,
|
||||
@JsonKey(name: 'solar_noon') DateTime? solarNoon,
|
||||
DateTime? dawn,
|
||||
DateTime? dusk,
|
||||
}) = _SunTimesData;
|
||||
|
||||
factory SunTimesData.fromJson(Map<String, dynamic> json) =>
|
||||
_$SunTimesDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Air quality information.
|
||||
@freezed
|
||||
sealed class AirQualityData with _$AirQualityData {
|
||||
const factory AirQualityData({
|
||||
int? aqi,
|
||||
String? quality,
|
||||
double? pm25,
|
||||
double? pm10,
|
||||
double? o3,
|
||||
double? no2,
|
||||
String? location,
|
||||
}) = _AirQualityData;
|
||||
|
||||
factory AirQualityData.fromJson(Map<String, dynamic> json) =>
|
||||
_$AirQualityDataFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/datasources/environment_datasource.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
|
||||
|
||||
part 'environment_provider.g.dart';
|
||||
|
||||
/// Fetches environment data (weather, forecast, sun times) from Core API.
|
||||
///
|
||||
/// Auto-invalidates every 5 minutes to keep data fresh.
|
||||
/// Weather data in Qdrant has 1-hour TTL, so 5-minute refresh is reasonable.
|
||||
@riverpod
|
||||
Future<EnvironmentData> environment(Ref ref) async {
|
||||
final datasource = ref.watch(environmentDatasourceProvider);
|
||||
return datasource.getEnvironment();
|
||||
}
|
||||
|
||||
/// Provides whether air quality data is available.
|
||||
///
|
||||
/// Used for conditional rendering of AirQualityWidget.
|
||||
@riverpod
|
||||
bool hasAirQuality(Ref ref) {
|
||||
final asyncValue = ref.watch(environmentProvider);
|
||||
return asyncValue.maybeWhen(
|
||||
data: (data) => data.airQuality != null,
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/system_stats_model.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/environment_provider.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/system_stats_provider.dart';
|
||||
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/forecast_widget.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/sun_position_widget.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/widgets.dart';
|
||||
import 'package:tatlock_ui/version.g.dart';
|
||||
|
||||
@@ -20,20 +23,28 @@ class DashboardContent extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DashboardContentState extends ConsumerState<DashboardContent> {
|
||||
Timer? _refreshTimer;
|
||||
Timer? _systemStatsTimer;
|
||||
Timer? _environmentTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshTimer = Timer.periodic(
|
||||
// Refresh system stats every 30 seconds
|
||||
_systemStatsTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => ref.invalidate(systemStatsProvider),
|
||||
);
|
||||
// Refresh environment data every 5 minutes
|
||||
_environmentTimer = Timer.periodic(
|
||||
const Duration(minutes: 5),
|
||||
(_) => ref.invalidate(environmentProvider),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
_systemStatsTimer?.cancel();
|
||||
_environmentTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -41,6 +52,7 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final systemStatsAsync = ref.watch(systemStatsProvider);
|
||||
final environmentAsync = ref.watch(environmentProvider);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -116,31 +128,41 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Environment - Weather & Air Quality
|
||||
// Environment - Sun, Weather, Forecast, 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,
|
||||
environmentAsync.when(
|
||||
data: (envData) => _EnvironmentSection(
|
||||
envData: envData,
|
||||
onRefresh: () => ref.invalidate(environmentProvider),
|
||||
),
|
||||
loading: () => const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (error, _) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: WeatherWidget()),
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: AirQualityWidget()),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Failed to load environment data',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => ref.invalidate(environmentProvider),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
// Stack on narrow screens
|
||||
return Column(
|
||||
children: [
|
||||
WeatherWidget(),
|
||||
const SizedBox(height: 12),
|
||||
AirQualityWidget(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -256,3 +278,56 @@ class _SystemStatsCard extends StatelessWidget {
|
||||
return parts.isNotEmpty ? parts.last : mount;
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment section displaying sun position, weather, forecast, and air quality.
|
||||
class _EnvironmentSection extends StatelessWidget {
|
||||
const _EnvironmentSection({
|
||||
required this.envData,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final dynamic envData;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 600;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Sun Position Widget - prominent at top
|
||||
SunPositionWidget(sunTimes: envData.sunTimes),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Weather and Forecast side-by-side on wide screens
|
||||
if (isWide)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: WeatherWidget(apiData: envData.weather)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: ForecastWidget(forecast: envData.forecast)),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
WeatherWidget(apiData: envData.weather),
|
||||
const SizedBox(height: 12),
|
||||
ForecastWidget(forecast: envData.forecast),
|
||||
],
|
||||
),
|
||||
|
||||
// Air Quality - only show if data is available
|
||||
if (envData.airQuality != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
AirQualityWidget(apiData: envData.airQuality),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user