Compare commits

...
5 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 780edd2d3b fix: environment widget alignment and sun position night labels
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m27s
- 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
Jeroen SchweitzerandClaude Opus 4.5 eefb491e87 fix: log environment API user only once per session
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m11s
- Store user in static variable for reuse
- Only debugPrint on first successful load

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:40:06 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9b2000efc2 fix: sun position arc overflow and revert user display
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m14s
- Constrain arc height to fit within card boundaries
- Scale radius down when arc would overflow on wider displays
- Revert user display in section header, use debugPrint instead

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:26:51 +01:00
Jeroen SchweitzerandClaude Opus 4.5 cc6068b759 chore: release v1.5.4
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 3m9s
Add user display in environment section header for debugging OIDC
user resolution issues.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 14:05:16 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9a75fb25db feat(sun-position): redesign arc to touch horizon at endpoints
Build and Push / release (push) Successful in 23s
Build and Push / build (push) Successful in 3m13s
- Arc geometry now uses chord-radius calculation for proper horizon intersection
- Sunrise/sunset icons integrated at horizon endpoints (removed duplicate labels)
- Daylight duration centered below arc
- Increased all environment card heights 20% (140px → 170px) for better arc visibility
- Visual balance: sunrise/sunset raised 10px, daylight at bottom

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 13:29:56 +01:00
7 changed files with 505 additions and 340 deletions
+52
View File
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.5.7] - 2026-01-08
### Fixed
- **Environment widget alignment** - Consistent card heights across all environment widgets
- Added `ConstrainedBox(minHeight: 170)` to Weather, Air Quality, and Forecast widgets
- All cards now match Sun Position widget height when displaying data
- **Sun position night labels** - Swap sunrise/sunset labels at night
- During day: Sunrise on left, Sunset on right (day arc)
- At night: Sunset on left, Sunrise on right (night arc)
### Changed
- **Weather widget header** - Changed from location name to "Weather" for consistency
- Location now displayed in content area below temperature
## [1.5.6] - 2026-01-07
### Changed
- **Environment user logging** - Now logs API user only once per session instead of on every refresh
## [1.5.5] - 2026-01-07
### Fixed
- **Sun position arc overflow** - Arc now constrained to fit within card boundaries
- Prevents arc and sun/moon from overflowing on wider displays
- Scales radius down when arc height exceeds available space
### Added
- **Debug logging for environment user** - Logs authenticated user on environment data load
## [1.5.4] - 2026-01-07
### Added
- User display in environment section header (reverted in 1.5.5)
## [1.5.3] - 2026-01-07
### Changed
- **Sun position arc redesign** - Arc now touches horizon at sunrise/sunset points
- Arc geometry uses chord-radius calculation for proper horizon intersection
- Sunrise/sunset icons integrated at horizon endpoints (no duplicate labels)
- Daylight duration centered below arc
- Increased card height 20% for better arc visibility (140px → 170px)
- **Visual balance improvements** - Sunrise/sunset labels raised 10px, daylight label at bottom
## [1.5.2] - 2026-01-07 ## [1.5.2] - 2026-01-07
### Changed ### Changed
@@ -26,6 +26,13 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
Timer? _systemStatsTimer; Timer? _systemStatsTimer;
Timer? _environmentTimer; Timer? _environmentTimer;
/// Tracks if we've logged the environment API user (log once per session)
static bool _hasLoggedEnvUser = false;
static String? _envApiUser;
/// Get the environment API user (available after first load)
static String? get envApiUser => _envApiUser;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -129,13 +136,21 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
const SizedBox(height: 24), const SizedBox(height: 24),
// Environment - Sun, Weather, Forecast, Air Quality // Environment - Sun, Weather, Forecast, Air Quality
_SectionHeader(title: 'Environment', icon: Icons.eco), const _SectionHeader(title: 'Environment', icon: Icons.eco),
const SizedBox(height: 8), const SizedBox(height: 8),
environmentAsync.when( environmentAsync.when(
data: (envData) => _EnvironmentSection( data: (envData) {
envData: envData, // Store and log user once per session
onRefresh: () => ref.invalidate(environmentProvider), if (!_hasLoggedEnvUser && envData.user != null) {
), _envApiUser = envData.user;
_hasLoggedEnvUser = true;
debugPrint('Environment API user: ${envData.user}');
}
return _EnvironmentSection(
envData: envData,
onRefresh: () => ref.invalidate(environmentProvider),
);
},
loading: () => const Card( loading: () => const Card(
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
+98 -80
View File
@@ -67,77 +67,93 @@ class AirQualityWidget extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
// AQI Display // Content area with minimum height to match other widgets
Row( ConstrainedBox(
children: [ constraints: const BoxConstraints(minHeight: 170),
// AQI number with colored background child: Column(
Container( crossAxisAlignment: CrossAxisAlignment.start,
width: 64, mainAxisSize: MainAxisSize.min,
height: 64, children: [
decoration: BoxDecoration( const SizedBox(height: 8),
color: aqi.level.color.withValues(alpha: 0.15), // AQI Display
borderRadius: BorderRadius.circular(12), Row(
border: Border.all(
color: aqi.level.color.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Text(
'${aqi.index}',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: aqi.level.color,
),
),
),
),
const SizedBox(width: 16),
// Level info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( // AQI number with colored background
aqi.level.label, Container(
style: width: 64,
Theme.of(context).textTheme.titleMedium?.copyWith( height: 64,
fontWeight: FontWeight.w600, decoration: BoxDecoration(
color: aqi.level.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: aqi.level.color.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Text(
'${aqi.index}',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: aqi.level.color, color: aqi.level.color,
), ),
),
),
), ),
const SizedBox(height: 4), const SizedBox(width: 16),
Text(
aqi.level.description, // Level info
style: Theme.of(context).textTheme.bodySmall?.copyWith( Expanded(
color: colorScheme.onSurfaceVariant, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
aqi.level.label,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: aqi.level.color,
),
), ),
maxLines: 2, const SizedBox(height: 4),
overflow: TextOverflow.ellipsis, Text(
aqi.level.description,
style:
Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
), ),
], ],
), ),
),
],
),
// Pollutants // Pollutants
if (aqi.pollutants.isNotEmpty) ...[ if (aqi.pollutants.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( Wrap(
spacing: 16, spacing: 16,
runSpacing: 8, runSpacing: 8,
children: aqi.pollutants children: aqi.pollutants
.map((p) => _PollutantChip(pollutant: p)) .map((p) => _PollutantChip(pollutant: p))
.toList(), .toList(),
),
],
],
), ),
], ),
], ],
), ),
), ),
@@ -210,28 +226,30 @@ class AirQualityWidget extends StatelessWidget {
), ),
], ],
), ),
// Match height of populated card content area const SizedBox(height: 8),
const SizedBox(height: 120), // Match sun position card height (170px content area)
Center( SizedBox(
child: Column( height: 170,
mainAxisSize: MainAxisSize.min, child: Center(
children: [ child: Column(
Icon( mainAxisSize: MainAxisSize.min,
Icons.air_outlined, children: [
size: 32, Icon(
color: colorScheme.outline, Icons.air_outlined,
), size: 32,
const SizedBox(height: 8), color: colorScheme.outline,
Text( ),
'No data available', const SizedBox(height: 8),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( Text(
color: colorScheme.outline, 'No data available',
), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
), color: colorScheme.outline,
], ),
),
],
),
), ),
), ),
const SizedBox(height: 40),
], ],
), ),
), ),
+38 -31
View File
@@ -48,28 +48,30 @@ class ForecastWidget extends StatelessWidget {
), ),
], ],
), ),
// Match height of populated card content area const SizedBox(height: 8),
const SizedBox(height: 120), // Match sun position card height (170px content area)
Center( SizedBox(
child: Column( height: 170,
mainAxisSize: MainAxisSize.min, child: Center(
children: [ child: Column(
Icon( mainAxisSize: MainAxisSize.min,
Icons.event_busy_outlined, children: [
size: 32, Icon(
color: colorScheme.outline, Icons.event_busy_outlined,
), size: 32,
const SizedBox(height: 8), color: colorScheme.outline,
Text( ),
'No data available', const SizedBox(height: 8),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( Text(
color: colorScheme.outline, 'No data available',
), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
), color: colorScheme.outline,
], ),
),
],
),
), ),
), ),
const SizedBox(height: 40),
], ],
), ),
), ),
@@ -104,18 +106,23 @@ class ForecastWidget extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
// Forecast days - horizontal scroll // Content area with minimum height to match other widgets
SizedBox( ConstrainedBox(
height: 100, constraints: const BoxConstraints(minHeight: 170),
child: ListView.separated( child: Center(
scrollDirection: Axis.horizontal, child: SizedBox(
itemCount: forecast!.length, height: 110,
separatorBuilder: (_, i) => const SizedBox(width: 12), child: ListView.separated(
itemBuilder: (context, index) { scrollDirection: Axis.horizontal,
return _ForecastDayCard(day: forecast![index]); itemCount: forecast!.length,
}, separatorBuilder: (_, i) => const SizedBox(width: 12),
itemBuilder: (context, index) {
return _ForecastDayCard(day: forecast![index]);
},
),
),
), ),
), ),
], ],
+171 -130
View File
@@ -119,41 +119,65 @@ class _SunPositionWidgetState extends State<SunPositionWidget> {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
// Arc visualization // 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( SizedBox(
height: 120, height: 170,
child: CustomPaint( child: LayoutBuilder(
size: const Size(double.infinity, 120), builder: (context, constraints) {
painter: _SunArcPainter( return Stack(
sunTimes: widget.sunTimes, clipBehavior: Clip.none,
isDark: colorScheme.brightness == Brightness.dark, 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,
),
),
],
);
},
), ),
), ),
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,
),
],
),
], ],
), ),
), ),
@@ -198,34 +222,39 @@ class _SunPositionWidgetState extends State<SunPositionWidget> {
} }
} }
class _TimeDisplay extends StatelessWidget { /// Horizon-aligned time display with icon above time and label.
const _TimeDisplay({ class _HorizonTimeDisplay extends StatelessWidget {
const _HorizonTimeDisplay({
required this.icon, required this.icon,
required this.label, required this.label,
required this.time, required this.time,
this.iconColor, this.iconColor,
this.alignment = CrossAxisAlignment.center,
}); });
final IconData icon; final IconData icon;
final String label; final String label;
final DateTime time; final DateTime time;
final Color? iconColor; final Color? iconColor;
final CrossAxisAlignment alignment;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Column( return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: alignment,
children: [ children: [
Icon( Icon(
icon, icon,
size: 20, size: 18,
color: iconColor ?? colorScheme.primary, color: iconColor ?? colorScheme.primary,
), ),
const SizedBox(height: 4), const SizedBox(height: 2),
Text( Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@@ -233,6 +262,7 @@ class _TimeDisplay extends StatelessWidget {
label, label,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontSize: 10,
), ),
), ),
], ],
@@ -280,7 +310,7 @@ class _DaylightDisplay extends StatelessWidget {
/// The arc is proportional to the day/night duration: /// The arc is proportional to the day/night duration:
/// - During day: shows a day arc spanning (daylight hours / 24) × 360° /// - During day: shows a day arc spanning (daylight hours / 24) × 360°
/// - During night: shows a night arc spanning (night hours / 24) × 360° /// - During night: shows a night arc spanning (night hours / 24) × 360°
/// - Horizon points (left/right) represent sunrise/sunset times /// - Arc endpoints touch the horizon line at sunrise/sunset positions
class _SunArcPainter extends CustomPainter { class _SunArcPainter extends CustomPainter {
_SunArcPainter({ _SunArcPainter({
this.sunTimes, this.sunTimes,
@@ -316,59 +346,79 @@ class _SunArcPainter extends CustomPainter {
final isDaytime = now.isAfter(sunrise) && now.isBefore(sunset); final isDaytime = now.isAfter(sunrise) && now.isBefore(sunset);
// Calculate arc angles (proportional to duration) // Calculate arc angles (proportional to duration)
// Day arc sweeps upward (above horizon), night arc sweeps downward
final dayArcAngle = (daylightMinutes / 1440) * 2 * math.pi; final dayArcAngle = (daylightMinutes / 1440) * 2 * math.pi;
final nightArcAngle = (nightMinutes / 1440) * 2 * math.pi; final nightArcAngle = (nightMinutes / 1440) * 2 * math.pi;
final center = Offset(size.width / 2, size.height - 10); // Layout constants - horizon is 50px from bottom to leave room for labels
final radius = math.min(size.width / 2 - 20, size.height - 30); const horizonY = 50.0;
const horizontalPadding = 40.0;
final horizonWidth = size.width - (horizontalPadding * 2);
final centerX = size.width / 2;
// Draw horizon line // Draw horizon line
final horizonPaint = Paint() final horizonPaint = Paint()
..color = isDark ? Colors.white24 : Colors.black12 ..color = isDark ? Colors.white24 : Colors.black12
..strokeWidth = 1; ..strokeWidth = 1;
canvas.drawLine( canvas.drawLine(
Offset(center.dx - radius - 10, center.dy), Offset(horizontalPadding - 5, size.height - horizonY),
Offset(center.dx + radius + 10, center.dy), Offset(size.width - horizontalPadding + 5, size.height - horizonY),
horizonPaint, 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( final arcRect = Rect.fromCenter(
center: center, center: center,
width: radius * 2, width: radius * 2,
height: 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 // Draw arc background with day gradient
final arcPaint = Paint() final arcPaint = Paint()
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 8 ..strokeWidth = 6
..strokeCap = StrokeCap.round ..strokeCap = StrokeCap.round
..shader = SweepGradient( ..shader = SweepGradient(
center: Alignment.center, center: Alignment.center,
@@ -383,18 +433,19 @@ class _SunArcPainter extends CustomPainter {
_duskOrange.withValues(alpha: 0.6), _duskOrange.withValues(alpha: 0.6),
], ],
stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0], stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0],
transform: const GradientRotation(math.pi), transform: GradientRotation(startAngle),
).createShader(arcRect); ).createShader(arcRect);
canvas.drawArc(arcRect, startAngle, sweepAngle, false, arcPaint); // 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 // Calculate sun position along the arc
final totalDaylight = sunset.difference(sunrise).inMinutes; final totalDaylight = sunset.difference(sunrise).inMinutes;
final minutesSinceSunrise = now.difference(sunrise).inMinutes; final minutesSinceSunrise = now.difference(sunrise).inMinutes;
final progress = (minutesSinceSunrise / totalDaylight).clamp(0.0, 1.0); final progress = (minutesSinceSunrise / totalDaylight).clamp(0.0, 1.0);
// Map progress to angle along the arc // Map progress to angle along the arc (0 = sunrise/left, 1 = sunset/right)
final sunAngle = startAngle + (progress * sweepAngle); final sunAngle = startAngle + (progress * clampedAngle);
final sunX = center.dx + radius * math.cos(sunAngle); final sunX = center.dx + radius * math.cos(sunAngle);
final sunY = center.dy + radius * math.sin(sunAngle); final sunY = center.dy + radius * math.sin(sunAngle);
@@ -410,14 +461,14 @@ class _SunArcPainter extends CustomPainter {
glowColor.withValues(alpha: 0.6), glowColor.withValues(alpha: 0.6),
glowColor.withValues(alpha: 0.0), glowColor.withValues(alpha: 0.0),
], ],
).createShader(Rect.fromCircle(center: Offset(sunX, sunY), radius: 24)); ).createShader(Rect.fromCircle(center: Offset(sunX, sunY), radius: 20));
canvas.drawCircle(Offset(sunX, sunY), 24, glowPaint); canvas.drawCircle(Offset(sunX, sunY), 20, glowPaint);
// Draw sun // Draw sun
final sunPaint = Paint() final sunPaint = Paint()
..style = PaintingStyle.fill ..style = PaintingStyle.fill
..color = sunColor; ..color = sunColor;
canvas.drawCircle(Offset(sunX, sunY), 12, sunPaint); canvas.drawCircle(Offset(sunX, sunY), 10, sunPaint);
// Draw sun rays // Draw sun rays
final rayPaint = Paint() final rayPaint = Paint()
@@ -428,8 +479,8 @@ class _SunArcPainter extends CustomPainter {
for (var i = 0; i < 8; i++) { for (var i = 0; i < 8; i++) {
final rayAngle = (i * math.pi / 4); final rayAngle = (i * math.pi / 4);
canvas.drawLine( canvas.drawLine(
Offset(sunX + 14 * math.cos(rayAngle), sunY + 14 * math.sin(rayAngle)), Offset(sunX + 12 * math.cos(rayAngle), sunY + 12 * math.sin(rayAngle)),
Offset(sunX + 18 * math.cos(rayAngle), sunY + 18 * math.sin(rayAngle)), Offset(sunX + 16 * math.cos(rayAngle), sunY + 16 * math.sin(rayAngle)),
rayPaint, rayPaint,
); );
} }
@@ -437,23 +488,48 @@ class _SunArcPainter extends CustomPainter {
void _drawNightArc( void _drawNightArc(
Canvas canvas, Canvas canvas,
Rect arcRect, Size size,
Offset center, double centerX,
double radius, double horizonWidth,
double horizonY,
double arcAngle, double arcAngle,
DateTime now, DateTime now,
DateTime sunrise, DateTime sunrise,
DateTime sunset, DateTime sunset,
) { ) {
// Night arc: sweeps below the horizon (or shown inverted above) // Clamp arc angle between 30° and 180° for visual sanity
// For visual clarity, we show it as an arc above horizon but with night colors final clampedAngle = arcAngle.clamp(math.pi / 6, math.pi);
final startAngle = math.pi;
final sweepAngle = arcAngle.clamp(0.0, math.pi); // Cap at semicircle // 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 // Draw arc background with night gradient
final arcPaint = Paint() final arcPaint = Paint()
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 8 ..strokeWidth = 6
..strokeCap = StrokeCap.round ..strokeCap = StrokeCap.round
..shader = SweepGradient( ..shader = SweepGradient(
center: Alignment.center, center: Alignment.center,
@@ -468,10 +544,11 @@ class _SunArcPainter extends CustomPainter {
_dawnOrange.withValues(alpha: 0.4), _dawnOrange.withValues(alpha: 0.4),
], ],
stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0], stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0],
transform: const GradientRotation(math.pi), transform: GradientRotation(startAngle),
).createShader(arcRect); ).createShader(arcRect);
canvas.drawArc(arcRect, startAngle, sweepAngle, false, arcPaint); // Draw the arc (positive sweep = clockwise in canvas coords)
canvas.drawArc(arcRect, startAngle, clampedAngle, false, arcPaint);
// Calculate moon position // Calculate moon position
// Night spans from sunset to next sunrise // Night spans from sunset to next sunrise
@@ -490,8 +567,8 @@ class _SunArcPainter extends CustomPainter {
progress = (minutesSincePrevSunset / nightDuration).clamp(0.0, 1.0); progress = (minutesSincePrevSunset / nightDuration).clamp(0.0, 1.0);
} }
// Map progress to angle along the arc // Map progress to angle along the arc (0 = sunset/left, 1 = sunrise/right)
final moonAngle = startAngle + (progress * sweepAngle); final moonAngle = startAngle + (progress * clampedAngle);
final moonX = center.dx + radius * math.cos(moonAngle); final moonX = center.dx + radius * math.cos(moonAngle);
final moonY = center.dy + radius * math.sin(moonAngle); final moonY = center.dy + radius * math.sin(moonAngle);
@@ -502,56 +579,20 @@ class _SunArcPainter extends CustomPainter {
_moonSilver.withValues(alpha: 0.3), _moonSilver.withValues(alpha: 0.3),
_moonSilver.withValues(alpha: 0.0), _moonSilver.withValues(alpha: 0.0),
], ],
).createShader(Rect.fromCircle(center: Offset(moonX, moonY), radius: 20)); ).createShader(Rect.fromCircle(center: Offset(moonX, moonY), radius: 16));
canvas.drawCircle(Offset(moonX, moonY), 20, glowPaint); canvas.drawCircle(Offset(moonX, moonY), 16, glowPaint);
// Draw moon // Draw moon
final moonPaint = Paint() final moonPaint = Paint()
..style = PaintingStyle.fill ..style = PaintingStyle.fill
..color = _moonSilver; ..color = _moonSilver;
canvas.drawCircle(Offset(moonX, moonY), 10, moonPaint); canvas.drawCircle(Offset(moonX, moonY), 8, moonPaint);
// Draw crescent shadow for moon effect // Draw crescent shadow for moon effect
final shadowPaint = Paint() final shadowPaint = Paint()
..style = PaintingStyle.fill ..style = PaintingStyle.fill
..color = _nightBlue.withValues(alpha: 0.7); ..color = _nightBlue.withValues(alpha: 0.7);
canvas.drawCircle(Offset(moonX + 4, moonY - 2), 8, shadowPaint); canvas.drawCircle(Offset(moonX + 3, moonY - 1), 6, 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 @override
+125 -93
View File
@@ -59,7 +59,7 @@ class WeatherWidget extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
weather.location, 'Weather',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -69,89 +69,119 @@ class WeatherWidget extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
// Main weather display (matching AQI layout) // Content area with minimum height to match other widgets
Row( ConstrainedBox(
children: [ constraints: const BoxConstraints(minHeight: 170),
// Weather icon in box (like AQI number box) child: Column(
Container( crossAxisAlignment: CrossAxisAlignment.start,
width: 64, mainAxisSize: MainAxisSize.min,
height: 64, children: [
decoration: BoxDecoration( const SizedBox(height: 8),
color: (weather.iconColor ?? colorScheme.primary) // Main weather display (matching AQI layout)
.withValues(alpha: 0.15), Row(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: (weather.iconColor ?? colorScheme.primary)
.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Icon(
weather.icon,
size: 32,
color: weather.iconColor ?? colorScheme.primary,
),
),
),
const SizedBox(width: 16),
// Temperature and condition
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( // Weather icon in box (like AQI number box)
'${weather.temperature.round()}°${weather.unit.symbol}', Container(
style: width: 64,
Theme.of(context).textTheme.titleMedium?.copyWith( height: 64,
fontWeight: FontWeight.w600, decoration: BoxDecoration(
), color: (weather.iconColor ?? colorScheme.primary)
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: (weather.iconColor ?? colorScheme.primary)
.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Icon(
weather.icon,
size: 32,
color: weather.iconColor ?? colorScheme.primary,
),
),
), ),
const SizedBox(height: 4), const SizedBox(width: 16),
Text(
weather.condition, // Temperature and condition
style: Theme.of(context).textTheme.bodySmall?.copyWith( Expanded(
color: colorScheme.onSurfaceVariant, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${weather.temperature.round()}°${weather.unit.symbol}',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
),
), ),
maxLines: 2, const SizedBox(height: 4),
overflow: TextOverflow.ellipsis, Text(
weather.condition,
style:
Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (weather.location.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
weather.location,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: colorScheme.outline,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
), ),
], ],
), ),
),
],
),
// Details // Details
if (weather.humidity != null || weather.windSpeed != null) ...[ if (weather.humidity != null || weather.windSpeed != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( Wrap(
spacing: 16, spacing: 16,
runSpacing: 8, runSpacing: 8,
children: [ children: [
if (weather.humidity != null) if (weather.humidity != null)
_DetailChip( _DetailChip(
label: 'Humidity', label: 'Humidity',
value: '${weather.humidity}%', value: '${weather.humidity}%',
), ),
if (weather.windSpeed != null) if (weather.windSpeed != null)
_DetailChip( _DetailChip(
label: 'Wind', label: 'Wind',
value: '${weather.windSpeed!.round()} ${weather.windUnit}', value:
), '${weather.windSpeed!.round()} ${weather.windUnit}',
if (weather.feelsLike != null) ),
_DetailChip( if (weather.feelsLike != null)
label: 'Feels', _DetailChip(
value: '${weather.feelsLike!.round()}°${weather.unit.symbol}', label: 'Feels',
value:
'${weather.feelsLike!.round()}°${weather.unit.symbol}',
),
],
), ),
],
], ],
), ),
], ),
], ],
), ),
), ),
@@ -212,28 +242,30 @@ class WeatherWidget extends StatelessWidget {
), ),
], ],
), ),
// Match height of populated card content area const SizedBox(height: 8),
const SizedBox(height: 120), // Match sun position card height (170px content area)
Center( SizedBox(
child: Column( height: 170,
mainAxisSize: MainAxisSize.min, child: Center(
children: [ child: Column(
Icon( mainAxisSize: MainAxisSize.min,
Icons.cloud_off_outlined, children: [
size: 32, Icon(
color: colorScheme.outline, Icons.cloud_off_outlined,
), size: 32,
const SizedBox(height: 8), color: colorScheme.outline,
Text( ),
'No data available', const SizedBox(height: 8),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( Text(
color: colorScheme.outline, 'No data available',
), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
), color: colorScheme.outline,
], ),
),
],
),
), ),
), ),
const SizedBox(height: 40),
], ],
), ),
), ),
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.5.2+1 version: 1.5.7+1
environment: environment:
sdk: ^3.10.4 sdk: ^3.10.4