feat: environment widgets layout redesign with always-visible widgets
- Desktop: 4-in-a-row layout (30/20/20/30 distribution) - Tablet: 2x2 grid layout - Mobile: Stacked vertically - All widgets show "No data available" state instead of being hidden - Sun position calculates from clock, defaults to 6am/6pm - Environment refresh changed from 5min to 1 hour 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
fffc3d5baf
commit
d200cad8be
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -34,9 +34,9 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
|
||||
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),
|
||||
],
|
||||
],
|
||||
);
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user