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
+21
View File
@@ -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
@@ -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),
],
],
);
},
);
}
}
+39 -3
View File
@@ -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 = <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(
+305
View File
@@ -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<ForecastDay>? 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;
}
}
+442
View File
@@ -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;
}
}
+67 -3
View File
@@ -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'),
+4 -1
View File
@@ -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
@@ -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;