chore: release v1.5.0
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m12s

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:
Jeroen Schweitzer
2026-01-06 23:05:24 +01:00
co-authored by Claude Opus 4.5
parent ea6914b5f4
commit fffc3d5baf
14 changed files with 1469 additions and 30 deletions
@@ -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),
],
],
);
},
);
}
}