Last active
September 2, 2025 17:17
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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