Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions SolisWidget/SolisWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,17 @@ struct MovingWidgetSky: View {
let theme: SunTheme

var body: some View {
// Use the same background colours as WidgetSky, stretched around the centre line.
LinearGradient(stops: data.skyStops.map {
Gradient.Stop(color: colour(for: $0.type), location: $0.position)
}, startPoint: .top, endPoint: .bottom)
.overlay(.black.opacity(0.25))
}, startPoint: UnitPoint(x: 0.5, y: -1), endPoint: UnitPoint(x: 0.5, y: 2))
.overlay(.black.opacity(0.24))
}

private func colour(for type: SunType) -> Color {
let palette = theme.palette
switch type {
case .sunrise, .sunset: return Color(palette.riseset)
case .civilDawn, .civilDusk: return Color(palette.civil)
case .sunrise, .sunset, .civilDawn, .civilDusk: return Color(palette.civil)
case .nauticalDawn, .nauticalDusk: return Color(palette.nautical)
case .astronomicalDawn, .astronomicalDusk, .middleNight: return Color(palette.astronomical)
}
Expand Down Expand Up @@ -159,6 +159,12 @@ struct SunlineGraphic: View {
renderingMode == .fullColor ? Color(theme.palette.riseset) : .white
}

private var markerColour: Color {
guard renderingMode == .fullColor, theme == .classic,
data.isDaylight, data.isGoldenHour else { return accent }
return Color(theme.palette.goldenHour.withAlphaComponent(1))
}

var body: some View {
GeometryReader { geometry in
let rect = CGRect(x: 7, y: 8, width: max(0, geometry.size.width - 14),
Expand All @@ -175,16 +181,16 @@ struct SunlineGraphic: View {
.stroke(accent, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.frame(width: rect.width, height: rect.height).position(x: rect.midX, y: rect.midY)
.widgetAccentable()
Circle().fill(accent.opacity(0.16)).frame(width: 22, height: 22).position(point)
Circle().fill(markerColour.opacity(0.16)).frame(width: 22, height: 22).position(point)
if data.isDaylight {
Circle().fill(accent).frame(width: 8, height: 8).position(point).widgetAccentable()
Circle().fill(markerColour).frame(width: 8, height: 8).position(point).widgetAccentable()
} else {
Image(systemName: "moon.fill").font(.system(size: 10))
.foregroundStyle(accent).position(point).widgetAccentable()
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(data.isDaylight ? "Sun above the horizon" : "Sun below the horizon")
.accessibilityLabel(data.isDaylight ? (data.isGoldenHour ? "Sun above the horizon, golden hour" : "Sun above the horizon") : "Sun below the horizon")
.accessibilityValue("\(widgetTime(data.date, zone: data.timeZone)) local time")
}
}
Expand Down Expand Up @@ -311,13 +317,21 @@ enum SunWidgetStyle { case clock, countdown, sunline, now }

struct NowContent: View {
let data: SunWidgetData
@Environment(\.widgetRenderingMode) private var renderingMode
@Environment(\.solisWidgetTheme) private var theme

private var markerColour: Color {
renderingMode == .fullColor && theme == .classic && data.isGoldenHour
? Color(theme.palette.goldenHour.withAlphaComponent(1)) : .white
}

var body: some View {
if let location = data.locationName {
ZStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
WidgetLocationLabel(name: location)
Text(data.skyPhase).font(muli(24)).lineLimit(1).minimumScaleFactor(0.8)
.padding(.bottom, 7)
Text(data.lightPhase).font(muli(24)).lineLimit(1).minimumScaleFactor(0.7)
Spacer(minLength: 28)
if let event = data.nextEvent {
Text(event.name).font(.system(size: 12, weight: .medium))
Expand All @@ -329,11 +343,11 @@ struct NowContent: View {
}
}
HStack(spacing: 8) {
Text("now").font(.system(size: 11, weight: .semibold))
Rectangle().fill(.white.opacity(0.65)).frame(height: 1)
Circle().frame(width: 5, height: 5)
Circle().fill(markerColour).frame(width: 5, height: 5)
}
.widgetAccentable()
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Expand Down
33 changes: 28 additions & 5 deletions SunriseSunset/SunWidgetData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,22 @@ struct SunWidgetDay {
let skyEvents: [WidgetSkyEvent]

let daylightIntervals: [DateInterval]
let goldenHourIntervals: [DateInterval]
let localNoon: Date

var daylightDuration: TimeInterval { daylightIntervals.reduce(0) { $0 + $1.duration } }

init?(date: Date, location: CLLocationCoordinate2D, timeZone: TimeZone) {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
guard date.timeIntervalSince1970.isFinite, let dayInterval = calendar.dateInterval(of: .day, for: date) else { return nil }
guard date.timeIntervalSince1970.isFinite,
let dayInterval = calendar.dateInterval(of: .day, for: date),
let noon = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: date) else { return nil }
interval = dayInterval
localNoon = noon
guard let solar = SunLogic.solarDay(date, location: location, timezone: timeZone) else { return nil }
daylightIntervals = solar.intervals(above: .horizon)
goldenHourIntervals = solar.intervals(between: .blue, and: .golden)
switch solar.state(at: .horizon) {
case .above: daylight = .allDay
case .below: daylight = .allNight
Expand Down Expand Up @@ -121,6 +127,7 @@ struct SunWidgetData {
let daylightChange: TimeInterval?
let daylightIntervals: [DateInterval]
let skyEvents: [WidgetSkyEvent]
let isGoldenHour: Bool

// Match the app's six-hour viewport: future above, past below, now at 0.5.
func skyPosition(for eventDate: Date) -> Double {
Expand All @@ -146,6 +153,18 @@ struct SunWidgetData {
}
}

var lightPhase: String {
if isGoldenHour { return "Golden hour" }
if daylight == .allDay { return "Midnight sun" }
if daylight == .allNight { return "Polar night" }
if isDaylight {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
return calendar.component(.hour, from: date) < 12 ? "Morning" : "Afternoon"
}
return skyPhase
}

var isDaylight: Bool {
daylightIntervals.contains { date >= $0.start && date < $0.end }
}
Expand Down Expand Up @@ -184,7 +203,8 @@ struct SunWidgetData {
daylightDuration: today?.daylightDuration,
daylightChange: today.flatMap { day in yesterday.map { day.daylightDuration - $0.daylightDuration } },
daylightIntervals: mergedIntervals(days.flatMap(\.daylightIntervals)),
skyEvents: days.flatMap(\.skyEvents).sorted { $0.date < $1.date })
skyEvents: days.flatMap(\.skyEvents).sorted { $0.date < $1.date },
isGoldenHour: today?.goldenHourIntervals.contains { date >= $0.start && date < $0.end } ?? false)
}

static func mergedIntervals(_ intervals: [DateInterval]) -> [DateInterval] {
Expand All @@ -200,7 +220,7 @@ struct SunWidgetData {
static func unknown(at date: Date) -> Self {
Self(date: date, locationName: nil, timeZone: .current,
nextEvent: nil, nextRiseOrSet: nil, sunrise: nil, sunset: nil, dayInterval: nil, daylight: nil,
daylightDuration: nil, daylightChange: nil, daylightIntervals: [], skyEvents: [])
daylightDuration: nil, daylightChange: nil, daylightIntervals: [], skyEvents: [], isGoldenHour: false)
}

static func days(from date: Date, location: CLLocationCoordinate2D, timeZone: TimeZone) -> [SunWidgetDay] {
Expand All @@ -220,8 +240,11 @@ struct SunWidgetData {
var dates = stride(from: 0.0, through: 24 * 60 * 60, by: 15 * 60).map {
now.addingTimeInterval($0)
}
// Exact event and local-midnight entries prevent stale labels between arc updates.
dates += days.flatMap { [$0.interval.start] + $0.events.map(\.date) }
// Phase boundaries prevent stale labels and markers between regular updates.
dates += days.flatMap { day in
[day.interval.start, day.localNoon] + day.events.map(\.date)
+ day.goldenHourIntervals.flatMap { [$0.start, $0.end] }
}
.filter { $0 > now && $0 <= end }
return Array(Set(dates)).sorted()
}
Expand Down
58 changes: 58 additions & 0 deletions SunriseSunsetTests/SunWidgetDataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,64 @@ struct SunWidgetDataTests {
return calendar.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour))!
}

@Test func goldenHourMarkerUsesSolarIntervalsAndUpdatesAtBoundaries() throws {
let now = date(hour: 0)
let days = SunWidgetData.days(from: now, location: coordinate, timeZone: zone)
let today = try #require(days.first { $0.interval.start == now })
#expect(today.goldenHourIntervals.count == 2)
let updates = SunWidgetData.timelineDates(from: now, days: days)
for interval in today.goldenHourIntervals {
func snapshot(_ date: Date) -> SunWidgetData {
SunWidgetData.snapshot(at: date, locationName: "Vancouver", timeZone: zone, days: days)
}
#expect(!snapshot(interval.start.addingTimeInterval(-1)).isGoldenHour)
#expect(snapshot(interval.start).isGoldenHour)
#expect(snapshot(interval.end.addingTimeInterval(-1)).isGoldenHour)
#expect(!snapshot(interval.end).isGoldenHour)
#expect(updates.contains(interval.start))
#expect(updates.contains(interval.end))
}
#expect(!SunWidgetData.unknown(at: now).isGoldenHour)
}

@Test func polarGoldenHourDoesNotRequireSunriseOrSunset() throws {
let pole = CLLocationCoordinate2D(latitude: 90, longitude: 0)
for (month, day, expected) in [(3, 25, true), (6, 15, false), (12, 15, false)] {
let now = date(month, day)
let days = SunWidgetData.days(from: now, location: pole, timeZone: .gmt)
let entry = SunWidgetData.snapshot(at: now, locationName: "North Pole", timeZone: .gmt, days: days)
#expect(entry.isGoldenHour == expected)
#expect(entry.sunrise == nil)
#expect(entry.sunset == nil)
}
}

@Test func lightPhaseUsesLocalTimeAndRefreshesAtNoon() {
let now = date(hour: 10)
let days = SunWidgetData.days(from: now, location: coordinate, timeZone: zone)
let noon = days[1].localNoon
func phase(_ date: Date) -> String {
SunWidgetData.snapshot(at: date, locationName: "Vancouver", timeZone: zone, days: days).lightPhase
}
#expect(phase(noon.addingTimeInterval(-1)) == "Morning")
#expect(phase(noon) == "Afternoon")
#expect(SunWidgetData.timelineDates(from: now, days: days).contains(noon))
#expect(phase(date(hour: 23)) == "Night")
let evening = days[1].goldenHourIntervals.last!
#expect(phase(evening.start) == "Golden hour")
#expect(phase(evening.end) == "Twilight")
}

@Test func polarLightPhasePrioritisesGoldenHour() {
let pole = CLLocationCoordinate2D(latitude: 90, longitude: 0)
for (month, day, expected) in [(3, 25, "Golden hour"), (6, 15, "Midnight sun"), (12, 15, "Polar night")] {
let now = date(month, day)
let days = SunWidgetData.days(from: now, location: pole, timeZone: .gmt)
let entry = SunWidgetData.snapshot(at: now, locationName: "North Pole", timeZone: .gmt, days: days)
#expect(entry.lightPhase == expected)
}
}

@Test func eventBoundaryAdvancesAtSunset() {
let now = date()
let days = SunWidgetData.days(from: now, location: coordinate, timeZone: zone)
Expand Down