Skip to content

Instantly share code, notes, and snippets.

@hansfaffing
Last active September 2, 2025 17:17
Show Gist options
  • Save hansfaffing/e22165b6277a61642e410c3bd8801ea1 to your computer and use it in GitHub Desktop.
Save hansfaffing/e22165b6277a61642e410c3bd8801ea1 to your computer and use it in GitHub Desktop.
Drop this into a iOS template app's AppDelegate to create an app that provides the Moon Phase via App Intents. Generally a good simple example of how iOS Swift App Intents work.
// Created by HansFaffing on 9/1/25.
// 1. Drop this into a iOS template app's AppDelegate to create an app that provides the Moon Phase via App Intents.
// - New Project -> iOS -> App (Interface: Storyboard, Language: Swift)
// 2. Update the "identifier" below to be correct
// 3. Load onto the iPhone of your choice
// 4. In iOS Shortcuts create a shortcut that uses the current returning Phase from it:
// - Returned value "Current Moon Phase" can be either: "New", "Full", "Waxing", "Waning", or "Error"
// 5. Use however you please, say thanks to hans maybe, refine this quick sketch of work
import UIKit
import AppIntents
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
}
}
/// These are the String values that the app intent may provide (iff not Error)
public enum Phase {
case New, Full, Waxing, Waning
public var description: String {
switch self {
case .New:
return "New"
case .Full:
return "Full"
case .Waxing:
return "Waxing"
case .Waning:
return "Waning"
}
}
}
/// Provides the shortcut to the system.
struct PhaseShortcut: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: GetPhaseIntent(),
phrases: [
"Get moon phase from \(.applicationName)",],
shortTitle: "Get Moon Phase",
systemImageName: "photo.on.rectangle.angled"
)
}
}
/// Defines the App Intent to get a sample image.
struct GetPhaseIntent: AppIntent {
// TOOD: Update this for your project
static var identifier: String = "com.wolfdog.phaseIntent"
static var title: LocalizedStringResource = "Moon Phase"
static var description: IntentDescription = IntentDescription("Returns the current moon phase for automation: Full, New, Waning, Waxing.",
categoryName: "Weather",
searchKeywords: ["Phase, Moon"],
resultValueName: "Current Moon Phase") // <- variable name in iOS shortcuts
func perform() async throws -> some IntentResult & ReturnsValue<String> {
do {
let phase = try await getCurrentFromAPI().description
return .result(value: phase)
} catch (let e) {
return .result(value: "Error: \(e)")
}
}
public func getCurrentFromAPI() async throws -> Phase {
let urlString = "https://aa.usno.navy.mil/api/moon/phases/date?date=\(formattedDate())&nump=1&id=MoonPhasePlusUltraLite"
guard let url = URL(string: urlString) else { throw LunarError.URLError }
let (data, _) = try await URLSession.shared.data(from: url)
guard let phaseRoot:PhaseRoot = try? JSONDecoder().decode(PhaseRoot.self, from: data) else { throw LunarError.parseError }
guard let nextPhase = phaseRoot.phasedata.first else { throw LunarError.emptyDataError }
var components = DateComponents()
components.year = nextPhase.year
components.month = nextPhase.month
components.day = nextPhase.day
components.hour = Int(nextPhase.time.split(separator: ":").first!)
components.minute = Int(nextPhase.time.split(separator: ":").last!)
let calendar = Calendar.current
guard let nextLunarMilestone = calendar.date(from: components) else { throw LunarError.dateError }
return getCurrentPhaseFromVibesOfTheDates(nextPhaseDate: nextLunarMilestone, phaseName: nextPhase.phase)
}
// TODO: Update this as you wish to tune it better
/// Like it says on the tin; vibes. Change this as you wish obviously. This is sloppy I know <3
private func getCurrentPhaseFromVibesOfTheDates(nextPhaseDate: Date, phaseName: String) -> Phase {
let timeDelta = nextPhaseDate.timeIntervalSinceNow
let isWithinDay: Bool = abs(timeDelta) < 86400 * 1
if phaseName == "Full Moon" {
if isWithinDay { return .Full }
else { return .Waxing }
}
if phaseName == "New Moon" {
if isWithinDay { return .New }
else { return .Waning }
}
if phaseName == "Last Quarter" { return .Waning }
if phaseName == "First Quarter" { return .Waxing }
return .New
}
private func formattedDate() -> String {
let DateFormatter = DateFormatter()
DateFormatter.dateFormat = "YYYY-MM-dd"
return DateFormatter.string(from: Date())
}
}
/// API decode
struct PhaseRoot: Decodable {
let phasedata: [PhaseEvent]
}
/// API decode
struct PhaseEvent: Decodable {
let day: Int // Start of phase
let month: Int
let year: Int
let time: String
let phase: String
}
enum LunarError: Error {
case parseError
case emptyDataError
case URLError
case dateError
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment