From 94bf65b7e6b0cb683ea9c952aed86b034d1c8363 Mon Sep 17 00:00:00 2001 From: ivar Date: Mon, 23 Mar 2026 16:47:59 +0100 Subject: feat: implement SolvervDef methods and update widget timeline to refresh at midnight --- Solsnu.Widget/Models/Season.swift | 49 +++++++++++++ Solsnu.Widget/Models/SolsticeData.swift | 114 +++++++++++++++++++++++++++++++ Solsnu.Widget/Models/SolsticeEvent.swift | 38 +++++++++++ Solsnu.Widget/Solsnu_Widget.swift | 45 ++++++++---- 4 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 Solsnu.Widget/Models/Season.swift create mode 100644 Solsnu.Widget/Models/SolsticeData.swift create mode 100644 Solsnu.Widget/Models/SolsticeEvent.swift (limited to 'Solsnu.Widget') diff --git a/Solsnu.Widget/Models/Season.swift b/Solsnu.Widget/Models/Season.swift new file mode 100644 index 0000000..01eaf99 --- /dev/null +++ b/Solsnu.Widget/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) // #4CAF50 + case .summer: return Color(red: 1.0, green: 0.761, blue: 0.039) // #FFC107 + case .autumn: return Color(red: 1.0, green: 0.596, blue: 0.0) // #FF9800 + case .winter: return Color(red: 0.129, green: 0.588, blue: 0.953) // #2196F3 + } + } + + 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/Solsnu.Widget/Models/SolsticeData.swift b/Solsnu.Widget/Models/SolsticeData.swift new file mode 100644 index 0000000..5a36da7 --- /dev/null +++ b/Solsnu.Widget/Models/SolsticeData.swift @@ -0,0 +1,114 @@ +import Foundation + +class SolsticeData { + static let shared = SolsticeData() + + private let events: [SolsticeEvent] + + private init() { + // Hardcoded solstice/equinox events for 2025-2030 (all in UTC) + var allEvents: [SolsticeEvent] = [] + + // 2025 + allEvents.append(SolsticeEvent(name: "Spring Equinox 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 3, day: 20, hour: 9, minute: 1), season: .spring)) + allEvents.append(SolsticeEvent(name: "Summer Solstice 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 6, day: 20, hour: 14, minute: 42), season: .summer)) + allEvents.append(SolsticeEvent(name: "Autumn Equinox 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 9, day: 22, hour: 18, minute: 20), season: .autumn)) + allEvents.append(SolsticeEvent(name: "Winter Solstice 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 12, day: 21, hour: 15, minute: 3), season: .winter)) + + // 2026 + allEvents.append(SolsticeEvent(name: "Spring Equinox 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 3, day: 20, hour: 14, minute: 46), season: .spring)) + allEvents.append(SolsticeEvent(name: "Summer Solstice 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 6, day: 21, hour: 8, minute: 25), season: .summer)) + allEvents.append(SolsticeEvent(name: "Autumn Equinox 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 9, day: 23, hour: 0, minute: 6), season: .autumn)) + allEvents.append(SolsticeEvent(name: "Winter Solstice 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 12, day: 21, hour: 20, minute: 50), season: .winter)) + + // 2027 + allEvents.append(SolsticeEvent(name: "Spring Equinox 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 3, day: 20, hour: 20, minute: 25), season: .spring)) + allEvents.append(SolsticeEvent(name: "Summer Solstice 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 6, day: 21, hour: 14, minute: 11), season: .summer)) + allEvents.append(SolsticeEvent(name: "Autumn Equinox 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 9, day: 23, hour: 6, minute: 2), season: .autumn)) + allEvents.append(SolsticeEvent(name: "Winter Solstice 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 12, day: 22, hour: 2, minute: 43), season: .winter)) + + // 2028 + allEvents.append(SolsticeEvent(name: "Spring Equinox 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 3, day: 20, hour: 2, minute: 17), season: .spring)) + allEvents.append(SolsticeEvent(name: "Summer Solstice 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 6, day: 20, hour: 20, minute: 2), season: .summer)) + allEvents.append(SolsticeEvent(name: "Autumn Equinox 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 9, day: 22, hour: 11, minute: 45), season: .autumn)) + allEvents.append(SolsticeEvent(name: "Winter Solstice 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 12, day: 21, hour: 8, minute: 20), season: .winter)) + + // 2029 + allEvents.append(SolsticeEvent(name: "Spring Equinox 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 3, day: 20, hour: 8, minute: 1), season: .spring)) + allEvents.append(SolsticeEvent(name: "Summer Solstice 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 6, day: 21, hour: 1, minute: 48), season: .summer)) + allEvents.append(SolsticeEvent(name: "Autumn Equinox 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 9, day: 22, hour: 17, minute: 37), season: .autumn)) + allEvents.append(SolsticeEvent(name: "Winter Solstice 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 12, day: 21, hour: 14, minute: 14), season: .winter)) + + // 2030 + allEvents.append(SolsticeEvent(name: "Spring Equinox 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 3, day: 20, hour: 13, minute: 51), season: .spring)) + allEvents.append(SolsticeEvent(name: "Summer Solstice 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 6, day: 21, hour: 7, minute: 31), season: .summer)) + allEvents.append(SolsticeEvent(name: "Autumn Equinox 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 9, day: 22, hour: 23, minute: 27), season: .autumn)) + allEvents.append(SolsticeEvent(name: "Winter Solstice 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 12, day: 21, hour: 20, minute: 9), season: .winter)) + + // Sort events by date + self.events = allEvents.sorted { $0.date < $1.date } + } + + /// Helper function to create UTC dates (static to be usable during init) + 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() + } + + /// Returns the next upcoming solstice/equinox event + func nextEvent() -> SolsticeEvent? { + let now = Date() + return events.first { $0.date > now } + } + + /// Returns the next N upcoming events + func upcomingEvents(count: Int) -> [SolsticeEvent] { + let now = Date() + let futureEvents = events.filter { $0.date > now } + return Array(futureEvents.prefix(count)) + } + + /// Returns the progress to the next event as (elapsed days, total days) + func progressToNextEvent() -> (elapsed: Int, total: Int)? { + guard let nextEvent = nextEvent() else { + return nil + } + + let now = Date() + let today = Calendar.current.startOfDay(for: now) + let eventDay = Calendar.current.startOfDay(for: nextEvent.date) + + // Find the previous event to calculate total days + guard let previousEventIndex = events.firstIndex(where: { $0.date > now }) else { + return nil + } + + let previousEvent: Date + if previousEventIndex > 0 { + previousEvent = events[previousEventIndex - 1].date + } else { + // If this is the first event, we need to handle this case + // Use the event itself minus some arbitrary period (not applicable for first event) + return nil + } + + let previousEventDay = Calendar.current.startOfDay(for: previousEvent) + + // Calculate elapsed and total days + 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/Solsnu.Widget/Models/SolsticeEvent.swift b/Solsnu.Widget/Models/SolsticeEvent.swift new file mode 100644 index 0000000..d8c4a7b --- /dev/null +++ b/Solsnu.Widget/Models/SolsticeEvent.swift @@ -0,0 +1,38 @@ +import Foundation + +struct SolsticeEvent: Identifiable, Codable { + let id: UUID + let name: String + let date: Date // UTC + let season: Season + + init(name: String, date: Date, season: Season) { + self.id = UUID() + self.name = name + self.date = date + self.season = season + } + + /// Convert UTC date to user's local timezone + 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 + } + + /// Days until this event from today + func daysUntil() -> Int { + let today = Calendar.current.startOfDay(for: Date()) + 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/Solsnu.Widget/Solsnu_Widget.swift b/Solsnu.Widget/Solsnu_Widget.swift index 1021db1..980725e 100644 --- a/Solsnu.Widget/Solsnu_Widget.swift +++ b/Solsnu.Widget/Solsnu_Widget.swift @@ -21,13 +21,21 @@ struct Provider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { var entries: [SolvervEntry] = [] let currentDate = Date() - for hourOffset in 0 ..< 5 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SolvervEntry(def: SolvervDef(date: currentDate)) - entries.append(entry) - } - let timeline = Timeline(entries: entries, policy: .atEnd) + // Single entry for today + let entry = SolvervEntry(def: SolvervDef(date: currentDate)) + entries.append(entry) + + // Calculate next midnight for refresh + let calendar = Calendar.current + var components = calendar.dateComponents([.year, .month, .day], from: currentDate) + components.hour = 0 + components.minute = 0 + components.second = 0 + let todayMidnight = calendar.date(from: components)! + let nextMidnight = calendar.date(byAdding: .day, value: 1, to: todayMidnight)! + + let timeline = Timeline(entries: entries, policy: .after(nextMidnight)) completion(timeline) } } @@ -133,20 +141,31 @@ struct SolvervDef { } extension SolvervDef { - var season: String { - // Will be fully implemented in Task 7 - return "winter" + var season: Season { + guard let next = nextEvent else { return .winter } + return next.season + } + + var nextEvent: SolsticeEvent? { + SolsticeData.shared.nextEvent() } func daysUntilNext() -> Int { - return 0 + guard let next = nextEvent else { return 0 } + return next.daysUntil() } func progressRatio() -> Double { - return 0.0 + guard let progress = SolsticeData.shared.progressToNextEvent() else { return 0.0 } + let ratio = Double(progress.elapsed) / Double(progress.total) + return max(0, min(1.0, ratio)) + } + + func upcomingEventsPreview(count: Int) -> [SolsticeEvent] { + SolsticeData.shared.upcomingEvents(count: count) } - func upcomingEventsPreview(count: Int) -> [String] { - return [] + static var preview: SolvervDef { + SolvervDef(date: Date()) } } -- cgit v1.3