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 createState() => _SunPositionWidgetState(); } class _SunPositionWidgetState extends State { 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; } }