Files
tatlock-ui/lib/shared/widgets/sun_position_widget.dart
T
Jeroen SchweitzerandClaude Opus 4.5 780edd2d3b
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m27s
fix: environment widget alignment and sun position night labels
- Consistent 170px minHeight across Weather, Air Quality, Forecast widgets
- Swap sunrise/sunset labels at night to match arc direction
- Weather header shows "Weather" instead of location

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 18:52:27 +01:00

603 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
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
///
/// Position updates every 10 minutes based on system clock.
/// When sun times are unavailable, assumes 12-hour day/night cycles
/// (6am sunrise, 6pm sunset).
class SunPositionWidget extends StatefulWidget {
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
State<SunPositionWidget> createState() => _SunPositionWidgetState();
}
class _SunPositionWidgetState extends State<SunPositionWidget> {
Timer? _positionTimer;
@override
void initState() {
super.initState();
_scheduleNextUpdate();
}
/// Schedule update at next 10-minute wall clock mark (0/10/20/30/40/50)
void _scheduleNextUpdate() {
final now = DateTime.now();
final currentMinute = now.minute;
// Calculate minutes until next 10-minute mark
final minutesUntilNext = 10 - (currentMinute % 10);
final secondsUntilNext = (minutesUntilNext * 60) - now.second;
_positionTimer = Timer(
Duration(seconds: secondsUntilNext),
() {
if (mounted) {
setState(() {});
_scheduleNextUpdate();
}
},
);
}
@override
void dispose() {
_positionTimer?.cancel();
super.dispose();
}
/// Get sunrise time, defaulting to 7am if not available (asymmetric for visual effect)
DateTime get _sunrise {
final now = DateTime.now();
return widget.sunTimes?.sunrise ?? DateTime(now.year, now.month, now.day, 7, 0);
}
/// Get sunset time, defaulting to 5pm if not available (asymmetric for visual effect)
DateTime get _sunset {
final now = DateTime.now();
return widget.sunTimes?.sunset ?? DateTime(now.year, now.month, now.day, 17, 0);
}
/// Calculate daylight minutes from sunrise/sunset
int get _daylightMinutes {
return _sunset.difference(_sunrise).inMinutes;
}
bool get _isDaytime {
final now = DateTime.now();
return now.isAfter(_sunrise) && now.isBefore(_sunset);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (widget.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: 8),
// Arc with integrated horizon labels
// At night: sunset on left (night start), sunrise on right (night end)
// During day: sunrise on left (day start), sunset on right (day end)
SizedBox(
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,
),
),
),
// Left horizon: Sunrise during day, Sunset at night
Positioned(
left: 0,
bottom: 10,
child: _HorizonTimeDisplay(
icon: _isDaytime ? Icons.wb_twilight : Icons.nights_stay,
label: _isDaytime ? 'Sunrise' : 'Sunset',
time: _isDaytime ? _sunrise : _sunset,
iconColor: _isDaytime ? Colors.orange : Colors.deepOrange,
alignment: CrossAxisAlignment.start,
),
),
// Daylight display centered (at bottom for balance)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Center(
child: _DaylightDisplay(minutes: _daylightMinutes),
),
),
// Right horizon: Sunset during day, Sunrise at night
Positioned(
right: 0,
bottom: 10,
child: _HorizonTimeDisplay(
icon: _isDaytime ? Icons.nights_stay : Icons.wb_twilight,
label: _isDaytime ? 'Sunset' : 'Sunrise',
time: _isDaytime ? _sunset : _sunrise,
iconColor: _isDaytime ? Colors.deepOrange : Colors.orange,
alignment: CrossAxisAlignment.end,
),
),
],
);
},
),
),
],
),
),
);
}
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(_sunrise),
style: Theme.of(context).textTheme.labelSmall,
),
Text(
_formatTime(_sunset),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
],
),
),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
}
/// 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: 18,
color: iconColor ?? colorScheme.primary,
),
const SizedBox(height: 2),
Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
],
);
}
}
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.
///
/// 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°
/// - Arc endpoints touch the horizon line at sunrise/sunset positions
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);
static const _moonSilver = Color(0xFFB0BEC5);
@override
void paint(Canvas canvas, Size size) {
final now = DateTime.now();
// Get sunrise/sunset times (default to 7am/5pm for asymmetric visual)
final sunrise = sunTimes?.sunrise ?? DateTime(now.year, now.month, now.day, 7, 0);
final sunset = sunTimes?.sunset ?? DateTime(now.year, now.month, now.day, 17, 0);
// Calculate durations
final daylightMinutes = sunset.difference(sunrise).inMinutes;
final nightMinutes = 1440 - daylightMinutes; // 24 hours = 1440 minutes
// Determine if currently day or night
final isDaytime = now.isAfter(sunrise) && now.isBefore(sunset);
// Calculate arc angles (proportional to duration)
final dayArcAngle = (daylightMinutes / 1440) * 2 * math.pi;
final nightArcAngle = (nightMinutes / 1440) * 2 * math.pi;
// 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(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))
var radius = horizonWidth / (2 * math.sin(clampedAngle / 2));
// Constrain arc height to fit within available space (leave 25px margin for sun)
final maxArcHeight = size.height - horizonY - 25;
final arcHeight = radius * (1 - math.cos(clampedAngle / 2));
if (arcHeight > maxArcHeight) {
// Scale radius down to fit
radius = maxArcHeight / (1 - math.cos(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,
);
// Draw arc background with day gradient
final arcPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6
..strokeCap = StrokeCap.round
..shader = SweepGradient(
center: Alignment.center,
startAngle: 0,
endAngle: 2 * math.pi,
colors: [
_dawnOrange.withValues(alpha: 0.6),
_sunriseOrange.withValues(alpha: 0.7),
_dayYellow.withValues(alpha: 0.8),
_dayGold.withValues(alpha: 0.8),
_dayYellow.withValues(alpha: 0.7),
_duskOrange.withValues(alpha: 0.6),
],
stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0],
transform: GradientRotation(startAngle),
).createShader(arcRect);
// 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 (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);
// Determine sun color based on position (orange near horizon, yellow at peak)
final isNearHorizon = progress < 0.15 || progress > 0.85;
final sunColor = isNearHorizon ? _sunriseOrange : _dayYellow;
final glowColor = isNearHorizon ? _dawnOrange : _dayYellow;
// Draw sun glow
final glowPaint = Paint()
..shader = RadialGradient(
colors: [
glowColor.withValues(alpha: 0.6),
glowColor.withValues(alpha: 0.0),
],
).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), 10, sunPaint);
// Draw sun rays
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);
canvas.drawLine(
Offset(sunX + 12 * math.cos(rayAngle), sunY + 12 * math.sin(rayAngle)),
Offset(sunX + 16 * math.cos(rayAngle), sunY + 16 * math.sin(rayAngle)),
rayPaint,
);
}
}
void _drawNightArc(
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
var radius = horizonWidth / (2 * math.sin(clampedAngle / 2));
// Constrain arc height to fit within available space (leave 20px margin for moon)
final maxArcHeight = size.height - horizonY - 20;
final arcHeight = radius * (1 - math.cos(clampedAngle / 2));
if (arcHeight > maxArcHeight) {
// Scale radius down to fit
radius = maxArcHeight / (1 - math.cos(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 = 6
..strokeCap = StrokeCap.round
..shader = SweepGradient(
center: Alignment.center,
startAngle: 0,
endAngle: 2 * math.pi,
colors: [
_duskOrange.withValues(alpha: 0.4),
_nightIndigo.withValues(alpha: 0.5),
_nightBlue.withValues(alpha: 0.6),
_nightBlue.withValues(alpha: 0.6),
_nightIndigo.withValues(alpha: 0.5),
_dawnOrange.withValues(alpha: 0.4),
],
stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0],
transform: GradientRotation(startAngle),
).createShader(arcRect);
// 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
final double progress;
if (now.isAfter(sunset)) {
// After sunset: progress from sunset toward midnight and beyond
final nextSunrise = DateTime(now.year, now.month, now.day + 1, sunrise.hour, sunrise.minute);
final nightDuration = nextSunrise.difference(sunset).inMinutes;
final minutesSinceSunset = now.difference(sunset).inMinutes;
progress = (minutesSinceSunset / nightDuration).clamp(0.0, 1.0);
} else {
// Before sunrise: progress toward sunrise
final prevSunset = DateTime(now.year, now.month, now.day - 1, sunset.hour, sunset.minute);
final nightDuration = sunrise.difference(prevSunset).inMinutes;
final minutesSincePrevSunset = now.difference(prevSunset).inMinutes;
progress = (minutesSincePrevSunset / nightDuration).clamp(0.0, 1.0);
}
// 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);
// Draw moon glow
final glowPaint = Paint()
..shader = RadialGradient(
colors: [
_moonSilver.withValues(alpha: 0.3),
_moonSilver.withValues(alpha: 0.0),
],
).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), 8, moonPaint);
// Draw crescent shadow for moon effect
final shadowPaint = Paint()
..style = PaintingStyle.fill
..color = _nightBlue.withValues(alpha: 0.7);
canvas.drawCircle(Offset(moonX + 3, moonY - 1), 6, shadowPaint);
}
@override
bool shouldRepaint(covariant _SunArcPainter oldDelegate) {
return oldDelegate.sunTimes != sunTimes || oldDelegate.isDark != isDark;
}
}