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,219 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import 'package:tatlock_ui/shared/widgets/air_quality_widget.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/weather_widget.dart';
import '../../harness/screen_sizes.dart';
import '../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
// Set larger window size and suppress overflow errors
Future<void> setLargeWindowSize(WidgetTester tester) async {
tester.view.physicalSize = const Size(1400, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
// Suppress overflow errors - not what we're testing
final originalOnError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains('overflowed')) {
return;
}
originalOnError?.call(details);
};
addTearDown(() => FlutterError.onError = originalOnError);
}
group('Environment Widgets', () {
testWidgets('displays Sun Position widget', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(SunPositionWidget), findsOneWidget);
expect(find.text('Sun Position'), findsOneWidget);
});
testWidgets('displays Weather widget', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(WeatherWidget), findsOneWidget);
});
testWidgets('displays Forecast widget', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(ForecastWidget), findsOneWidget);
expect(find.text('Forecast'), findsOneWidget);
});
testWidgets('displays Air Quality widget when data available',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment(); // Has air quality data
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(AirQualityWidget), findsOneWidget);
expect(find.text('Air Quality'), findsOneWidget);
});
testWidgets('hides Air Quality widget when no data', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironmentNoAirQuality(); // No air quality data
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Air Quality widget should not be present
expect(find.byType(AirQualityWidget), findsNothing);
});
testWidgets('calls environment API on mount', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
harness.verifyApiCalled('GET', '/tools/environment');
});
testWidgets('displays error state when environment fails', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenApiError(
method: 'GET',
path: '/tools/environment',
statusCode: 500,
message: 'Internal server error',
);
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should show environment error message or error indicator
expect(
find.text('Failed to load environment data').evaluate().isNotEmpty ||
find.byIcon(Icons.error_outline).evaluate().isNotEmpty,
isTrue,
reason: 'Should display environment error',
);
});
testWidgets('displays sunrise and sunset times', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should display sunrise/sunset labels
expect(find.text('Sunrise'), findsOneWidget);
expect(find.text('Sunset'), findsOneWidget);
});
testWidgets('displays daylight duration', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should display daylight label
expect(find.text('Daylight'), findsOneWidget);
});
});
group('Environment Widgets Responsive', () {
testWidgets('renders environment widgets at desktop size', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(SunPositionWidget), findsOneWidget);
expect(find.byType(WeatherWidget), findsOneWidget);
expect(find.byType(ForecastWidget), findsOneWidget);
});
testWidgets('renders environment widgets at tablet size', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(SunPositionWidget), findsOneWidget);
expect(find.byType(WeatherWidget), findsOneWidget);
expect(find.byType(ForecastWidget), findsOneWidget);
});
testWidgets('renders environment widgets at mobile size', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Page should render without crashing at mobile size
// Widgets may be below the fold due to scrolling
expect(find.byType(FrontHallPage), findsOneWidget);
});
});
}
@@ -34,6 +34,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -47,6 +48,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -60,6 +62,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -75,6 +78,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -88,6 +92,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -101,6 +106,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -114,6 +120,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -127,6 +134,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pump();
@@ -142,6 +150,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -155,6 +164,7 @@ void main() {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenEnvironment();
harness.givenApiError(
method: 'GET',
path: '/tools/system/stats',
@@ -179,6 +189,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -191,6 +202,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -204,6 +216,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks([]); // Empty list triggers fallback
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -219,6 +232,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks(); // Fixtures have Infrastructure, Development, Home
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -235,6 +249,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -248,6 +263,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -261,6 +277,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -273,6 +290,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
@@ -286,6 +304,7 @@ void main() {
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
+89
View File
@@ -318,4 +318,93 @@ class Fixtures {
static Map<String, dynamic> quickLink(int index) =>
Map<String, dynamic>.from(quickLinks[index]);
// ============================================================
// Environment Data
// ============================================================
static const environment = {
'weather': {
'temperature': 8.5,
'feels_like': 5.0,
'conditions': 'Partly Cloudy',
'humidity': 72,
'wind_speed': 18.5,
'wind_direction': 'SW',
'location': 'Rotterdam, NL',
'icon': '03d',
},
'forecast': [
{
'date': '2026-01-06',
'high': 10.0,
'low': 4.0,
'conditions': 'Cloudy',
'precipitation_chance': 20,
},
{
'date': '2026-01-07',
'high': 12.0,
'low': 5.0,
'conditions': 'Partly Cloudy',
'precipitation_chance': 10,
},
{
'date': '2026-01-08',
'high': 9.0,
'low': 3.0,
'conditions': 'Rain',
'precipitation_chance': 80,
},
{
'date': '2026-01-09',
'high': 11.0,
'low': 6.0,
'conditions': 'Sunny',
'precipitation_chance': 0,
},
],
'sun_times': {
'sunrise': '2026-01-06T08:45:00Z',
'sunset': '2026-01-06T16:50:00Z',
'daylight_minutes': 485,
'solar_noon': '2026-01-06T12:47:30Z',
},
'air_quality': {
'aqi': 42,
'quality': 'Good',
'pm25': 8.5,
'pm10': 15.0,
'o3': 32.0,
},
'updated_at': '2026-01-06T10:30:00Z',
'user': 'default',
};
/// Environment data without air quality (for conditional rendering test).
static const environmentNoAirQuality = {
'weather': {
'temperature': 8.5,
'conditions': 'Partly Cloudy',
'humidity': 72,
'wind_speed': 18.5,
'location': 'Rotterdam, NL',
},
'forecast': [
{
'date': '2026-01-06',
'high': 10.0,
'low': 4.0,
'conditions': 'Cloudy',
},
],
'sun_times': {
'sunrise': '2026-01-06T08:45:00Z',
'sunset': '2026-01-06T16:50:00Z',
'daylight_minutes': 485,
},
'air_quality': null,
'updated_at': '2026-01-06T10:30:00Z',
'user': 'default',
};
}
+10
View File
@@ -172,6 +172,16 @@ class TestHarness {
api.whenGet('/tools/system/stats', stats ?? Fixtures.systemStats);
}
/// Set up mock environment response.
void givenEnvironment([Map<String, dynamic>? environment]) {
api.whenGet('/tools/environment', environment ?? Fixtures.environment);
}
/// Set up mock environment response without air quality.
void givenEnvironmentNoAirQuality() {
api.whenGet('/tools/environment', Fixtures.environmentNoAirQuality);
}
/// Set up theme mode.
void givenThemeMode(ThemeMode mode) {
_themeMode = mode;