diff --git a/CHANGELOG.md b/CHANGELOG.md index 3319630..c963f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.0] - 2026-01-06 + +### Added + +- **Dynamic Environment Widgets** - Live weather, sun position, and forecast data from Qdrant + - `SunPositionWidget` - Animated semicircle arc showing sun/moon position based on current time + - Gradient colors: yellow for daytime, orange for sunrise/sunset, blue for night + - Displays sunrise, sunset times and daylight duration + - `WeatherWidget` - Current temperature, conditions, humidity from API + - `ForecastWidget` - Multi-day weather forecast with conditions icons + - `AirQualityWidget` - AQI display (only shown when data available) +- Environment data provider with auto-refresh every 5 minutes +- Environment datasource calling `GET /tools/environment` +- Freezed models for environment data (weather, forecast, sun times, air quality) +- Comprehensive widget tests for environment section + +### Changed + +- Dashboard layout now displays dynamic environment data instead of static widgets +- Weather and air quality widgets accept optional API data parameters + ## [1.4.0] - 2026-01-05 ### Added diff --git a/lib/features/front_hall/data/datasources/environment_datasource.dart b/lib/features/front_hall/data/datasources/environment_datasource.dart new file mode 100644 index 0000000..4953b1a --- /dev/null +++ b/lib/features/front_hall/data/datasources/environment_datasource.dart @@ -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 getEnvironment() async { + final response = await _dio.get>(_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); +} diff --git a/lib/features/front_hall/data/models/environment_model.dart b/lib/features/front_hall/data/models/environment_model.dart new file mode 100644 index 0000000..5ee17dd --- /dev/null +++ b/lib/features/front_hall/data/models/environment_model.dart @@ -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? 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 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 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 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 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 json) => + _$AirQualityDataFromJson(json); +} diff --git a/lib/features/front_hall/presentation/providers/environment_provider.dart b/lib/features/front_hall/presentation/providers/environment_provider.dart new file mode 100644 index 0000000..09f7446 --- /dev/null +++ b/lib/features/front_hall/presentation/providers/environment_provider.dart @@ -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 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, + ); +} diff --git a/lib/features/front_hall/presentation/widgets/dashboard_content.dart b/lib/features/front_hall/presentation/widgets/dashboard_content.dart index 6b09f8a..df9591b 100644 --- a/lib/features/front_hall/presentation/widgets/dashboard_content.dart +++ b/lib/features/front_hall/presentation/widgets/dashboard_content.dart @@ -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 { - 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 { 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 { ), 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), + ], + ], + ); + }, + ); + } +} diff --git a/lib/shared/widgets/air_quality_widget.dart b/lib/shared/widgets/air_quality_widget.dart index da89ab5..300f344 100644 --- a/lib/shared/widgets/air_quality_widget.dart +++ b/lib/shared/widgets/air_quality_widget.dart @@ -1,17 +1,24 @@ 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. /// -/// Currently uses mock data. Will be connected to air quality API in future. +/// 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 to display. Uses mock data if null. + /// 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. @@ -20,7 +27,11 @@ class AirQualityWidget extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final aqi = data ?? AirQualityData.mock(); + + // 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); @@ -199,6 +210,31 @@ class _PollutantChip extends StatelessWidget { } } +/// Converts API air quality data to local AirQualityData model. +AirQualityData _fromApiData(api.AirQualityData data) { + final index = data.aqi ?? 0; + final pollutants = []; + + 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( diff --git a/lib/shared/widgets/forecast_widget.dart b/lib/shared/widgets/forecast_widget.dart new file mode 100644 index 0000000..f86fb60 --- /dev/null +++ b/lib/shared/widgets/forecast_widget.dart @@ -0,0 +1,305 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart'; + +/// Forecast widget displaying multi-day weather outlook. +/// +/// Shows a horizontal scrollable list of forecast days with +/// high/low temperatures and weather icons. +class ForecastWidget extends StatelessWidget { + const ForecastWidget({ + super.key, + this.forecast, + this.compact = false, + }); + + /// Forecast data to display. + final List? forecast; + + /// Whether to use compact layout. + final bool compact; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (forecast == null || forecast!.isEmpty) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Text( + 'No forecast data available', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + } + + if (compact) { + return _buildCompact(context, colorScheme); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + Icon( + Icons.calendar_today, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Forecast', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Forecast days - horizontal scroll + SizedBox( + height: 100, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: forecast!.length, + separatorBuilder: (_, i) => const SizedBox(width: 12), + itemBuilder: (context, index) { + return _ForecastDayCard(day: forecast![index]); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildCompact(BuildContext context, ColorScheme colorScheme) { + // Show just first 3 days in compact mode + final days = forecast!.take(3).toList(); + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_today, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + ...days.map((day) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _getDayName(day.date), + style: Theme.of(context).textTheme.labelSmall, + ), + Icon( + _getWeatherIcon(day.conditions), + size: 16, + color: _getWeatherColor(day.conditions), + ), + Text( + '${day.high?.round() ?? '--'}°', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + )), + ], + ), + ), + ); + } + + String _getDayName(String dateStr) { + try { + final date = DateTime.parse(dateStr); + final now = DateTime.now(); + if (date.day == now.day && + date.month == now.month && + date.year == now.year) { + return 'Today'; + } + final tomorrow = now.add(const Duration(days: 1)); + if (date.day == tomorrow.day && + date.month == tomorrow.month && + date.year == tomorrow.year) { + return 'Tmrw'; + } + return DateFormat('E').format(date); + } catch (_) { + return dateStr.length > 3 ? dateStr.substring(0, 3) : dateStr; + } + } + + IconData _getWeatherIcon(String? conditions) { + final condition = conditions?.toLowerCase() ?? ''; + + if (condition.contains('clear') || condition.contains('sunny')) { + return Icons.wb_sunny; + } else if (condition.contains('cloud') || condition.contains('overcast')) { + return Icons.cloud; + } else if (condition.contains('rain') || condition.contains('drizzle')) { + return Icons.grain; + } else if (condition.contains('storm') || condition.contains('thunder')) { + return Icons.thunderstorm; + } else if (condition.contains('snow') || condition.contains('sleet')) { + return Icons.ac_unit; + } else if (condition.contains('fog') || condition.contains('mist')) { + return Icons.blur_on; + } + return Icons.cloud; + } + + Color? _getWeatherColor(String? conditions) { + final condition = conditions?.toLowerCase() ?? ''; + + if (condition.contains('clear') || condition.contains('sunny')) { + return Colors.amber; + } else if (condition.contains('cloud')) { + return Colors.blueGrey; + } else if (condition.contains('rain')) { + return Colors.blue; + } else if (condition.contains('storm')) { + return Colors.deepPurple; + } else if (condition.contains('snow')) { + return Colors.lightBlue; + } + return null; + } +} + +class _ForecastDayCard extends StatelessWidget { + const _ForecastDayCard({required this.day}); + + final ForecastDay day; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final icon = _getWeatherIcon(day.conditions); + final color = _getWeatherColor(day.conditions) ?? colorScheme.primary; + + return Container( + width: 72, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Day name + Text( + _getDayName(day.date), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + + // Weather icon + Icon( + icon, + size: 24, + color: color, + ), + + // High/Low temps + Column( + children: [ + Text( + '${day.high?.round() ?? '--'}°', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + '${day.low?.round() ?? '--'}°', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ); + } + + String _getDayName(String dateStr) { + try { + final date = DateTime.parse(dateStr); + final now = DateTime.now(); + if (date.day == now.day && + date.month == now.month && + date.year == now.year) { + return 'Today'; + } + final tomorrow = now.add(const Duration(days: 1)); + if (date.day == tomorrow.day && + date.month == tomorrow.month && + date.year == tomorrow.year) { + return 'Tmrw'; + } + return DateFormat('EEE').format(date); + } catch (_) { + return dateStr.length > 3 ? dateStr.substring(0, 3) : dateStr; + } + } + + IconData _getWeatherIcon(String? conditions) { + final condition = conditions?.toLowerCase() ?? ''; + + if (condition.contains('clear') || condition.contains('sunny')) { + return Icons.wb_sunny; + } else if (condition.contains('cloud') || condition.contains('overcast')) { + return Icons.cloud; + } else if (condition.contains('rain') || condition.contains('drizzle')) { + return Icons.grain; + } else if (condition.contains('storm') || condition.contains('thunder')) { + return Icons.thunderstorm; + } else if (condition.contains('snow') || condition.contains('sleet')) { + return Icons.ac_unit; + } else if (condition.contains('fog') || condition.contains('mist')) { + return Icons.blur_on; + } + return Icons.cloud; + } + + Color? _getWeatherColor(String? conditions) { + final condition = conditions?.toLowerCase() ?? ''; + + if (condition.contains('clear') || condition.contains('sunny')) { + return Colors.amber; + } else if (condition.contains('cloud')) { + return Colors.blueGrey; + } else if (condition.contains('rain')) { + return Colors.blue; + } else if (condition.contains('storm')) { + return Colors.deepPurple; + } else if (condition.contains('snow')) { + return Colors.lightBlue; + } + return null; + } +} diff --git a/lib/shared/widgets/sun_position_widget.dart b/lib/shared/widgets/sun_position_widget.dart new file mode 100644 index 0000000..2bc5869 --- /dev/null +++ b/lib/shared/widgets/sun_position_widget.dart @@ -0,0 +1,442 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart'; + +/// Sun position widget displaying sunrise/sunset with animated arc. +/// +/// Shows a semicircle arc representing the day, with sun/moon icon +/// moving along based on current time. Uses color gradients: +/// - Blue tones for night +/// - Orange tones for sunrise/sunset +/// - Yellow tones for daytime +class SunPositionWidget extends StatelessWidget { + const SunPositionWidget({ + super.key, + this.sunTimes, + this.compact = false, + }); + + /// Sun times data to display. + final SunTimesData? sunTimes; + + /// Whether to use compact layout. + final bool compact; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (compact) { + return _buildCompact(context, colorScheme); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + Icon( + _isDaytime ? Icons.wb_sunny : Icons.nightlight_round, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Sun Position', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Arc visualization + SizedBox( + height: 120, + child: CustomPaint( + size: const Size(double.infinity, 120), + painter: _SunArcPainter( + sunTimes: sunTimes, + isDark: colorScheme.brightness == Brightness.dark, + ), + ), + ), + + const SizedBox(height: 12), + + // Sunrise/Sunset times + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _TimeDisplay( + icon: Icons.wb_twilight, + label: 'Sunrise', + time: sunTimes?.sunrise, + iconColor: Colors.orange, + ), + if (sunTimes?.daylightMinutes != null) + _DaylightDisplay(minutes: sunTimes!.daylightMinutes!), + _TimeDisplay( + icon: Icons.nights_stay, + label: 'Sunset', + time: sunTimes?.sunset, + iconColor: Colors.deepOrange, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildCompact(BuildContext context, ColorScheme colorScheme) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _isDaytime ? Icons.wb_sunny : Icons.nightlight_round, + size: 24, + color: _isDaytime ? Colors.amber : Colors.indigo, + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatTime(sunTimes?.sunrise), + style: Theme.of(context).textTheme.labelSmall, + ), + Text( + _formatTime(sunTimes?.sunset), + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + ); + } + + bool get _isDaytime { + if (sunTimes?.sunrise == null || sunTimes?.sunset == null) return true; + final now = DateTime.now(); + return now.isAfter(sunTimes!.sunrise!) && now.isBefore(sunTimes!.sunset!); + } + + String _formatTime(DateTime? time) { + if (time == null) return '--:--'; + return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + } +} + +class _TimeDisplay extends StatelessWidget { + const _TimeDisplay({ + required this.icon, + required this.label, + required this.time, + this.iconColor, + }); + + final IconData icon; + final String label; + final DateTime? time; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + Icon( + icon, + size: 20, + color: iconColor ?? colorScheme.primary, + ), + const SizedBox(height: 4), + Text( + _formatTime(time), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + String _formatTime(DateTime? time) { + if (time == null) return '--:--'; + return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + } +} + +class _DaylightDisplay extends StatelessWidget { + const _DaylightDisplay({required this.minutes}); + + final int minutes; + + @override + Widget build(BuildContext context) { + final hours = minutes ~/ 60; + final mins = minutes % 60; + + return Column( + children: [ + Icon( + Icons.access_time, + size: 16, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 4), + Text( + '${hours}h ${mins}m', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + 'Daylight', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } +} + +/// Custom painter for the sun arc visualization. +class _SunArcPainter extends CustomPainter { + _SunArcPainter({ + this.sunTimes, + this.isDark = false, + }); + + final SunTimesData? sunTimes; + final bool isDark; + + // Color palette + static const _nightBlue = Color(0xFF1a237e); + static const _dawnOrange = Color(0xFFff6f00); + static const _sunriseOrange = Color(0xFFffa000); + static const _dayYellow = Color(0xFFffc107); + static const _dayGold = Color(0xFFffb300); + static const _duskOrange = Color(0xFFef6c00); + static const _nightIndigo = Color(0xFF283593); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height - 10); + final radius = math.min(size.width / 2 - 20, size.height - 30); + + // Draw horizon line + final horizonPaint = Paint() + ..color = isDark ? Colors.white24 : Colors.black12 + ..strokeWidth = 1; + canvas.drawLine( + Offset(center.dx - radius - 10, center.dy), + Offset(center.dx + radius + 10, center.dy), + horizonPaint, + ); + + // Create gradient for arc background + final arcRect = Rect.fromCenter( + center: center, + width: radius * 2, + height: radius * 2, + ); + + // Draw arc background (night portion below horizon implied) + final arcBgPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 8 + ..strokeCap = StrokeCap.round; + + // Create gradient that transitions through time of day colors + arcBgPaint.shader = SweepGradient( + center: Alignment.bottomCenter, + startAngle: math.pi, + endAngle: 2 * math.pi, + colors: [ + _nightBlue.withValues(alpha: 0.3), + _dawnOrange.withValues(alpha: 0.5), + _sunriseOrange.withValues(alpha: 0.6), + _dayYellow.withValues(alpha: 0.7), + _dayGold.withValues(alpha: 0.7), + _dayYellow.withValues(alpha: 0.6), + _duskOrange.withValues(alpha: 0.5), + _nightIndigo.withValues(alpha: 0.3), + ], + stops: const [0.0, 0.1, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0], + transform: const GradientRotation(-math.pi / 2), + ).createShader(arcRect); + + // Draw the arc (semicircle from left to right) + canvas.drawArc( + arcRect, + math.pi, // Start at left (180 degrees) + math.pi, // Sweep 180 degrees (semicircle) + false, + arcBgPaint, + ); + + // Calculate sun position + final sunPosition = _calculateSunPosition(); + + // Draw sun/moon indicator + final angle = math.pi + (sunPosition * math.pi); // Map 0-1 to pi-2pi + final sunX = center.dx + radius * math.cos(angle); + final sunY = center.dy + radius * math.sin(angle); + + // Determine if day or night for icon/color + final isDaytime = sunPosition > 0 && sunPosition < 1; + final isNearHorizon = sunPosition < 0.15 || sunPosition > 0.85; + + // Sun/moon glow + if (isDaytime) { + final glowColor = isNearHorizon ? _dawnOrange : _dayYellow; + final glowPaint = Paint() + ..shader = RadialGradient( + colors: [ + glowColor.withValues(alpha: 0.6), + glowColor.withValues(alpha: 0.0), + ], + ).createShader( + Rect.fromCircle(center: Offset(sunX, sunY), radius: 24), + ); + canvas.drawCircle(Offset(sunX, sunY), 24, glowPaint); + } + + // Sun/moon circle + final sunPaint = Paint() + ..style = PaintingStyle.fill + ..color = isDaytime + ? (isNearHorizon ? _sunriseOrange : _dayYellow) + : _nightIndigo; + + canvas.drawCircle(Offset(sunX, sunY), 12, sunPaint); + + // Sun rays or moon craters + if (isDaytime) { + final rayPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = (isNearHorizon ? _dawnOrange : _dayGold).withValues(alpha: 0.8); + + for (var i = 0; i < 8; i++) { + final rayAngle = (i * math.pi / 4); + final innerRadius = 14.0; + final outerRadius = 18.0; + canvas.drawLine( + Offset( + sunX + innerRadius * math.cos(rayAngle), + sunY + innerRadius * math.sin(rayAngle), + ), + Offset( + sunX + outerRadius * math.cos(rayAngle), + sunY + outerRadius * math.sin(rayAngle), + ), + rayPaint, + ); + } + } else { + // Moon highlight + final moonHighlight = Paint() + ..style = PaintingStyle.fill + ..color = Colors.white24; + canvas.drawCircle(Offset(sunX - 3, sunY - 3), 4, moonHighlight); + } + + // Draw time markers on arc + _drawTimeMarkers(canvas, center, radius); + } + + double _calculateSunPosition() { + if (sunTimes?.sunrise == null || sunTimes?.sunset == null) { + // Default to noon position + return 0.5; + } + + final now = DateTime.now(); + final sunrise = sunTimes!.sunrise!; + final sunset = sunTimes!.sunset!; + + // Before sunrise + if (now.isBefore(sunrise)) { + // Calculate position in pre-dawn (negative values = below horizon) + final midnight = DateTime(now.year, now.month, now.day); + final minutesSinceMidnight = now.difference(midnight).inMinutes; + final minutesToSunrise = sunrise.difference(midnight).inMinutes; + // Map to 0 at sunrise, negative before + return (minutesSinceMidnight / minutesToSunrise) * 0.15 - 0.1; + } + + // After sunset + if (now.isAfter(sunset)) { + // Calculate position in post-dusk + final minutesSinceSunset = now.difference(sunset).inMinutes; + final nextMidnight = DateTime(now.year, now.month, now.day + 1); + final minutesToMidnight = nextMidnight.difference(sunset).inMinutes; + // Map to 1 at sunset, going towards negative + return 1.0 + (minutesSinceSunset / minutesToMidnight) * 0.1; + } + + // During daylight hours + final totalDaylight = sunset.difference(sunrise).inMinutes; + final minutesSinceSunrise = now.difference(sunrise).inMinutes; + return minutesSinceSunrise / totalDaylight; + } + + void _drawTimeMarkers(Canvas canvas, Offset center, double radius) { + final textPainter = TextPainter( + textDirection: TextDirection.ltr, + textAlign: TextAlign.center, + ); + + final markerStyle = TextStyle( + fontSize: 10, + color: isDark ? Colors.white38 : Colors.black38, + ); + + // Draw quarter markers (6am, 12pm, 6pm positions conceptually) + final markers = ['6:00', '12:00', '18:00']; + final positions = [0.25, 0.5, 0.75]; + + for (var i = 0; i < markers.length; i++) { + final angle = math.pi + (positions[i] * math.pi); + final markerRadius = radius + 15; + final x = center.dx + markerRadius * math.cos(angle); + final y = center.dy + markerRadius * math.sin(angle); + + textPainter.text = TextSpan(text: markers[i], style: markerStyle); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + } + + @override + bool shouldRepaint(covariant _SunArcPainter oldDelegate) { + return oldDelegate.sunTimes != sunTimes || oldDelegate.isDark != isDark; + } +} diff --git a/lib/shared/widgets/weather_widget.dart b/lib/shared/widgets/weather_widget.dart index 7d17673..7d84106 100644 --- a/lib/shared/widgets/weather_widget.dart +++ b/lib/shared/widgets/weather_widget.dart @@ -1,16 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart' + as api; /// Weather widget displaying current conditions. /// -/// Currently uses mock data. Will be connected to weather API in future. +/// Displays weather data from the Core API environment endpoint. class WeatherWidget extends StatelessWidget { const WeatherWidget({ super.key, + this.apiData, this.data, this.compact = false, }); - /// Weather data to display. Uses mock data if null. + /// Weather data from API. Takes priority over legacy data. + final api.WeatherData? apiData; + + /// Legacy weather data to display. Uses mock data if null. final WeatherData? data; /// Whether to use compact layout. @@ -19,7 +25,11 @@ class WeatherWidget extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final weather = data ?? WeatherData.mock(); + + // Convert API data to local model, or use legacy data + final weather = apiData != null + ? _fromApiData(apiData!) + : (data ?? WeatherData.mock()); if (compact) { return _buildCompact(context, colorScheme, weather); @@ -206,6 +216,60 @@ class _DetailChip extends StatelessWidget { } } +/// Converts API weather data to local WeatherData model. +WeatherData _fromApiData(api.WeatherData data) { + return WeatherData( + location: data.location ?? 'Unknown', + temperature: data.temperature ?? 0, + condition: data.conditions ?? 'Unknown', + icon: _getWeatherIcon(data.conditions, data.icon), + humidity: data.humidity, + windSpeed: data.windSpeed, + feelsLike: data.feelsLike, + iconColor: _getWeatherColor(data.conditions), + ); +} + +/// Maps weather condition to icon. +IconData _getWeatherIcon(String? conditions, String? iconCode) { + final condition = conditions?.toLowerCase() ?? ''; + + if (condition.contains('clear') || condition.contains('sunny')) { + return Icons.wb_sunny; + } else if (condition.contains('cloud') || condition.contains('overcast')) { + return Icons.cloud; + } else if (condition.contains('rain') || condition.contains('drizzle')) { + return Icons.grain; + } else if (condition.contains('storm') || condition.contains('thunder')) { + return Icons.thunderstorm; + } else if (condition.contains('snow') || condition.contains('sleet')) { + return Icons.ac_unit; + } else if (condition.contains('fog') || condition.contains('mist')) { + return Icons.blur_on; + } else if (condition.contains('wind')) { + return Icons.air; + } + return Icons.cloud; +} + +/// Maps weather condition to color. +Color? _getWeatherColor(String? conditions) { + final condition = conditions?.toLowerCase() ?? ''; + + if (condition.contains('clear') || condition.contains('sunny')) { + return Colors.amber; + } else if (condition.contains('cloud')) { + return Colors.blueGrey; + } else if (condition.contains('rain')) { + return Colors.blue; + } else if (condition.contains('storm')) { + return Colors.deepPurple; + } else if (condition.contains('snow')) { + return Colors.lightBlue; + } + return null; +} + /// Temperature unit for weather display. enum TemperatureUnit { celsius('C'), diff --git a/pubspec.yaml b/pubspec.yaml index c13f200..56b3ce2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.4.0+1 +version: 1.5.0+1 environment: sdk: ^3.10.4 @@ -57,6 +57,9 @@ dependencies: crypto: ^3.0.3 web: ^1.1.0 + # Internationalization + intl: ^0.19.0 + # UI flex_color_scheme: ^8.1.0 flutter_adaptive_scaffold: ^0.3.1 diff --git a/test/features/front_hall/environment_test.dart b/test/features/front_hall/environment_test.dart new file mode 100644 index 0000000..f3728c3 --- /dev/null +++ b/test/features/front_hall/environment_test.dart @@ -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 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); + }); + }); +} diff --git a/test/features/front_hall/presentation/pages/front_hall_page_test.dart b/test/features/front_hall/presentation/pages/front_hall_page_test.dart index ba23592..bb975b3 100644 --- a/test/features/front_hall/presentation/pages/front_hall_page_test.dart +++ b/test/features/front_hall/presentation/pages/front_hall_page_test.dart @@ -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(); diff --git a/test/harness/fixtures.dart b/test/harness/fixtures.dart index 3f89bdc..c473118 100644 --- a/test/harness/fixtures.dart +++ b/test/harness/fixtures.dart @@ -318,4 +318,93 @@ class Fixtures { static Map quickLink(int index) => Map.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', + }; } diff --git a/test/harness/test_harness.dart b/test/harness/test_harness.dart index 774d72d..ef1c317 100644 --- a/test/harness/test_harness.dart +++ b/test/harness/test_harness.dart @@ -172,6 +172,16 @@ class TestHarness { api.whenGet('/tools/system/stats', stats ?? Fixtures.systemStats); } + /// Set up mock environment response. + void givenEnvironment([Map? 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;