@@ -1,3 +1,4 @@
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 ' ;
@@ -9,7 +10,11 @@ import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dar
/// - Blue tones for night
/// - Orange tones for sunrise/sunset
/// - Yellow tones for daytime
class SunPositionWidget extends StatelessWidget {
///
/// 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 ,
@@ -22,11 +27,71 @@ class SunPositionWidget extends StatelessWidget {
/// 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 ( compact ) {
if ( widget . compact ) {
return _buildCompact ( context , colorScheme ) ;
}
@@ -62,7 +127,7 @@ class SunPositionWidget extends StatelessWidget {
child: CustomPaint (
size: const Size ( double . infinity , 120 ) ,
painter: _SunArcPainter (
sunTimes: sunTimes ,
sunTimes: widget . sunTimes ,
isDark: colorScheme . brightness = = Brightness . dark ,
) ,
) ,
@@ -77,15 +142,14 @@ class SunPositionWidget extends StatelessWidget {
_TimeDisplay (
icon: Icons . wb_twilight ,
label: ' Sunrise ' ,
time: sunTimes ? . sunrise ,
time: _sunrise ,
iconColor: Colors . orange ,
) ,
if ( sunTimes ? . daylightMinutes ! = null )
_DaylightDisplay ( minutes: sunTimes ! . daylightMinutes ! ) ,
_DaylightDisplay ( minutes: _daylightMinutes ) ,
_TimeDisplay (
icon: Icons . nights_stay ,
label: ' Sunset ' ,
time: sunTimes ? . sunset ,
time: _sunset ,
iconColor: Colors . deepOrange ,
) ,
] ,
@@ -114,11 +178,11 @@ class SunPositionWidget extends StatelessWidget {
mainAxisSize: MainAxisSize . min ,
children: [
Text (
_formatTime ( sunTimes ? . sunrise ) ,
_formatTime ( _sunrise ) ,
style: Theme . of ( context ) . textTheme . labelSmall ,
) ,
Text (
_formatTime ( sunTimes ? . sunset ) ,
_formatTime ( _sunset ) ,
style: Theme . of ( context ) . textTheme . labelSmall ,
) ,
] ,
@@ -129,18 +193,7 @@ class SunPositionWidget extends StatelessWidget {
) ;
}
bool get _isDaytime {
final now = DateTime . now ( ) ;
// Use actual sun times if available, otherwise default to 6am/6pm
final sunrise = sunTimes ? . sunrise ? ?
DateTime ( now . year , now . month , now . day , 6 , 0 ) ;
final sunset = sunTimes ? . sunset ? ?
DateTime ( now . year , now . month , now . day , 18 , 0 ) ;
return now . isAfter ( sunrise ) & & now . isBefore ( sunset ) ;
}
String _formatTime ( DateTime ? time ) {
if ( time = = null ) return ' --:-- ' ;
String _formatTime ( DateTime time ) {
return ' ${ time . hour . toString ( ) . padLeft ( 2 , ' 0 ' ) } : ${ time . minute . toString ( ) . padLeft ( 2 , ' 0 ' ) } ' ;
}
}
@@ -155,7 +208,7 @@ class _TimeDisplay extends StatelessWidget {
final IconData icon ;
final String label ;
final DateTime ? time ;
final DateTime time ;
final Color ? iconColor ;
@ override
@@ -171,7 +224,7 @@ class _TimeDisplay extends StatelessWidget {
) ,
const SizedBox ( height: 4 ) ,
Text (
_formatTime ( time ) ,
' ${ time . hour . toString ( ) . padLeft ( 2 , ' 0 ' ) } : ${ time . minute . toString ( ) . padLeft ( 2 , ' 0 ' ) } ' ,
style: Theme . of ( context ) . textTheme . titleSmall ? . copyWith (
fontWeight: FontWeight . w600 ,
) ,
@@ -185,11 +238,6 @@ class _TimeDisplay extends StatelessWidget {
] ,
) ;
}
String _formatTime ( DateTime ? time ) {
if ( time = = null ) return ' --:-- ' ;
return ' ${ time . hour . toString ( ) . padLeft ( 2 , ' 0 ' ) } : ${ time . minute . toString ( ) . padLeft ( 2 , ' 0 ' ) } ' ;
}
}
class _DaylightDisplay extends StatelessWidget {
@@ -228,6 +276,11 @@ class _DaylightDisplay extends StatelessWidget {
}
/// 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 ,
@@ -245,9 +298,28 @@ class _SunArcPainter extends CustomPainter {
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 ) ;
@@ -261,181 +333,225 @@ class _SunArcPainter extends CustomPainter {
horizonPaint ,
) ;
// Create gradient for arc background
final arcRect = Rect . fromCenter (
center: center ,
width: radius * 2 ,
height: radius * 2 ,
) ;
// Draw arc background (night portion below horizon implied)
final arcBgPaint = Paint ( )
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 ;
. . 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 ) ;
// Create gradient that transitions through time of day colors
arcBgPaint . shader = SweepGradient (
center: Alignment . bottomCenter ,
startAngle: math . pi ,
endAngle: 2 * math . pi ,
colors: [
_nightBlue . withValues ( alpha: 0.3 ) ,
_dawnOrange . withValues ( alpha: 0.5 ) ,
_sunriseOrange . withValues ( alpha: 0.6 ) ,
_dayYellow . withValues ( alpha: 0.7 ) ,
_dayGold . withValues ( alpha: 0.7 ) ,
_dayYellow . withValues ( alpha: 0.6 ) ,
_duskOrange . withValues ( alpha: 0.5 ) ,
_nightIndigo . withValues ( alpha: 0.3 ) ,
] ,
stops: const [ 0.0 , 0.1 , 0.2 , 0.4 , 0.5 , 0.6 , 0.8 , 1.0 ] ,
transform: const GradientRotation ( - math . pi / 2 ) ,
) . createShader ( arcRect ) ;
canvas . drawArc ( arcRect , startAngle , sweepAngle , false , arcPaint ) ;
// Draw the arc (semicircle from left to right)
canvas . drawArc (
arcRect ,
math . pi , // Start at left (180 degrees)
math . pi , // Sweep 180 degrees (semicircle)
false ,
arcBgPaint ,
) ;
// Calculate sun position
final sunPosition = _calculateSunPosition ( ) ;
// Draw sun/moon indicator
final angle = math . pi + ( sunPosition * math . pi ) ; // Map 0-1 to pi-2pi
final sunX = center . dx + radius * math . cos ( angle ) ;
final sunY = center . dy + radius * math . sin ( angle ) ;
// Determine if day or night for icon/color
final isDaytime = sunPosition > 0 & & sunPosition < 1 ;
final isNearHorizon = sunPosition < 0.15 | | sunPosition > 0.85 ;
// Sun/moon glow
if ( isDaytime ) {
final glowColor = isNearHorizon ? _dawnOrange : _dayYellow ;
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 ) ;
}
// Sun/moon circle
final sunPaint = Paint ( )
. . style = PaintingStyle . fill
. . color = isDaytime
? ( isNearHorizon ? _sunriseOrange : _dayYellow )
: _nightIndigo ;
canvas . drawCircle ( Offset ( sunX , sunY ) , 12 , sunPaint ) ;
// Sun rays or moon craters
if ( isDaytime ) {
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 ) ;
final innerRadius = 14.0 ;
final outerRadius = 18.0 ;
canvas . drawLine (
Offset (
sunX + innerRadius * math . cos ( rayAngle ) ,
sunY + innerRadius * math . sin ( rayAngle ) ,
) ,
Offset (
sunX + outerRadius * math . cos ( rayAngle ) ,
sunY + outerRadius * math . sin ( rayAngle ) ,
) ,
rayPaint ,
) ;
}
} else {
// Moon highlight
final moonHighlight = Paint ( )
. . style = PaintingStyle . fill
. . color = Colors . white24 ;
canvas . drawCircle ( Offset ( sunX - 3 , sunY - 3 ) , 4 , moonHighlight ) ;
}
// Draw time markers on arc
_drawTimeMarkers ( canvas , center , radius ) ;
}
double _calculateSunPosition ( ) {
final now = DateTime . now ( ) ;
// Use actual sun times if available, otherwise default to 6am/6pm (12-hour cycles)
final sunrise = sunTimes ? . sunrise ? ?
DateTime ( now . year , now . month , now . day , 6 , 0 ) ;
final sunset = sunTimes ? . sunset ? ?
DateTime ( now . year , now . month , now . day , 18 , 0 ) ;
// Before sunrise
if ( now . isBefore ( sunrise ) ) {
// Calculate position in pre-dawn (negative values = below horizon)
final midnight = DateTime ( now . year , now . month , now . day ) ;
final minutesSinceMidnight = now . difference ( midnight ) . inMinutes ;
final minutesToSunrise = sunrise . difference ( midnight ) . inMinutes ;
// Map to 0 at sunrise, negative before
return ( minutesSinceMidnight / minutesToSunrise ) * 0.15 - 0.1 ;
}
// After sunset
if ( now . isAfter ( sunset ) ) {
// Calculate position in post-dusk
final minutesSinceSunset = now . difference ( sunset ) . inMinutes ;
final nextMidnight = DateTime ( now . year , now . month , now . day + 1 ) ;
final minutesToMidnight = nextMidnight . difference ( sunset ) . inMinutes ;
// Map to 1 at sunset, going towards negative
return 1.0 + ( minutesSinceSunset / minutesToMidnight ) * 0.1 ;
}
// During daylight hours
// Calculate sun position along the arc
final totalDaylight = sunset . difference ( sunrise ) . inMinutes ;
final minutesSinceSunrise = now . difference ( sunrise ) . inMinutes ;
return minutesSinceSunrise / totalDaylight ;
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 _drawTimeMarkers ( Canvas canvas , Offset center , double radius ) {
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 markerStyle = TextStyle (
final labelStyle = TextStyle (
fontSize: 10 ,
color: isDark ? Colors . white38 : Colors . black38 ,
) ;
// Draw quarter markers (6am, 12pm, 6pm positions conceptually )
final markers = [ ' 6:00 ' , ' 12:00 ' , ' 18:00 ' ] ;
final positions = [ 0.25 , 0.5 , 0.75 ] ;
// 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 ) ,
) ;
for ( var i = 0 ; i < markers . length ; i + + ) {
final angle = math . pi + ( positions [ i ] * math . pi ) ;
final markerRadius = radius + 15 ;
final x = center . dx + markerRadius * math . cos ( angle ) ;
final y = center . dy + markerRadius * math . sin ( angle ) ;
textPainter . text = TextSpan ( text: markers [ i ] , style: markerStyle ) ;
textPainter . layout ( ) ;
textPainter . paint (
canvas ,
Offset ( x - textPainter . width / 2 , y - textPainter . height / 2 ) ,
) ;
}
// 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