summaryrefslogtreecommitdiffstats
path: root/Shared
diff options
context:
space:
mode:
Diffstat (limited to 'Shared')
-rw-r--r--Shared/Models/Season.swift49
-rw-r--r--Shared/Models/SolsticeData.swift96
-rw-r--r--Shared/Models/SolsticeEvent.swift34
-rw-r--r--Shared/Utilities/AppGroupManager.swift67
-rw-r--r--Shared/Utilities/SunTimes.swift96
5 files changed, 342 insertions, 0 deletions
diff --git a/Shared/Models/Season.swift b/Shared/Models/Season.swift
new file mode 100644
index 0000000..c956fed
--- /dev/null
+++ b/Shared/Models/Season.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+
+enum Season: String, Codable {
+ case spring
+ case summer
+ case autumn
+ case winter
+
+ var displayName: String {
+ switch self {
+ case .spring: return "Spring"
+ case .summer: return "Summer"
+ case .autumn: return "Autumn"
+ case .winter: return "Winter"
+ }
+ }
+
+ var description: String {
+ switch self {
+ case .spring: return "Day and night are approximately equal length"
+ case .summer: return "Longest day of the year"
+ case .autumn: return "Day and night are approximately equal length"
+ case .winter: return "Shortest day of the year"
+ }
+ }
+
+ var colorLight: Color {
+ switch self {
+ case .spring: return Color(red: 0.298, green: 0.686, blue: 0.314)
+ case .summer: return Color(red: 1.0, green: 0.761, blue: 0.039)
+ case .autumn: return Color(red: 1.0, green: 0.596, blue: 0.0)
+ case .winter: return Color(red: 0.129, green: 0.588, blue: 0.953)
+ }
+ }
+
+ var assetName: String {
+ return "Season\(displayName)"
+ }
+
+ static func fromDate(_ date: Date) -> Season {
+ let month = Calendar.current.component(.month, from: date)
+ switch month {
+ case 3, 4, 5: return .spring
+ case 6, 7, 8: return .summer
+ case 9, 10, 11: return .autumn
+ default: return .winter
+ }
+ }
+}
diff --git a/Shared/Models/SolsticeData.swift b/Shared/Models/SolsticeData.swift
new file mode 100644
index 0000000..9285aad
--- /dev/null
+++ b/Shared/Models/SolsticeData.swift
@@ -0,0 +1,96 @@
+import Foundation
+
+class SolsticeData {
+ static let shared = SolsticeData()
+
+ private let events: [SolsticeEvent]
+
+ private init() {
+ var allEvents: [SolsticeEvent] = []
+
+ // 2025
+ allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 3, day: 20, hour: 9, minute: 1), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Sommersolverv 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 6, day: 20, hour: 14, minute: 42), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 9, day: 22, hour: 18, minute: 20), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Vintersolverv 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 12, day: 21, hour: 15, minute: 3), season: .winter))
+
+ // 2026
+ allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 3, day: 20, hour: 14, minute: 46), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Sommersolverv 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 6, day: 21, hour: 8, minute: 25), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 9, day: 23, hour: 0, minute: 6), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Vintersolverv 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 12, day: 21, hour: 20, minute: 50), season: .winter))
+
+ // 2027
+ allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 3, day: 20, hour: 20, minute: 25), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Sommersolverv 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 6, day: 21, hour: 14, minute: 11), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 9, day: 23, hour: 6, minute: 2), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Vintersolverv 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 12, day: 22, hour: 2, minute: 43), season: .winter))
+
+ // 2028
+ allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 3, day: 20, hour: 2, minute: 17), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Sommersolverv 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 6, day: 20, hour: 20, minute: 2), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 9, day: 22, hour: 11, minute: 45), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Vintersolverv 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 12, day: 21, hour: 8, minute: 20), season: .winter))
+
+ // 2029
+ allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 3, day: 20, hour: 8, minute: 1), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Sommersolverv 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 6, day: 21, hour: 1, minute: 48), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 9, day: 22, hour: 17, minute: 37), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Vintersolverv 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 12, day: 21, hour: 14, minute: 14), season: .winter))
+
+ // 2030
+ allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 3, day: 20, hour: 13, minute: 51), season: .spring))
+ allEvents.append(SolsticeEvent(name: "Sommersolverv 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 6, day: 21, hour: 7, minute: 31), season: .summer))
+ allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 9, day: 22, hour: 23, minute: 27), season: .autumn))
+ allEvents.append(SolsticeEvent(name: "Vintersolverv 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 12, day: 21, hour: 20, minute: 9), season: .winter))
+
+ self.events = allEvents.sorted { $0.date < $1.date }
+ }
+
+ private static func dateFromUTC(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date {
+ var components = DateComponents()
+ components.year = year
+ components.month = month
+ components.day = day
+ components.hour = hour
+ components.minute = minute
+ components.second = 0
+ components.timeZone = TimeZone(abbreviation: "UTC")
+ return Calendar(identifier: .gregorian).date(from: components) ?? Date()
+ }
+
+ func nextEvent(from now: Date = Date()) -> SolsticeEvent? {
+ return events.first { $0.date > now }
+ }
+
+ func upcomingEvents(count: Int, from now: Date = Date()) -> [SolsticeEvent] {
+ let futureEvents = events.filter { $0.date > now }
+ return Array(futureEvents.prefix(count))
+ }
+
+ func progressToNextEvent(from now: Date = Date()) -> (elapsed: Int, total: Int)? {
+ guard let _ = nextEvent(from: now) else { return nil }
+
+ let today = Calendar.current.startOfDay(for: now)
+
+ guard let previousEventIndex = events.firstIndex(where: { $0.date > now }) else {
+ return nil
+ }
+
+ guard previousEventIndex > 0 else { return nil }
+
+ let nextEvent = events[previousEventIndex]
+ let previousEvent = events[previousEventIndex - 1]
+
+ let previousEventDay = Calendar.current.startOfDay(for: previousEvent.date)
+ let eventDay = Calendar.current.startOfDay(for: nextEvent.date)
+
+ let elapsedComponents = Calendar.current.dateComponents([.day], from: previousEventDay, to: today)
+ let totalComponents = Calendar.current.dateComponents([.day], from: previousEventDay, to: eventDay)
+
+ let elapsed = max(0, elapsedComponents.day ?? 0)
+ let total = max(1, totalComponents.day ?? 1)
+
+ return (elapsed: elapsed, total: total)
+ }
+}
diff --git a/Shared/Models/SolsticeEvent.swift b/Shared/Models/SolsticeEvent.swift
new file mode 100644
index 0000000..b28cba8
--- /dev/null
+++ b/Shared/Models/SolsticeEvent.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+struct SolsticeEvent: Identifiable, Codable {
+ let id: UUID
+ let name: String
+ let date: Date
+ let season: Season
+
+ init(name: String, date: Date, season: Season) {
+ self.id = UUID()
+ self.name = name
+ self.date = date
+ self.season = season
+ }
+
+ func localDateTime() -> Date {
+ let utcCalendar = Calendar(identifier: .gregorian)
+ let utcComponents = utcCalendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
+ let timeZone = TimeZone.current
+ let offset = timeZone.secondsFromGMT(for: date)
+ var localCalendar = Calendar.current
+ localCalendar.timeZone = timeZone
+ var localComponents = utcComponents
+ localComponents.second = (localComponents.second ?? 0) + offset
+ return localCalendar.date(from: localComponents) ?? date
+ }
+
+ func daysUntil(from now: Date = Date()) -> Int {
+ let today = Calendar.current.startOfDay(for: now)
+ let eventDay = Calendar.current.startOfDay(for: date)
+ let components = Calendar.current.dateComponents([.day], from: today, to: eventDay)
+ return max(0, components.day ?? 0)
+ }
+}
diff --git a/Shared/Utilities/AppGroupManager.swift b/Shared/Utilities/AppGroupManager.swift
new file mode 100644
index 0000000..69b5d73
--- /dev/null
+++ b/Shared/Utilities/AppGroupManager.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+class AppGroupManager {
+ static let shared = AppGroupManager()
+ static let appGroupID = "group.com.ivarlovlie.solverv"
+
+ private lazy var userDefaults: UserDefaults? = {
+ UserDefaults(suiteName: Self.appGroupID)
+ }()
+
+ // MARK: - Location Storage
+
+ struct UserLocation: Codable {
+ let latitude: Double
+ let longitude: Double
+ let timestamp: String
+ let isDefaultLocation: Bool
+ }
+
+ func saveLocation(_ location: UserLocation) {
+ guard let ud = userDefaults else { return }
+ if let encoded = try? JSONEncoder().encode(location) {
+ ud.set(encoded, forKey: "userLocation")
+ }
+ }
+
+ func getLocation() -> UserLocation? {
+ guard let ud = userDefaults,
+ let data = ud.data(forKey: "userLocation"),
+ let location = try? JSONDecoder().decode(UserLocation.self, from: data) else {
+ return nil
+ }
+ return location
+ }
+
+ // MARK: - Sunrise/Sunset Storage
+
+ struct SunTimes: Codable {
+ let date: String
+ let sunrise: String
+ let sunset: String
+ let timestamp: String
+ }
+
+ func saveSunTimes(_ sunTimes: SunTimes) {
+ guard let ud = userDefaults else { return }
+ if let encoded = try? JSONEncoder().encode(sunTimes) {
+ ud.set(encoded, forKey: "sunTimes")
+ }
+ }
+
+ func getSunTimes() -> SunTimes? {
+ guard let ud = userDefaults,
+ let data = ud.data(forKey: "sunTimes"),
+ let sunTimes = try? JSONDecoder().decode(SunTimes.self, from: data) else {
+ return nil
+ }
+ return sunTimes
+ }
+
+ // MARK: - Helpers
+
+ func clearAllData() {
+ userDefaults?.removeObject(forKey: "userLocation")
+ userDefaults?.removeObject(forKey: "sunTimes")
+ }
+}
diff --git a/Shared/Utilities/SunTimes.swift b/Shared/Utilities/SunTimes.swift
new file mode 100644
index 0000000..bbb7a8d
--- /dev/null
+++ b/Shared/Utilities/SunTimes.swift
@@ -0,0 +1,96 @@
+import Foundation
+
+class SunTimes {
+ let latitude: Double
+ let longitude: Double
+ let date: Date
+
+ init(latitude: Double, longitude: Double, date: Date) {
+ self.latitude = latitude
+ self.longitude = longitude
+ self.date = date
+ }
+
+ func sunrise() -> Date? {
+ return calculateSunTimes()?.sunrise
+ }
+
+ func sunset() -> Date? {
+ return calculateSunTimes()?.sunset
+ }
+
+ // NOAA solar position algorithm with standard horizon correction.
+ // Uses zenith = 90.833° to account for atmospheric refraction (~0.5666°)
+ // and solar disk radius (~0.2667°), matching skyfield's almanac.sunrise_sunset.
+ // Reference: https://www.esrl.noaa.gov/gmd/grad/solcalc/
+ private func calculateSunTimes() -> (sunrise: Date, sunset: Date)? {
+ let calendar = Calendar(identifier: .gregorian)
+ let components = calendar.dateComponents([.year, .month, .day], from: date)
+ guard let year = components.year else { return nil }
+
+ let jan1 = calendar.date(from: DateComponents(year: year, month: 1, day: 1))!
+ let dayOfYear = calendar.dateComponents([.day], from: jan1, to: date).day! + 1
+
+ let daysInYear = Double(isLeapYear(year) ? 366 : 365)
+ let gamma = 2.0 * Double.pi * Double(dayOfYear - 1) / daysInYear
+
+ let decl = 0.006918 - 0.399912 * cos(gamma) + 0.070257 * sin(gamma)
+ - 0.006758 * cos(2.0 * gamma) + 0.000907 * sin(2.0 * gamma)
+ - 0.002697 * cos(3.0 * gamma) + 0.00148 * sin(3.0 * gamma)
+
+ let eot = 229.18 * (0.000075 + 0.001868 * cos(gamma) - 0.032077 * sin(gamma)
+ - 0.014615 * cos(2.0 * gamma) - 0.040849 * sin(2.0 * gamma))
+
+ let zenith = 90.833 * Double.pi / 180.0
+ let latRad = latitude * Double.pi / 180.0
+ let cosH = (cos(zenith) - sin(latRad) * sin(decl)) / (cos(latRad) * cos(decl))
+
+ guard cosH >= -1.0 && cosH <= 1.0 else { return nil }
+
+ let h = acos(cosH) * 180.0 / Double.pi
+
+ let solarNoonUTC = 12.0 - (longitude / 15.0) - (eot / 60.0)
+ let sunriseUTC = solarNoonUTC - (h / 15.0)
+ let sunsetUTC = solarNoonUTC + (h / 15.0)
+
+ let tzOffset = Double(TimeZone.current.secondsFromGMT(for: date)) / 3600.0
+ let sunriseLocal = sunriseUTC + tzOffset
+ let sunsetLocal = sunsetUTC + tzOffset
+
+ let currentCalendar = Calendar.current
+ let baseComponents = currentCalendar.dateComponents([.year, .month, .day], from: date)
+
+ guard let sunriseDate = buildDate(from: baseComponents, decimalHours: sunriseLocal, calendar: currentCalendar),
+ let sunsetDate = buildDate(from: baseComponents, decimalHours: sunsetLocal, calendar: currentCalendar) else {
+ return nil
+ }
+
+ return (sunriseDate, sunsetDate)
+ }
+
+ private func buildDate(from base: DateComponents, decimalHours: Double, calendar: Calendar) -> Date? {
+ let hour = Int(decimalHours)
+ let minute = Int((decimalHours - Double(hour)) * 60.0)
+ var comps = base
+
+ if hour < 0 {
+ guard let prevDay = calendar.date(byAdding: .day, value: -1, to: date) else { return nil }
+ comps = calendar.dateComponents([.year, .month, .day], from: prevDay)
+ comps.hour = 24 + hour
+ } else if hour >= 24 {
+ guard let nextDay = calendar.date(byAdding: .day, value: 1, to: date) else { return nil }
+ comps = calendar.dateComponents([.year, .month, .day], from: nextDay)
+ comps.hour = hour - 24
+ } else {
+ comps.hour = hour
+ }
+ comps.minute = max(0, min(59, minute))
+ comps.second = 0
+
+ return calendar.date(from: comps)
+ }
+
+ private func isLeapYear(_ year: Int) -> Bool {
+ return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
+ }
+}