Skip to content
Open
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
47 changes: 47 additions & 0 deletions Intents/GetNextAltitudeCrossing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// GetNextAltitudeCrossing.swift
// Solstice
//
// Created by Daniel Eden on 24/04/2026.
//

import Foundation
import AppIntents
import CoreLocation

struct GetNextAltitudeCrossing: AppIntent {
static var title: LocalizedStringResource = "Get Next Sun Altitude Crossing"
static var description = IntentDescription("Returns the next time in the following 24 hours that the sun's altitude crosses a given value in a given direction (rising, falling, or either). Returns no value if no such crossing occurs.")

@Parameter(title: "Altitude")
var altitude: Measurement<UnitAngle>

@Parameter(title: "Direction", default: .either)
var direction: AltitudeCrossingDirection

@Parameter(title: "Start Date")
var startDate: Date?

@Parameter(title: "Location")
var location: LocationAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Next time sun altitude crosses \(\.$altitude) (\(\.$direction)) after \(\.$startDate) in \(\.$location)")
}

func perform() async throws -> some IntentResult & ReturnsValue<Date?> {
let start = startDate ?? .now
let (coordinate, timeZone) = try await resolveSunGeometryLocation(location)
let targetDegrees = altitude.converted(to: .degrees).value

let crossing = findNextAltitudeCrossing(
targetDegrees: targetDegrees,
direction: direction,
start: start,
coordinate: coordinate,
timeZone: timeZone
)

return .result(value: crossing)
}
}
47 changes: 47 additions & 0 deletions Intents/GetNextAzimuthCrossing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// GetNextAzimuthCrossing.swift
// Solstice
//
// Created by Daniel Eden on 24/04/2026.
//

import Foundation
import AppIntents
import CoreLocation

struct GetNextAzimuthCrossing: AppIntent {
static var title: LocalizedStringResource = "Get Next Sun Azimuth Crossing"
static var description = IntentDescription("Returns the next time in the following 24 hours that the sun's azimuth (compass bearing) crosses a given value in a given direction (clockwise, counterclockwise, or either). Returns no value if no such crossing occurs.")

@Parameter(title: "Azimuth")
var azimuth: Measurement<UnitAngle>

@Parameter(title: "Direction", default: .either)
var direction: AzimuthCrossingDirection

@Parameter(title: "Start Date")
var startDate: Date?

@Parameter(title: "Location")
var location: LocationAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Next time sun azimuth crosses \(\.$azimuth) (\(\.$direction)) after \(\.$startDate) in \(\.$location)")
}

func perform() async throws -> some IntentResult & ReturnsValue<Date?> {
let start = startDate ?? .now
let (coordinate, timeZone) = try await resolveSunGeometryLocation(location)
let targetDegrees = azimuth.converted(to: .degrees).value

let crossing = findNextAzimuthCrossing(
targetDegrees: targetDegrees,
direction: direction,
start: start,
coordinate: coordinate,
timeZone: timeZone
)

return .result(value: crossing)
}
}
33 changes: 33 additions & 0 deletions Intents/GetSolarNoon.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// GetSolarNoon.swift
// Solstice
//
// Created by Daniel Eden on 24/04/2026.
//

import Foundation
import AppIntents
import CoreLocation

struct GetSolarNoon: AppIntent {
static var title: LocalizedStringResource = "Get Solar Noon"
static var description = IntentDescription("Returns the time at which the sun crosses the local meridian (solar noon) on the given date.")

@Parameter(title: "Date")
var date: Date?

@Parameter(title: "Location")
var location: LocationAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Get solar noon on \(\.$date) in \(\.$location)")
}

func perform() async throws -> some IntentResult & ReturnsValue<Date?> {
let evaluationDate = date ?? .now
let (coordinate, timeZone) = try await resolveSunGeometryLocation(location)

let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone)
return .result(value: solar?.solarNoon)
}
}
37 changes: 37 additions & 0 deletions Intents/GetSunAltitude.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// GetSunAltitude.swift
// Solstice
//
// Created by Daniel Eden on 24/04/2026.
//

import Foundation
import AppIntents
import CoreLocation

struct GetSunAltitude: AppIntent {
static var title: LocalizedStringResource = "Get Sun Altitude"
static var description = IntentDescription("Returns the sun's altitude above the horizon in degrees. Negative values mean the sun is below the horizon.")

@Parameter(title: "Date")
var date: Date?

@Parameter(title: "Location")
var location: LocationAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Get sun altitude at \(\.$date) in \(\.$location)")
}

func perform() async throws -> some IntentResult & ReturnsValue<Measurement<UnitAngle>> {
let evaluationDate = date ?? .now
let (coordinate, timeZone) = try await resolveSunGeometryLocation(location)

guard let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) else {
return .result(value: Measurement(value: 0, unit: .degrees))
}

let degrees = solar.altitude(at: evaluationDate)
return .result(value: Measurement(value: degrees, unit: .degrees))
}
}
37 changes: 37 additions & 0 deletions Intents/GetSunAzimuth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// GetSunAzimuth.swift
// Solstice
//
// Created by Daniel Eden on 24/04/2026.
//

import Foundation
import AppIntents
import CoreLocation

struct GetSunAzimuth: AppIntent {
static var title: LocalizedStringResource = "Get Sun Azimuth"
static var description = IntentDescription("Returns the sun's compass bearing (azimuth) in degrees, measured clockwise from true north. 0° = North, 90° = East, 180° = South, 270° = West.")

@Parameter(title: "Date")
var date: Date?

@Parameter(title: "Location")
var location: LocationAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Get sun azimuth at \(\.$date) in \(\.$location)")
}

