Files
tatlock-ui/lib/shared/widgets/sun_position_widget.dart
T
Jeroen SchweitzerandClaude Opus 4.5 9b2878f6e3
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m12s
feat(sun-position): add proportional arc visualization with 10-min updates
- Arc angle now proportional to day/night duration (day = daylight/24 × 360°)
- Horizon points represent sunrise/sunset times
- Day arc with sun icon and yellow/orange gradient
- Night arc with moon icon and blue/indigo gradient
- Position updates every 10 minutes aligned to clock (0/10/20/30/40/50)
- Default to 07:00-17:00 when API data unavailable (asymmetric for visual effect)
- All environment cards maintain consistent height in "no data" state

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:06:17 +01:00

562 lines
17 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: 16),
// Arc visualization
SizedBox(
height: 120,
child: CustomPaint(
size: const Size(double.infinity, 120),
painter: _SunArcPainter(
sunTimes: widget.sunTimes,
isDark: colorScheme.brightness == Brightness.dark,
),
),
),
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,
),
],
),
],
),
),
);
}
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')}';
}
}
class _TimeDisplay extends StatelessWidget {
const _TimeDisplay({
required this.icon,
required this.label,
required this.time,
this.iconColor,
});
final IconData icon;
final String label;
final DateTime time;
final Color? iconColor;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
Icon(
icon,
size: 20,
color: iconColor ?? colorScheme.primary,
),
const SizedBox(height: 4),
Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
);
}
}
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°
/// - Horizon points (left/right) represent sunrise/sunset times
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)
// 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);
// 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),
horizonPaint,
);
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
..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: const GradientRotation(math.pi),
).createShader(arcRect);
canvas.drawArc(arcRect, startAngle, sweepAngle, 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);
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: 24));
canvas.drawCircle(Offset(sunX, sunY), 24, glowPaint);
// Draw sun
final sunPaint = Paint()
..style = PaintingStyle.fill
..color = sunColor;
canvas.drawCircle(Offset(sunX, sunY), 12, 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 + 14 * math.cos(rayAngle), sunY + 14 * math.sin(rayAngle)),
Offset(sunX + 18 * math.cos(rayAngle), sunY + 18 * math.sin(rayAngle)),
rayPaint,
);
}
}
void _drawNightArc(
Canvas canvas,
Rect arcRect,
Offset center,
double radius,
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
// Draw arc background with night gradient
final arcPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 8
..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: const GradientRotation(math.pi),
).createShader(arcRect);
canvas.drawArc(arcRect, startAngle, sweepAngle, 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
final moonAngle = startAngle + (progress * sweepAngle);
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: 20));
canvas.drawCircle(Offset(moonX, moonY), 20, glowPaint);
// Draw moon
final moonPaint = Paint()
..style = PaintingStyle.fill
..color = _moonSilver;
canvas.drawCircle(Offset(moonX, moonY), 10, 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),
);
}
@override
bool shouldRepaint(covariant _SunArcPainter oldDelegate) {
return oldDelegate.sunTimes != sunTimes || oldDelegate.isDark != isDark;
}
}