diff options
| author | ivar <i@oiee.no> | 2026-05-06 21:01:10 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-05-06 21:01:10 +0200 |
| commit | 01eee1c4fe8252bffc9334e4bb2dbbc15f002835 (patch) | |
| tree | dba40ea6312844c66183043058cfead8b0a5c9d3 | |
| parent | 7328b2e18121d3047ac142eaf0c8b048933d17dc (diff) | |
| download | solverv-01eee1c4fe8252bffc9334e4bb2dbbc15f002835.tar.xz solverv-01eee1c4fe8252bffc9334e4bb2dbbc15f002835.zip | |
feat: add Shared/ folder with merged source files
| -rw-r--r-- | Shared/Models/Season.swift | 49 | ||||
| -rw-r--r-- | Shared/Models/SolsticeData.swift | 96 | ||||
| -rw-r--r-- | Shared/Models/SolsticeEvent.swift | 34 | ||||
| -rw-r--r-- | Shared/Utilities/AppGroupManager.swift | 67 | ||||
| -rw-r--r-- | Shared/Utilities/SunTimes.swift | 96 | ||||
| -rw-r--r-- | Solverv/Assets.xcassets/badeball.imageset/Contents.json | 21 | ||||
| -rw-r--r-- | Solverv/Assets.xcassets/badeball.imageset/Uten navn (86) 3 1.png | bin | 0 -> 10493 bytes |
7 files changed, 363 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) + } +} diff --git a/Solverv/Assets.xcassets/badeball.imageset/Contents.json b/Solverv/Assets.xcassets/badeball.imageset/Contents.json new file mode 100644 index 0000000..d0b9ef5 --- /dev/null +++ b/Solverv/Assets.xcassets/badeball.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Uten navn (86) 3 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Solverv/Assets.xcassets/badeball.imageset/Uten navn (86) 3 1.png b/Solverv/Assets.xcassets/badeball.imageset/Uten navn (86) 3 1.png Binary files differnew file mode 100644 index 0000000..76380ac --- /dev/null +++ b/Solverv/Assets.xcassets/badeball.imageset/Uten navn (86) 3 1.png |
