diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e5c7e..2e2801c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.3] - 2026-01-07 + +### Changed + +- **Sun position arc redesign** - Arc now touches horizon at sunrise/sunset points + - Arc geometry uses chord-radius calculation for proper horizon intersection + - Sunrise/sunset icons integrated at horizon endpoints (no duplicate labels) + - Daylight duration centered below arc + - Increased card height 20% for better arc visibility (140px → 170px) +- **Visual balance improvements** - Sunrise/sunset labels raised 10px, daylight label at bottom + ## [1.5.2] - 2026-01-07 ### Changed diff --git a/lib/shared/widgets/air_quality_widget.dart b/lib/shared/widgets/air_quality_widget.dart index 340e087..8e82311 100644 --- a/lib/shared/widgets/air_quality_widget.dart +++ b/lib/shared/widgets/air_quality_widget.dart @@ -210,28 +210,30 @@ class AirQualityWidget extends StatelessWidget { ), ], ), - // Match height of populated card content area - const SizedBox(height: 120), - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - 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: 8), + // Match sun position card height (170px content area) + SizedBox( + height: 170, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + 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: 40), ], ), ), diff --git a/lib/shared/widgets/forecast_widget.dart b/lib/shared/widgets/forecast_widget.dart index 8c7683c..cb62614 100644 --- a/lib/shared/widgets/forecast_widget.dart +++ b/lib/shared/widgets/forecast_widget.dart @@ -48,28 +48,30 @@ class ForecastWidget extends StatelessWidget { ), ], ), - // Match height of populated card content area - const SizedBox(height: 120), - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.event_busy_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: 8), + // Match sun position card height (170px content area) + SizedBox( + height: 170, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.event_busy_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: 40), ], ), ), diff --git a/lib/shared/widgets/sun_position_widget.dart b/lib/shared/widgets/sun_position_widget.dart index 114418f..32fe1c3 100644 --- a/lib/shared/widgets/sun_position_widget.dart +++ b/lib/shared/widgets/sun_position_widget.dart @@ -119,41 +119,63 @@ class _SunPositionWidgetState extends State { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), - // Arc visualization + // Arc with integrated horizon labels SizedBox( - height: 120, - child: CustomPaint( - size: const Size(double.infinity, 120), - painter: _SunArcPainter( - sunTimes: widget.sunTimes, - isDark: colorScheme.brightness == Brightness.dark, - ), + height: 170, + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + clipBehavior: Clip.none, + children: [ + // Arc painter + Positioned.fill( + child: CustomPaint( + painter: _SunArcPainter( + sunTimes: widget.sunTimes, + isDark: colorScheme.brightness == Brightness.dark, + ), + ), + ), + // Sunrise widget at left horizon (10px up for balance) + Positioned( + left: 0, + bottom: 10, + child: _HorizonTimeDisplay( + icon: Icons.wb_twilight, + label: 'Sunrise', + time: _sunrise, + iconColor: Colors.orange, + alignment: CrossAxisAlignment.start, + ), + ), + // Daylight display centered (at bottom for balance) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Center( + child: _DaylightDisplay(minutes: _daylightMinutes), + ), + ), + // Sunset widget at right horizon (10px up for balance) + Positioned( + right: 0, + bottom: 10, + child: _HorizonTimeDisplay( + icon: Icons.nights_stay, + label: 'Sunset', + time: _sunset, + iconColor: Colors.deepOrange, + alignment: CrossAxisAlignment.end, + ), + ), + ], + ); + }, ), ), - - const SizedBox(height: 12), - - // Sunrise/Sunset times - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _TimeDisplay( - icon: Icons.wb_twilight, - label: 'Sunrise', - time: _sunrise, - iconColor: Colors.orange, - ), - _DaylightDisplay(minutes: _daylightMinutes), - _TimeDisplay( - icon: Icons.nights_stay, - label: 'Sunset', - time: _sunset, - iconColor: Colors.deepOrange, - ), - ], - ), ], ), ), @@ -198,34 +220,39 @@ class _SunPositionWidgetState extends State { } } -class _TimeDisplay extends StatelessWidget { - const _TimeDisplay({ +/// Horizon-aligned time display with icon above time and label. +class _HorizonTimeDisplay extends StatelessWidget { + const _HorizonTimeDisplay({ required this.icon, required this.label, required this.time, this.iconColor, + this.alignment = CrossAxisAlignment.center, }); final IconData icon; final String label; final DateTime time; final Color? iconColor; + final CrossAxisAlignment alignment; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: alignment, children: [ Icon( icon, - size: 20, + size: 18, color: iconColor ?? colorScheme.primary, ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', - style: Theme.of(context).textTheme.titleSmall?.copyWith( + style: Theme.of(context).textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w600, ), ), @@ -233,6 +260,7 @@ class _TimeDisplay extends StatelessWidget { label, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: colorScheme.onSurfaceVariant, + fontSize: 10, ), ), ], @@ -280,7 +308,7 @@ class _DaylightDisplay extends StatelessWidget { /// The arc is proportional to the day/night duration: /// - During day: shows a day arc spanning (daylight hours / 24) × 360° /// - During night: shows a night arc spanning (night hours / 24) × 360° -/// - Horizon points (left/right) represent sunrise/sunset times +/// - Arc endpoints touch the horizon line at sunrise/sunset positions class _SunArcPainter extends CustomPainter { _SunArcPainter({ this.sunTimes, @@ -316,59 +344,71 @@ class _SunArcPainter extends CustomPainter { final isDaytime = now.isAfter(sunrise) && now.isBefore(sunset); // Calculate arc angles (proportional to duration) - // Day arc sweeps upward (above horizon), night arc sweeps downward final dayArcAngle = (daylightMinutes / 1440) * 2 * math.pi; final nightArcAngle = (nightMinutes / 1440) * 2 * math.pi; - final center = Offset(size.width / 2, size.height - 10); - final radius = math.min(size.width / 2 - 20, size.height - 30); + // Layout constants - horizon is 50px from bottom to leave room for labels + const horizonY = 50.0; + const horizontalPadding = 40.0; + final horizonWidth = size.width - (horizontalPadding * 2); + final centerX = size.width / 2; // 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), + Offset(horizontalPadding - 5, size.height - horizonY), + Offset(size.width - horizontalPadding + 5, size.height - horizonY), horizonPaint, ); + if (isDaytime) { + _drawDayArc(canvas, size, centerX, horizonWidth, horizonY, dayArcAngle, now, sunrise, sunset); + } else { + _drawNightArc(canvas, size, centerX, horizonWidth, horizonY, nightArcAngle, now, sunrise, sunset); + } + } + + void _drawDayArc( + Canvas canvas, + Size size, + double centerX, + double horizonWidth, + double horizonY, + double arcAngle, + DateTime now, + DateTime sunrise, + DateTime sunset, + ) { + // Clamp arc angle between 30° and 180° for visual sanity + final clampedAngle = arcAngle.clamp(math.pi / 6, math.pi); + + // Calculate radius so arc endpoints touch horizon + // For a chord of width W and arc angle θ: R = W / (2 * sin(θ/2)) + final radius = horizonWidth / (2 * math.sin(clampedAngle / 2)); + + // Arc center is below the horizon for an upward-bulging arc + // Distance from chord to center = R * cos(θ/2) + final horizonYPos = size.height - horizonY; + final centerY = horizonYPos + radius * math.cos(clampedAngle / 2); + final center = Offset(centerX, centerY); + + // The arc starts at the LEFT horizon point + // In canvas coords (Y down), angle to left point = 3π/2 - θ/2 + // The arc sweeps clockwise (positive) through angle θ to reach right point + final startAngle = (3 * math.pi / 2) - (clampedAngle / 2); + final arcRect = Rect.fromCenter( center: center, width: radius * 2, height: radius * 2, ); - if (isDaytime) { - _drawDayArc(canvas, arcRect, center, radius, dayArcAngle, now, sunrise, sunset); - } else { - _drawNightArc(canvas, arcRect, center, radius, nightArcAngle, now, sunrise, sunset); - } - - // Draw sunrise/sunset time labels at horizon points - _drawHorizonLabels(canvas, center, radius, sunrise, sunset); - } - - void _drawDayArc( - Canvas canvas, - Rect arcRect, - Offset center, - double radius, - double arcAngle, - DateTime now, - DateTime sunrise, - DateTime sunset, - ) { - // Day arc: starts at left horizon, sweeps upward - // Arc starts at π (left) and sweeps toward 0 (right) - // But the sweep angle is proportional to daylight duration - final startAngle = math.pi; - final sweepAngle = arcAngle.clamp(0.0, math.pi); // Cap at semicircle for visual - // Draw arc background with day gradient final arcPaint = Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 8 + ..strokeWidth = 6 ..strokeCap = StrokeCap.round ..shader = SweepGradient( center: Alignment.center, @@ -383,18 +423,19 @@ class _SunArcPainter extends CustomPainter { _duskOrange.withValues(alpha: 0.6), ], stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0], - transform: const GradientRotation(math.pi), + transform: GradientRotation(startAngle), ).createShader(arcRect); - canvas.drawArc(arcRect, startAngle, sweepAngle, false, arcPaint); + // Draw the arc (positive sweep = clockwise in canvas coords = visually upward arc) + canvas.drawArc(arcRect, startAngle, clampedAngle, false, arcPaint); // Calculate sun position along the arc final totalDaylight = sunset.difference(sunrise).inMinutes; final minutesSinceSunrise = now.difference(sunrise).inMinutes; final progress = (minutesSinceSunrise / totalDaylight).clamp(0.0, 1.0); - // Map progress to angle along the arc - final sunAngle = startAngle + (progress * sweepAngle); + // Map progress to angle along the arc (0 = sunrise/left, 1 = sunset/right) + final sunAngle = startAngle + (progress * clampedAngle); final sunX = center.dx + radius * math.cos(sunAngle); final sunY = center.dy + radius * math.sin(sunAngle); @@ -410,14 +451,14 @@ class _SunArcPainter extends CustomPainter { 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); + ).createShader(Rect.fromCircle(center: Offset(sunX, sunY), radius: 20)); + canvas.drawCircle(Offset(sunX, sunY), 20, glowPaint); // Draw sun final sunPaint = Paint() ..style = PaintingStyle.fill ..color = sunColor; - canvas.drawCircle(Offset(sunX, sunY), 12, sunPaint); + canvas.drawCircle(Offset(sunX, sunY), 10, sunPaint); // Draw sun rays final rayPaint = Paint() @@ -428,8 +469,8 @@ class _SunArcPainter extends CustomPainter { for (var i = 0; i < 8; i++) { final rayAngle = (i * math.pi / 4); canvas.drawLine( - Offset(sunX + 14 * math.cos(rayAngle), sunY + 14 * math.sin(rayAngle)), - Offset(sunX + 18 * math.cos(rayAngle), sunY + 18 * math.sin(rayAngle)), + Offset(sunX + 12 * math.cos(rayAngle), sunY + 12 * math.sin(rayAngle)), + Offset(sunX + 16 * math.cos(rayAngle), sunY + 16 * math.sin(rayAngle)), rayPaint, ); } @@ -437,23 +478,40 @@ class _SunArcPainter extends CustomPainter { void _drawNightArc( Canvas canvas, - Rect arcRect, - Offset center, - double radius, + Size size, + double centerX, + double horizonWidth, + double horizonY, double arcAngle, DateTime now, DateTime sunrise, DateTime sunset, ) { - // Night arc: sweeps below the horizon (or shown inverted above) - // For visual clarity, we show it as an arc above horizon but with night colors - final startAngle = math.pi; - final sweepAngle = arcAngle.clamp(0.0, math.pi); // Cap at semicircle + // Clamp arc angle between 30° and 180° for visual sanity + final clampedAngle = arcAngle.clamp(math.pi / 6, math.pi); + + // Calculate radius so arc endpoints touch horizon + final radius = horizonWidth / (2 * math.sin(clampedAngle / 2)); + + // Arc center is below the horizon for an upward-bulging arc + final horizonYPos = size.height - horizonY; + final centerY = horizonYPos + radius * math.cos(clampedAngle / 2); + final center = Offset(centerX, centerY); + + // The arc starts at the LEFT horizon point + // In canvas coords (Y down), angle to left point = 3π/2 - θ/2 + final startAngle = (3 * math.pi / 2) - (clampedAngle / 2); + + final arcRect = Rect.fromCenter( + center: center, + width: radius * 2, + height: radius * 2, + ); // Draw arc background with night gradient final arcPaint = Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 8 + ..strokeWidth = 6 ..strokeCap = StrokeCap.round ..shader = SweepGradient( center: Alignment.center, @@ -468,10 +526,11 @@ class _SunArcPainter extends CustomPainter { _dawnOrange.withValues(alpha: 0.4), ], stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0], - transform: const GradientRotation(math.pi), + transform: GradientRotation(startAngle), ).createShader(arcRect); - canvas.drawArc(arcRect, startAngle, sweepAngle, false, arcPaint); + // Draw the arc (positive sweep = clockwise in canvas coords) + canvas.drawArc(arcRect, startAngle, clampedAngle, false, arcPaint); // Calculate moon position // Night spans from sunset to next sunrise @@ -490,8 +549,8 @@ class _SunArcPainter extends CustomPainter { progress = (minutesSincePrevSunset / nightDuration).clamp(0.0, 1.0); } - // Map progress to angle along the arc - final moonAngle = startAngle + (progress * sweepAngle); + // Map progress to angle along the arc (0 = sunset/left, 1 = sunrise/right) + final moonAngle = startAngle + (progress * clampedAngle); final moonX = center.dx + radius * math.cos(moonAngle); final moonY = center.dy + radius * math.sin(moonAngle); @@ -502,56 +561,20 @@ class _SunArcPainter extends CustomPainter { _moonSilver.withValues(alpha: 0.3), _moonSilver.withValues(alpha: 0.0), ], - ).createShader(Rect.fromCircle(center: Offset(moonX, moonY), radius: 20)); - canvas.drawCircle(Offset(moonX, moonY), 20, glowPaint); + ).createShader(Rect.fromCircle(center: Offset(moonX, moonY), radius: 16)); + canvas.drawCircle(Offset(moonX, moonY), 16, glowPaint); // Draw moon final moonPaint = Paint() ..style = PaintingStyle.fill ..color = _moonSilver; - canvas.drawCircle(Offset(moonX, moonY), 10, moonPaint); + canvas.drawCircle(Offset(moonX, moonY), 8, moonPaint); // Draw crescent shadow for moon effect final shadowPaint = Paint() ..style = PaintingStyle.fill ..color = _nightBlue.withValues(alpha: 0.7); - canvas.drawCircle(Offset(moonX + 4, moonY - 2), 8, shadowPaint); - } - - void _drawHorizonLabels( - Canvas canvas, - Offset center, - double radius, - DateTime sunrise, - DateTime sunset, - ) { - final textPainter = TextPainter( - textDirection: TextDirection.ltr, - textAlign: TextAlign.center, - ); - - final labelStyle = TextStyle( - fontSize: 10, - color: isDark ? Colors.white38 : Colors.black38, - ); - - // Sunrise label (left horizon point) - final sunriseText = '${sunrise.hour.toString().padLeft(2, '0')}:${sunrise.minute.toString().padLeft(2, '0')}'; - textPainter.text = TextSpan(text: sunriseText, style: labelStyle); - textPainter.layout(); - textPainter.paint( - canvas, - Offset(center.dx - radius - textPainter.width / 2, center.dy + 5), - ); - - // Sunset label (right horizon point) - final sunsetText = '${sunset.hour.toString().padLeft(2, '0')}:${sunset.minute.toString().padLeft(2, '0')}'; - textPainter.text = TextSpan(text: sunsetText, style: labelStyle); - textPainter.layout(); - textPainter.paint( - canvas, - Offset(center.dx + radius - textPainter.width / 2, center.dy + 5), - ); + canvas.drawCircle(Offset(moonX + 3, moonY - 1), 6, shadowPaint); } @override diff --git a/lib/shared/widgets/weather_widget.dart b/lib/shared/widgets/weather_widget.dart index ee0c3d4..3fad334 100644 --- a/lib/shared/widgets/weather_widget.dart +++ b/lib/shared/widgets/weather_widget.dart @@ -212,28 +212,30 @@ class WeatherWidget extends StatelessWidget { ), ], ), - // Match height of populated card content area - const SizedBox(height: 120), - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - 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: 8), + // Match sun position card height (170px content area) + SizedBox( + height: 170, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + 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: 40), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index cdfafdb..66df985 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.2+1 +version: 1.5.3+1 environment: sdk: ^3.10.4