Compare commits

...
1 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 9a75fb25db feat(sun-position): redesign arc to touch horizon at endpoints
Build and Push / release (push) Successful in 23s
Build and Push / build (push) Successful in 3m13s
- Arc geometry now uses chord-radius calculation for proper horizon intersection
- Sunrise/sunset icons integrated at horizon endpoints (removed duplicate labels)
- Daylight duration centered below arc
- Increased all environment card heights 20% (140px → 170px) for better arc visibility
- Visual balance: sunrise/sunset raised 10px, daylight at bottom

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 13:29:56 +01:00
6 changed files with 231 additions and 191 deletions
+11
View File
@@ -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
+22 -20
View File
@@ -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),
],
),
),
+22 -20
View File
@@ -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),
],
),
),
+153 -130
View File
@@ -119,41 +119,63 @@ class _SunPositionWidgetState extends State<SunPositionWidget> {
),
],
),
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<SunPositionWidget> {
}
}
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
+22 -20
View File
@@ -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),
],
),
),
+1 -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.5.2+1
version: 1.5.3+1
environment:
sdk: ^3.10.4