func perform() async throws -> some IntentResult & ReturnsValue<Measurement<UnitAngle>> {
let evaluationDate = date ?? .now
let (coordinate, timeZone) = try await resolveSunGeometryLocation(location)

guard let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) else {
return .result(value: Measurement(value: 0, unit: .degrees))
}

let degrees = solar.azimuth(at: evaluationDate)
return .result(value: Measurement(value: degrees, unit: .degrees))
}
}
36 changes: 36 additions & 0 deletions Intents/GetSunPositionAtTime.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// GetSunPositionAtTime.swift
// Solstice
//
// Created by Daniel Eden on 24/04/2026.
//

import Foundation
import AppIntents
import CoreLocation

struct GetSunPositionAtTime: AppIntent {
static var title: LocalizedStringResource = "Get Sun Position"
static var description = IntentDescription("Returns both the sun's altitude and azimuth at a given date and location, so downstream Shortcut actions can consume either as a magic variable.")

@Parameter(title: "Date")
var date: Date

@Parameter(title: "Location")
var location: LocationAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Get sun position at \(\.$date) in \(\.$location)")
}

func perform() async throws -> some IntentResult & ReturnsValue<SunPositionEntity> {
let (coordinate, timeZone) = try await resolveSunGeometryLocation(location)

guard let solar = NTSolar(for: date, coordinate: coordinate, timeZone: timeZone) else {
return .result(value: SunPositionEntity())
}

let (altitude, azimuth) = solar.altitudeAndAzimuth(at: date)
return .result(value: SunPositionEntity(altitudeDegrees: altitude, azimuthDegrees: azimuth))
}
}
50 changes: 50 additions & 0 deletions Intents/IsSunOnBearing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// IsSunOnBearing.swift
// Solstice
//
// Created by Daniel Eden on 24/04/2026.
//

import Foundation
import AppIntents
import CoreLocation

struct IsSunOnBearing: AppIntent {
static var title: LocalizedStringResource = "Is Sun Shining On Bearing"
static var description = IntentDescription("Returns true if the sun is above the horizon and its azimuth is within a given tolerance of a compass bearing. Useful for automations like \"is the sun shining on my east-facing wall?\".")

@Parameter(title: "Bearing")
var bearing: Measurement<UnitAngle>

@Parameter(title: "Tolerance")
var tolerance: Measurement<UnitAngle>?

@Parameter(title: "Date")
var date: Date?

@Parameter(title: "Location")
var location: LocationAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Is the sun shining on bearing \(\.$bearing) (±\(\.$tolerance)) at \(\.$date) in \(\.$location)")
}

func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
let evaluationDate = date ?? .now
let (coordinate, timeZone) = try await resolveSunGeometryLocation(location)

guard let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) else {
return .result(value: false)
}

let (altitude, azimuth) = solar.altitudeAndAzimuth(at: evaluationDate)
guard altitude > 0 else {
return .result(value: false)
}

let bearingDegrees = bearing.converted(to: .degrees).value
let toleranceDegrees = abs((tolerance ?? Measurement(value: 60, unit: .degrees)).converted(to: .degrees).value)
let diff = signedAngularDifference(from: bearingDegrees, to: azimuth)
return .result(value: abs(diff) <= toleranceDegrees)
}
}
36 changes: 36 additions & 0 deletions Intents/IsSunUp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// IsSunUp.swift
// Solstice
//
// Created by Daniel Eden on 24/04/2026.
//

import Foundation
import AppIntents
import CoreLocation

struct IsSunUp: AppIntent {
static var title: LocalizedStringResource = "Is Sun Up"
static var description = IntentDescription("Returns true if the sun is currently above the horizon at the given location.")

@Parameter(title: "Date")
var date: Date?

@Parameter(title: "Location")
var location: LocationAppEntity?

static var parameterSummary: some ParameterSummary {
Summary("Is the sun up at \(\.$date) in \(\.$location)")
}

func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
let evaluationDate = date ?? .now
let (coordinate, timeZone) = try await resolveSunGeometryLocation(location)

guard let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) else {
return .result(value: false)
}

return .result(value: solar.altitude(at: evaluationDate) > 0)
}
}
51 changes: 51 additions & 0 deletions Intents/SolsticeShortcutsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,57 @@ struct SolsticeShortcutsProvider: AppShortcutsProvider {
shortTitle: "View remaining daylight",
systemImageName: "timer"
),
AppShortcut(
intent: GetSunAltitude(),
phrases: [
"Get sun altitude from \(.applicationName)",
"What's the sun's altitude in \(.applicationName)",
],
shortTitle: "Get sun altitude",
systemImageName: "arrow.up.forward"
),
AppShortcut(
intent: GetSunAzimuth(),
phrases: [
"Get sun azimuth from \(.applicationName)",
"What's the sun's azimuth in \(.applicationName)",
],
shortTitle: "Get sun azimuth",
systemImageName: "safari"
),
AppShortcut(
intent: IsSunUp(),
phrases: [
"Is the sun up in \(.applicationName)",
],
shortTitle: "Is sun up",
systemImageName: "sun.horizon"
),
AppShortcut(
intent: GetSolarNoon(),
phrases: [
"Get solar noon from \(.applicationName)",
"When is solar noon in \(.applicationName)",
],
shortTitle: "Get solar noon",
systemImageName: "sun.max.fill"
),
AppShortcut(
intent: IsSunOnBearing(),
phrases: [
"Is the sun shining on a bearing in \(.applicationName)",
],
shortTitle: "Is sun on bearing",
systemImageName: "sun.max.circle"
),
AppShortcut(
intent: GetSunPositionAtTime(),
phrases: [
"Get sun position from \(.applicationName)",
],
shortTitle: "Get sun position",
systemImageName: "location.north.line"
),
]
}
}
Loading