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: 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; } }