diff --git a/CHANGELOG.md b/CHANGELOG.md index c963f98..e43e912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1] - 2026-01-07 + +### Changed + +- **Environment widgets layout redesign** - All 4 widgets always visible with responsive layout + - Desktop (>900px): 4-in-a-row with 30/20/20/30 width distribution (Sun | Weather | Air Quality | Forecast) + - Tablet (600-900px): 2x2 grid layout + - Mobile (<600px): Stacked vertically + - Widgets now show "No data available" state instead of being hidden or using mock data +- Sun position widget now calculates position from system clock + - Defaults to 6am/6pm (12-hour day/night cycles) when API sun times unavailable +- Environment data refresh interval changed from 5 minutes to 1 hour + ## [1.5.0] - 2026-01-06 ### Added diff --git a/assets/art/authentik-background.png b/assets/art/authentik-background.png new file mode 100644 index 0000000..82bcffe Binary files /dev/null and b/assets/art/authentik-background.png differ diff --git a/assets/art/tatlock-butler.png b/assets/art/tatlock-butler.png new file mode 100644 index 0000000..6408d5c Binary files /dev/null and b/assets/art/tatlock-butler.png differ diff --git a/lib/features/front_hall/presentation/widgets/dashboard_content.dart b/lib/features/front_hall/presentation/widgets/dashboard_content.dart index df9591b..9e6ce5f 100644 --- a/lib/features/front_hall/presentation/widgets/dashboard_content.dart +++ b/lib/features/front_hall/presentation/widgets/dashboard_content.dart @@ -34,9 +34,9 @@ class _DashboardContentState extends ConsumerState { const Duration(seconds: 30), (_) => ref.invalidate(systemStatsProvider), ); - // Refresh environment data every 5 minutes + // Refresh environment data every hour _environmentTimer = Timer.periodic( - const Duration(minutes: 5), + const Duration(hours: 1), (_) => ref.invalidate(environmentProvider), ); } @@ -279,7 +279,12 @@ class _SystemStatsCard extends StatelessWidget { } } -/// Environment section displaying sun position, weather, forecast, and air quality. +/// Environment section displaying sun position, weather, air quality, and forecast. +/// +/// Layout: +/// - Desktop (>900px): 4 widgets in a row - Sun(30%) | Weather(20%) | AirQuality(20%) | Forecast(30%) +/// - Tablet (600-900px): 2x2 grid +/// - Mobile (<600px): Stacked vertically class _EnvironmentSection extends StatelessWidget { const _EnvironmentSection({ required this.envData, @@ -293,40 +298,73 @@ class _EnvironmentSection extends StatelessWidget { 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) + if (constraints.maxWidth > 900) { + // Desktop: 4 in a row (30/20/20/30) - wider widgets on outsides + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 30, + child: SunPositionWidget(sunTimes: envData.sunTimes), + ), + const SizedBox(width: 12), + Expanded( + flex: 20, + child: WeatherWidget(apiData: envData.weather), + ), + const SizedBox(width: 12), + Expanded( + flex: 20, + child: AirQualityWidget(apiData: envData.airQuality), + ), + const SizedBox(width: 12), + Expanded( + flex: 30, + child: ForecastWidget(forecast: envData.forecast), + ), + ], + ); + } else if (constraints.maxWidth > 600) { + // Tablet: 2x2 grid + return Column( + children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( + child: SunPositionWidget(sunTimes: envData.sunTimes), + ), + const SizedBox(width: 12), Expanded(child: WeatherWidget(apiData: envData.weather)), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: AirQualityWidget(apiData: envData.airQuality), + ), 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) ...[ + ], + ); + } else { + // Mobile: stacked vertically + return Column( + children: [ + SunPositionWidget(sunTimes: envData.sunTimes), + const SizedBox(height: 12), + WeatherWidget(apiData: envData.weather), const SizedBox(height: 12), AirQualityWidget(apiData: envData.airQuality), + const SizedBox(height: 12), + ForecastWidget(forecast: envData.forecast), ], - ], - ); + ); + } }, ); } diff --git a/lib/shared/widgets/air_quality_widget.dart b/lib/shared/widgets/air_quality_widget.dart index 300f344..168ee9b 100644 --- a/lib/shared/widgets/air_quality_widget.dart +++ b/lib/shared/widgets/air_quality_widget.dart @@ -28,10 +28,16 @@ class AirQualityWidget extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + // Show "no data" state when no API data and no legacy data + if (apiData == null && data == null) { + if (compact) { + return _buildCompactNoData(context, colorScheme); + } + return _buildNoData(context, colorScheme); + } + // Convert API data to local model, or use legacy data - final aqi = apiData != null - ? _fromApiData(apiData!) - : (data ?? AirQualityData.mock()); + final aqi = apiData != null ? _fromApiData(apiData!) : data!; if (compact) { return _buildCompact(context, colorScheme, aqi); @@ -178,6 +184,93 @@ class AirQualityWidget extends StatelessWidget { ), ); } + + Widget _buildNoData(BuildContext context, ColorScheme colorScheme) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + Icon( + Icons.air, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Air Quality', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 24), + Center( + child: Column( + children: [ + Icon( + Icons.air_outlined, + size: 32, + color: colorScheme.outline, + ), + const SizedBox(height: 8), + Text( + 'No data available', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.outline, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildCompactNoData(BuildContext context, ColorScheme colorScheme) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: colorScheme.outline.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + '--', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.outline, + ), + ), + ), + ), + const SizedBox(width: 8), + Text( + 'AQI', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.outline, + ), + ), + ], + ), + ), + ); + } } class _PollutantChip extends StatelessWidget { diff --git a/lib/shared/widgets/sun_position_widget.dart b/lib/shared/widgets/sun_position_widget.dart index 2bc5869..8f55e51 100644 --- a/lib/shared/widgets/sun_position_widget.dart +++ b/lib/shared/widgets/sun_position_widget.dart @@ -130,9 +130,13 @@ class SunPositionWidget extends StatelessWidget { } 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!); + // Use actual sun times if available, otherwise default to 6am/6pm + final sunrise = sunTimes?.sunrise ?? + DateTime(now.year, now.month, now.day, 6, 0); + final sunset = sunTimes?.sunset ?? + DateTime(now.year, now.month, now.day, 18, 0); + return now.isAfter(sunrise) && now.isBefore(sunset); } String _formatTime(DateTime? time) { @@ -370,14 +374,13 @@ class _SunArcPainter extends CustomPainter { } 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!; + + // Use actual sun times if available, otherwise default to 6am/6pm (12-hour cycles) + final sunrise = sunTimes?.sunrise ?? + DateTime(now.year, now.month, now.day, 6, 0); + final sunset = sunTimes?.sunset ?? + DateTime(now.year, now.month, now.day, 18, 0); // Before sunrise if (now.isBefore(sunrise)) { diff --git a/lib/shared/widgets/weather_widget.dart b/lib/shared/widgets/weather_widget.dart index 7d84106..f137dfe 100644 --- a/lib/shared/widgets/weather_widget.dart +++ b/lib/shared/widgets/weather_widget.dart @@ -26,10 +26,16 @@ class WeatherWidget extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + // Show "no data" state when no API data and no legacy data + if (apiData == null && data == null) { + if (compact) { + return _buildCompactNoData(context, colorScheme); + } + return _buildNoData(context, colorScheme); + } + // Convert API data to local model, or use legacy data - final weather = apiData != null - ? _fromApiData(apiData!) - : (data ?? WeatherData.mock()); + final weather = apiData != null ? _fromApiData(apiData!) : data!; if (compact) { return _buildCompact(context, colorScheme, weather); @@ -180,6 +186,82 @@ class WeatherWidget extends StatelessWidget { ), ); } + + Widget _buildNoData(BuildContext context, ColorScheme colorScheme) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + Icon( + Icons.wb_sunny_outlined, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Weather', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 24), + Center( + child: Column( + children: [ + Icon( + Icons.cloud_off_outlined, + size: 32, + color: colorScheme.outline, + ), + const SizedBox(height: 8), + Text( + 'No data available', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.outline, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildCompactNoData(BuildContext context, ColorScheme colorScheme) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cloud_off_outlined, + size: 24, + color: colorScheme.outline, + ), + const SizedBox(width: 8), + Text( + '--', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.outline, + ), + ), + ], + ), + ), + ); + } } class _DetailChip extends StatelessWidget { diff --git a/pubspec.yaml b/pubspec.yaml index 56b3ce2..f647293 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.5.0+1 +version: 1.5.1+1 environment: sdk: ^3.10.4 diff --git a/test/features/front_hall/environment_test.dart b/test/features/front_hall/environment_test.dart index f3728c3..55d7ecf 100644 --- a/test/features/front_hall/environment_test.dart +++ b/test/features/front_hall/environment_test.dart @@ -89,7 +89,8 @@ void main() { expect(find.text('Air Quality'), findsOneWidget); }); - testWidgets('hides Air Quality widget when no data', (tester) async { + testWidgets('shows Air Quality widget with no data state when data unavailable', + (tester) async { await setLargeWindowSize(tester); harness.givenAuthenticatedUser(); harness.givenQuickLinks(); @@ -99,8 +100,9 @@ void main() { await tester.pumpWidget(harness.wrap(const FrontHallPage())); await tester.pumpAndSettle(); - // Air Quality widget should not be present - expect(find.byType(AirQualityWidget), findsNothing); + // Air Quality widget should still be present, showing "no data" state + expect(find.byType(AirQualityWidget), findsOneWidget); + expect(find.text('Air Quality'), findsOneWidget); }); testWidgets('calls environment API on mount', (tester) async {